File: Dcp\ResourceSnapshotBuilderTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Tests\Aspire.Hosting.Tests.csproj (Aspire.Hosting.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
#pragma warning disable ASPIREPROJECTS001 // Project launch defaults are experimental but needed to verify snapshot emission.
#pragma warning disable ASPIREEXTENSION001 // Debug support annotations are experimental.
 
using Aspire.Dashboard.Model;
using Aspire.Hosting.Dcp;
using Aspire.Hosting.Dcp.Model;
using DcpCustomResource = Aspire.Hosting.Dcp.Model.CustomResource;
using DcpResourceSnapshotBuilder = Aspire.Hosting.Dcp.ResourceSnapshotBuilder;
 
namespace Aspire.Hosting.Tests.Dcp;
 
[Trait("Partition", "4")]
public class ResourceSnapshotBuilderTests
{
    private const string DcpTemplateArgument = "{{- portForServing \"exe\" -}}";
    private const string ResolvedPortArgument = "52731";
 
    [Fact]
    public void ContainerSnapshotAddsDisplayMetadataForDashboardProperties()
    {
        var container = Container.Create("container", "redis:latest");
        container.Spec.Command = "redis-server";
        container.Spec.Ports = [new() { ContainerPort = 6379 }];
        container.Spec.Persistent = true;
        container.Status = new ContainerStatus
        {
            ContainerId = "1234567890abcdef",
            EffectiveArgs = ["--appendonly", "yes"]
        };
        var snapshot = CreateSnapshotBuilder().ToSnapshot(container, CreatePreviousSnapshot());
 
        AssertHighlightedProperty(snapshot, KnownProperties.Container.Image, "Container image", isSensitive: false, sortOrder: 0);
        AssertHighlightedProperty(snapshot, KnownProperties.Container.Id, "Container ID", isSensitive: false, sortOrder: 1);
        AssertHighlightedProperty(snapshot, KnownProperties.Container.Command, "Container command", isSensitive: false, sortOrder: 2);
        AssertHighlightedProperty(snapshot, KnownProperties.Container.Args, "Container arguments", isSensitive: true, sortOrder: 3);
        AssertHighlightedProperty(snapshot, KnownProperties.Container.Ports, "Container ports", isSensitive: false, sortOrder: 4);
        AssertHighlightedProperty(snapshot, KnownProperties.Container.Lifetime, "Container lifetime", isSensitive: false, sortOrder: 5);
    }
 
    [Fact]
    public void ExecutableSnapshotAddsDisplayMetadataForDashboardProperties()
    {
        var executable = Executable.Create("exe", "dotnet");
        executable.Spec.WorkingDirectory = "/app";
        executable.Status = new ExecutableStatus
        {
            EffectiveArgs = ["run"],
            ProcessId = 1234
        };
        var snapshot = CreateSnapshotBuilder().ToSnapshot(executable, CreatePreviousSnapshot());
 
        AssertHighlightedProperty(snapshot, KnownProperties.Executable.Path, "Executable path", isSensitive: false, sortOrder: 0);
        AssertHighlightedProperty(snapshot, KnownProperties.Executable.WorkDir, "Working directory", isSensitive: false, sortOrder: 1);
        AssertHighlightedProperty(snapshot, KnownProperties.Executable.Args, "Executable arguments", isSensitive: true, sortOrder: 2);
        AssertHighlightedProperty(snapshot, KnownProperties.Executable.Pid, "Process ID", isSensitive: false, sortOrder: 3);
    }
 
    [Fact]
    public void ProjectSnapshotAddsDisplayMetadataForDashboardProperties()
    {
        var project = new ProjectResource("project");
        project.Annotations.Add(new TestProjectMetadata());
        project.Annotations.Add(new LaunchProfileAnnotation("https"));
 
        var executable = Executable.Create("project", "dotnet");
        executable.Annotate(DcpCustomResource.ResourceNameAnnotation, project.Name);
        executable.Spec.WorkingDirectory = "/app";
        executable.Status = new ExecutableStatus
        {
            EffectiveArgs = ["run"],
            ProcessId = 1234
        };
 
        var snapshot = CreateSnapshotBuilder(new Dictionary<string, IResource>
        {
            [project.Name] = project
        }).ToSnapshot(executable, CreatePreviousSnapshot());
 
        AssertDefaultProperty(snapshot, KnownProperties.Executable.Path, isSensitive: false);
        AssertDefaultProperty(snapshot, KnownProperties.Executable.WorkDir, isSensitive: false);
        AssertDefaultProperty(snapshot, KnownProperties.Executable.Args, isSensitive: true);
        AssertHighlightedProperty(snapshot, KnownProperties.Project.Path, "Project path", isSensitive: false, sortOrder: 0);
        AssertHighlightedProperty(snapshot, KnownProperties.Project.LaunchProfile, "Launch profile", isSensitive: false, sortOrder: 1);
        AssertHighlightedProperty(snapshot, KnownProperties.Executable.Pid, "Process ID", isSensitive: false, sortOrder: 2);
    }
 
    [Fact]
    public void ProjectSnapshotIncludesLaunchConfigurationTypeForDebuggableProject()
    {
        var builder = DistributedApplication.CreateBuilder();
        var project = builder.AddResource(new ProjectResource("project"));
        project.Resource.Annotations.Add(new TestProjectMetadata());
        var configuredProject = project.WithProjectDefaults(new ProjectResourceOptions { ExcludeLaunchProfile = true });
 
        var executable = Executable.Create("project", "dotnet");
        executable.Annotate(DcpCustomResource.ResourceNameAnnotation, configuredProject.Resource.Name);
        executable.Status = new ExecutableStatus
        {
            EffectiveArgs = ["run"],
            ProcessId = 1234
        };
 
        var snapshot = CreateSnapshotBuilder(new Dictionary<string, IResource>
        {
            [configuredProject.Resource.Name] = configuredProject.Resource
        }).ToSnapshot(executable, CreatePreviousSnapshot());
 
        var launchConfigurationType = Assert.Single(snapshot.Properties, p => p.Name == KnownProperties.Resource.LaunchConfigurationType);
        Assert.Equal("project", Assert.IsType<string>(launchConfigurationType.Value));
    }
 
    [Theory]
    [InlineData(KnownLaunchConfigurationTypes.Project, null)]
    [InlineData(KnownLaunchConfigurationTypes.Project, "Release")]
    [InlineData(KnownLaunchConfigurationTypes.ProjectWithExternalBuild, "Release")]
    public void ProjectSnapshotIncludesIdeDotNetLaunchMetadata(string launchConfigurationType, string? configuration)
    {
        var builder = DistributedApplication.CreateBuilder();
        var project = builder.AddResource(new ProjectResource("project"));
        project.Resource.Annotations.Add(new TestProjectMetadata());
        project.WithProjectDefaults(new ProjectResourceOptions { ExcludeLaunchProfile = true });
 
        var executable = Executable.Create("project", "dotnet");
        executable.Annotate(DcpCustomResource.ResourceNameAnnotation, project.Resource.Name);
        executable.Spec.ExecutionType = ExecutionType.IDE;
        executable.SetAnnotationAsObjectList(Executable.LaunchConfigurationsAnnotation, new[]
        {
            new ProjectLaunchConfiguration
            {
                Type = launchConfigurationType,
                ProjectPath = "/app/project.csproj",
                Mode = ExecutableLaunchMode.NoDebug,
                BuildConfiguration = configuration
            }
        });
        executable.Status = new ExecutableStatus
        {
            EffectiveArgs = ["--configuration", "private-application-argument"],
            ProcessId = 1234
        };
 
        var snapshot = CreateSnapshotBuilder(new Dictionary<string, IResource>
        {
            [project.Resource.Name] = project.Resource
        }).ToSnapshot(executable, CreatePreviousSnapshot());
 
        Assert.Equal("ide", GetProperty(snapshot, KnownProperties.Project.LaunchCommand).Value);
        if (configuration is not null)
        {
            Assert.Equal(configuration, GetProperty(snapshot, KnownProperties.Project.Configuration).Value);
        }
        else
        {
            Assert.Empty(snapshot.Properties.Where(property => property.Name == KnownProperties.Project.Configuration));
        }
        Assert.False(GetProperty(snapshot, KnownProperties.Project.LaunchCommand).IsSensitive);
        Assert.True(GetProperty(snapshot, KnownProperties.Executable.Args).IsSensitive);
    }
 
    [Fact]
    public void CoordinatedProjectIdeSnapshotDoesNotReportDirectLaunchMetadata()
    {
        var resource = new TestDotnetProjectResource("project");
        resource.Annotations.Add(new TestProjectMetadata
        {
            SuppressBuild = true,
            BuildWorkingDirectory = "/app",
            BuildTarget = new("/app/bin/Release/net10.0/project.dll", useAppHost: false)
        });
        resource.Annotations.Add(SupportsDebuggingAnnotation.Create<object>(
            resource.Name,
            KnownLaunchConfigurationTypes.ProjectWithExternalBuild,
            _ => Task.FromResult(new object())));
 
        var executable = Executable.Create(resource.Name, "dotnet");
        executable.Annotate(DcpCustomResource.ResourceNameAnnotation, resource.Name);
        executable.Spec.ExecutionType = ExecutionType.IDE;
        executable.SetAnnotationAsObjectList(Executable.LaunchConfigurationsAnnotation, new[]
        {
            new ProjectLaunchConfiguration
            {
                Type = KnownLaunchConfigurationTypes.ProjectWithExternalBuild,
                ProjectPath = "/app/project.csproj",
                Mode = ExecutableLaunchMode.NoDebug,
                BuildConfiguration = "Release"
            }
        });
        executable.Status = new ExecutableStatus
        {
            EffectiveArgs = ["private-application-argument"],
            ProcessId = 1234
        };
 
        var snapshot = CreateSnapshotBuilder(new Dictionary<string, IResource>
        {
            [resource.Name] = resource
        }).ToSnapshot(executable, CreatePreviousSnapshot(KnownResourceTypes.Project));
 
        Assert.Equal("ide", GetProperty(snapshot, KnownProperties.Project.LaunchCommand).Value);
        Assert.Equal("Release", GetProperty(snapshot, KnownProperties.Project.Configuration).Value);
        Assert.Empty(snapshot.Properties.Where(property => property.Name is KnownProperties.Project.TargetPath
            or KnownProperties.Project.UseAppHost));
    }
 
    [Theory]
    [InlineData("/app/bin/Release/net10.0/project", "Release", "/app/bin/Release/net10.0/project.dll", true)]
    [InlineData("C:\\app\\bin\\Debug\\net10.0\\project.exe", "Debug", "C:\\app\\bin\\Debug\\net10.0\\project.dll", true)]
    [InlineData("dotnet", "Release", "/app/bin/Release/net10.0/project.dll", false)]
    [InlineData("dotnet", null, "/app/bin/Debug/net10.0/project.dll", false)]
    public void CoordinatedProjectSnapshotIncludesDirectLaunchMetadata(
        string executablePath,
        string? configuration,
        string targetPath,
        bool useAppHost)
    {
        var resource = new TestDotnetProjectResource("project");
        resource.Annotations.Add(new TestProjectMetadata
        {
            SuppressBuild = true,
            BuildWorkingDirectory = "/app",
            BuildTarget = new(targetPath, useAppHost)
        });
        resource.Annotations.Add(new ProjectLaunchDefaultsAnnotation { BuildConfiguration = configuration });
        resource.Annotations.Add(SupportsDebuggingAnnotation.Create<object>(
            resource.Name,
            KnownLaunchConfigurationTypes.ProjectWithExternalBuild,
            _ => throw new InvalidOperationException("Process launches do not use the IDE configuration factory.")));
 
        var executable = Executable.Create(resource.Name, executablePath);
        executable.Annotate(DcpCustomResource.ResourceNameAnnotation, resource.Name);
        executable.Spec.ExecutionType = ExecutionType.Process;
        executable.Status = new ExecutableStatus
        {
            State = ExecutableState.Running,
            EffectiveArgs = ["--configuration", "private-application-argument"],
            ProcessId = 1234
        };
 
        var snapshot = CreateSnapshotBuilder(new Dictionary<string, IResource>
        {
            [resource.Name] = resource
        }).ToSnapshot(executable, CreatePreviousSnapshot(KnownResourceTypes.Project));
 
        Assert.Equal(KnownResourceTypes.Project, snapshot.ResourceType);
        Assert.Equal("/app/project.csproj", GetProperty(snapshot, KnownProperties.Project.Path).Value);
        Assert.Equal(executablePath, GetProperty(snapshot, KnownProperties.Executable.Path).Value);
        Assert.Equal(1234, GetProperty(snapshot, KnownProperties.Executable.Pid).Value);
        Assert.Equal("direct", GetProperty(snapshot, KnownProperties.Project.LaunchCommand).Value);
        Assert.Equal(
            configuration is null ? [] : [configuration],
            snapshot.Properties.Where(property => property.Name == KnownProperties.Project.Configuration).Select(property => property.Value));
        Assert.Equal(targetPath, GetProperty(snapshot, KnownProperties.Project.TargetPath).Value);
        Assert.Equal(useAppHost, GetProperty(snapshot, KnownProperties.Project.UseAppHost).Value);
        Assert.False(GetProperty(snapshot, KnownProperties.Project.LaunchCommand).IsSensitive);
        Assert.True(GetProperty(snapshot, KnownProperties.Executable.Args).IsSensitive);
    }
 
    [Theory]
    [InlineData(KnownLaunchConfigurationTypes.Project, true, "/app", "/app/project.csproj")]
    [InlineData("maui", true, "/app", "/app/project.csproj")]
    [InlineData(KnownLaunchConfigurationTypes.ProjectWithExternalBuild, false, "/app", "/app/project.csproj")]
    [InlineData(KnownLaunchConfigurationTypes.ProjectWithExternalBuild, true, null, "/app/project.csproj")]
    [InlineData(KnownLaunchConfigurationTypes.ProjectWithExternalBuild, true, "/app", "/app/project.cs")]
    public void ProjectSnapshotDoesNotInferDirectLaunchFromAnArbitraryExecutable(
        string launchConfigurationType,
        bool suppressBuild,
        string? buildWorkingDirectory,
        string projectPath)
    {
        var resource = new TestDotnetProjectResource("project");
        resource.Annotations.Add(new TestProjectMetadata
        {
            ProjectPath = projectPath,
            SuppressBuild = suppressBuild,
            BuildWorkingDirectory = buildWorkingDirectory
        });
        resource.Annotations.Add(SupportsDebuggingAnnotation.Create<object>(
            resource.Name,
            launchConfigurationType,
            _ => Task.FromResult(new object())));
        var executable = Executable.Create(resource.Name, "/app/unknown");
        executable.Annotate(DcpCustomResource.ResourceNameAnnotation, resource.Name);
        executable.Spec.ExecutionType = ExecutionType.Process;
 
        var snapshot = CreateSnapshotBuilder(new Dictionary<string, IResource>
        {
            [resource.Name] = resource
        }).ToSnapshot(executable, CreatePreviousSnapshot(KnownResourceTypes.Project));
 
        Assert.Empty(snapshot.Properties.Where(property => property.Name is KnownProperties.Project.LaunchCommand
            or KnownProperties.Project.Configuration or KnownProperties.Project.TargetFramework
            or KnownProperties.Project.TargetPath or KnownProperties.Project.UseAppHost));
    }
 
    [Theory]
    [InlineData("Run", "run", "--configuration", "--framework=net10.0")]
    [InlineData("run", "run", "-c", "-f")]
    [InlineData("run", "run", "--configuration=Release", "--framework")]
    [InlineData("run", "run", "-c=Release", "-f=net10.0")]
    [InlineData("run", "run", "--configuration:Release", "--framework:net10.0")]
    [InlineData("run", "run", "-c:Release", "-f:net10.0")]
    [InlineData("WATCH", "watch", "--configuration", "--framework=net10.0")]
    [InlineData("watch", "watch", "-c", "-f")]
    [InlineData("watch", "watch", "--configuration=Release", "--framework")]
    [InlineData("watch", "watch", "-c=Release", "-f=net10.0")]
    [InlineData("watch", "watch", "--configuration:Release", "--framework:net10.0")]
    [InlineData("watch", "watch", "-c:Release", "-f:net10.0")]
    public void ProjectSnapshotIncludesSafeDotNetLaunchMetadata(
        string launchCommand,
        string expectedLaunchCommand,
        string configurationArgument,
        string targetFrameworkArgument)
    {
        var project = new ProjectResource("project");
        project.Annotations.Add(new TestProjectMetadata());
 
        var effectiveArgs = new List<string>
        {
            launchCommand,
            "--project",
            "/app/project.csproj",
            configurationArgument
        };
        if (configurationArgument is "--configuration" or "-c")
        {
            effectiveArgs.Add("Release");
        }
        effectiveArgs.Add(targetFrameworkArgument);
        if (targetFrameworkArgument is "--framework" or "-f")
        {
            effectiveArgs.Add("net10.0");
        }
        effectiveArgs.Add("--");
        if (expectedLaunchCommand == "run")
        {
            effectiveArgs.AddRange(["--configuration", "Private", "--framework", "private"]);
        }
 
        var executable = Executable.Create("project", "dotnet");
        executable.Annotate(DcpCustomResource.ResourceNameAnnotation, project.Name);
        executable.Status = new ExecutableStatus
        {
            EffectiveArgs = effectiveArgs,
            ProcessId = 1234
        };
 
        var snapshot = CreateSnapshotBuilder(new Dictionary<string, IResource>
        {
            [project.Name] = project
        }).ToSnapshot(executable, CreatePreviousSnapshot());
 
        Assert.Equal(expectedLaunchCommand, Assert.IsType<string>(GetProperty(snapshot, KnownProperties.Project.LaunchCommand).Value));
        Assert.Equal("Release", Assert.IsType<string>(GetProperty(snapshot, KnownProperties.Project.Configuration).Value));
        Assert.Equal("net10.0", Assert.IsType<string>(GetProperty(snapshot, KnownProperties.Project.TargetFramework).Value));
        Assert.True(GetProperty(snapshot, KnownProperties.Executable.Args).IsSensitive);
        Assert.False(GetProperty(snapshot, KnownProperties.Project.LaunchCommand).IsSensitive);
        Assert.False(GetProperty(snapshot, KnownProperties.Project.Configuration).IsSensitive);
        Assert.False(GetProperty(snapshot, KnownProperties.Project.TargetFramework).IsSensitive);
    }
 
    [Fact]
    public void ProjectSnapshotOmitsSensitiveHiddenLaunchToolMetadata()
    {
        var project = new ProjectResource("project");
        project.Annotations.Add(new TestProjectMetadata());
 
        var effectiveArgs = new List<string>
        {
            "run",
            "--project",
            "/app/project.csproj",
            "--configuration",
            "resolved-configuration-secret",
            "--framework=resolved-framework-secret",
        };
        var executable = Executable.Create("project", "dotnet");
        executable.Annotate(DcpCustomResource.ResourceNameAnnotation, project.Name);
        executable.Status = new ExecutableStatus
        {
            EffectiveArgs = effectiveArgs,
            ProcessId = 1234
        };
        executable.SetAnnotationAsObjectList(DcpCustomResource.ResourceAppArgsAnnotation, Array.Empty<AppLaunchArgumentAnnotation>());
        executable.SetAnnotationAsObjectList(Executable.SensitiveEffectiveArgumentIndexesAnnotation, [4, 5]);
 
        var previousSnapshot = CreatePreviousSnapshot() with
        {
            Properties =
            [
                new(KnownProperties.Project.Configuration, "stale-configuration"),
                new(KnownProperties.Project.TargetFramework, "stale-framework"),
            ]
        };
        var snapshot = CreateSnapshotBuilder(new Dictionary<string, IResource>
        {
            [project.Name] = project
        }).ToSnapshot(executable, previousSnapshot);
 
        Assert.Null(GetProperty(snapshot, KnownProperties.Project.LaunchCommand).Value);
        Assert.Empty(snapshot.Properties.Where(property => property.Name == KnownProperties.Project.Configuration));
        Assert.Empty(snapshot.Properties.Where(property => property.Name == KnownProperties.Project.TargetFramework));
    }
 
    [Fact]
    public void ProjectSnapshotDoesNotPublishDotNetLaunchMetadataBeforeSensitiveOverride()
    {
        var project = new ProjectResource("project");
        project.Annotations.Add(new TestProjectMetadata());
 
        var effectiveArgs = new List<string>
        {
            "run",
            "--configuration",
            "Release",
            "--configuration",
            "resolved-configuration-secret",
        };
        var executable = Executable.Create("project", "dotnet");
        executable.Annotate(DcpCustomResource.ResourceNameAnnotation, project.Name);
        executable.Status = new ExecutableStatus
        {
            EffectiveArgs = effectiveArgs,
            ProcessId = 1234
        };
        executable.SetAnnotationAsObjectList(
            DcpCustomResource.ResourceAppArgsAnnotation,
            effectiveArgs.Select((argument, index) => new AppLaunchArgumentAnnotation(
                argument,
                isSensitive: false,
                effectiveArgumentIndex: index)));
        executable.SetAnnotationAsObjectList(Executable.SensitiveEffectiveArgumentIndexesAnnotation, [4]);
 
        var previousSnapshot = CreatePreviousSnapshot() with
        {
            Properties = [new(KnownProperties.Project.Configuration, "stale-configuration")]
        };
        var snapshot = CreateSnapshotBuilder(new Dictionary<string, IResource>
        {
            [project.Name] = project
        }).ToSnapshot(executable, previousSnapshot);
 
        Assert.Empty(snapshot.Properties.Where(property => property.Name == KnownProperties.Project.Configuration));
        Assert.Null(GetProperty(snapshot, KnownProperties.Project.LaunchCommand).Value);
    }
 
    [Theory]
    [InlineData("--runtime", "win-x64")]
    [InlineData("--runtime=win-x64")]
    [InlineData("-r", "linux-arm64")]
    [InlineData("--arch=arm64")]
    [InlineData("--os", "linux")]
    [InlineData("--artifacts-path", "custom")]
    [InlineData("--artifacts-path=custom")]
    [InlineData("-p:AssemblyName=Custom")]
    [InlineData("/property:OutputPath=custom")]
    [InlineData("--property:RuntimeIdentifier=win-x64")]
    [InlineData("--property", "TargetFramework=net10.0")]
    public void ProjectSnapshotFailsClosedForUnreportedTargetInputs(params string[] launchArguments)
    {
        var project = new ProjectResource("project");
        project.Annotations.Add(new TestProjectMetadata());
        var executable = Executable.Create("project", "dotnet");
        executable.Annotate(DcpCustomResource.ResourceNameAnnotation, project.Name);
        executable.Status = new ExecutableStatus
        {
            EffectiveArgs = ["run", "--configuration", "Release", "--framework", "net10.0", .. launchArguments],
            ProcessId = 1234
        };
        var snapshot = CreateSnapshotBuilder(new Dictionary<string, IResource>
        {
            [project.Name] = project
        }).ToSnapshot(executable, CreatePreviousSnapshot());
 
        Assert.Collection(
            snapshot.Properties.Where(property => property.Name is KnownProperties.Project.LaunchCommand
                or KnownProperties.Project.Configuration or KnownProperties.Project.TargetFramework),
            property =>
            {
                Assert.Equal(KnownProperties.Project.LaunchCommand, property.Name);
                Assert.Null(property.Value);
            });
    }
 
    [Theory]
    [InlineData("run", "--", "--configuration", "Release", "--framework", "net10.0")]
    [InlineData("run", "--configuration", "Release", "--framework", "net8.0", "--", "--runtime", "win-x64")]
    public void ProjectSnapshotFailsClosedForAmbiguousDotNetWatchTargetInputs(params string[] launchArguments)
    {
        var project = new ProjectResource("project");
        project.Annotations.Add(new TestProjectMetadata());
        var executable = Executable.Create("project", "dotnet");
        executable.Annotate(DcpCustomResource.ResourceNameAnnotation, project.Name);
        executable.Status = new ExecutableStatus
        {
            EffectiveArgs = ["watch", "--project", "/app/project.csproj", .. launchArguments],
            ProcessId = 1234
        };
        var snapshot = CreateSnapshotBuilder(new Dictionary<string, IResource>
        {
            [project.Name] = project
        }).ToSnapshot(executable, CreatePreviousSnapshot());
 
        Assert.Collection(
            snapshot.Properties.Where(property => property.Name is KnownProperties.Project.LaunchCommand
                or KnownProperties.Project.Configuration or KnownProperties.Project.TargetFramework),
            property =>
            {
                Assert.Equal(KnownProperties.Project.LaunchCommand, property.Name);
                Assert.Null(property.Value);
            });
        Assert.True(GetProperty(snapshot, KnownProperties.Executable.Args).IsSensitive);
    }
 
    [Theory]
    [InlineData("run", "[env:ASPNETCORE_ENVIRONMENT=Development]", "--diagnostics")]
    [InlineData("watch", "-d")]
    public void ProjectSnapshotIncludesLaunchMetadataAfterSupportedDotNetPrefixes(
        string launchCommand,
        params string[] prefixes)
    {
        var project = new ProjectResource("project");
        project.Annotations.Add(new TestProjectMetadata());
 
        var executable = Executable.Create("project", "dotnet");
        executable.Annotate(DcpCustomResource.ResourceNameAnnotation, project.Name);
        executable.Status = new ExecutableStatus
        {
            EffectiveArgs = [.. prefixes, launchCommand, "--configuration", "Release", "--framework", "net10.0"],
            ProcessId = 1234
        };
 
        var snapshot = CreateSnapshotBuilder(new Dictionary<string, IResource>
        {
            [project.Name] = project
        }).ToSnapshot(executable, CreatePreviousSnapshot());
 
        Assert.Equal(launchCommand, GetProperty(snapshot, KnownProperties.Project.LaunchCommand).Value);
        Assert.Equal("Release", GetProperty(snapshot, KnownProperties.Project.Configuration).Value);
        Assert.Equal("net10.0", GetProperty(snapshot, KnownProperties.Project.TargetFramework).Value);
    }
 
    [Fact]
    public void ProjectSnapshotIncludesNullLaunchCommandWhenDotNetArgumentsAreMissing()
    {
        var project = new ProjectResource("project");
        project.Annotations.Add(new TestProjectMetadata());
 
        var executable = Executable.Create("project", "dotnet");
        executable.Annotate(DcpCustomResource.ResourceNameAnnotation, project.Name);
        executable.Status = new ExecutableStatus
        {
            ProcessId = 1234
        };
 
        var snapshot = CreateSnapshotBuilder(new Dictionary<string, IResource>
        {
            [project.Name] = project
        }).ToSnapshot(executable, CreatePreviousSnapshot());
 
        var launchCommand = GetProperty(snapshot, KnownProperties.Project.LaunchCommand);
        Assert.Null(launchCommand.Value);
        Assert.False(launchCommand.IsSensitive);
    }
 
    [Fact]
    public void ProjectSnapshotIncludesNullLaunchCommandWhenDotNetCommandIsUnsupported()
    {
        var project = new ProjectResource("project");
        project.Annotations.Add(new TestProjectMetadata());
 
        var executable = Executable.Create("project", "dotnet.exe");
        executable.Annotate(DcpCustomResource.ResourceNameAnnotation, project.Name);
        executable.Status = new ExecutableStatus
        {
            EffectiveArgs = ["publish", "--configuration", "Release"],
            ProcessId = 1234
        };
 
        var snapshot = CreateSnapshotBuilder(new Dictionary<string, IResource>
        {
            [project.Name] = project
        }).ToSnapshot(executable, CreatePreviousSnapshot());
 
        var launchCommand = GetProperty(snapshot, KnownProperties.Project.LaunchCommand);
        Assert.Null(launchCommand.Value);
        Assert.False(launchCommand.IsSensitive);
    }
 
    [Fact]
    public void ExecutableSnapshotPublishesSourcePathIndependentlyOfDebuggerEligibility()
    {
        const string propertyName = "resource.launchConfigurationType";
        var resource = new TestDotnetProjectResource("python");
        resource.Annotations.Add(SupportsDebuggingAnnotation.Create<object>(
            resource.Name,
            "python",
            _ => Task.FromResult(new object())));
 
        var executable = Executable.Create(resource.Name, "python");
        executable.Annotate(DcpCustomResource.ResourceNameAnnotation, resource.Name);
 
        var snapshotBuilder = CreateSnapshotBuilder(new Dictionary<string, IResource>
        {
            [resource.Name] = resource
        });
        var snapshot = snapshotBuilder.ToSnapshot(executable, CreatePreviousSnapshot());
 
        Assert.Equal("python", GetProperty(snapshot, propertyName).Value);
        Assert.Equal(resource.WorkingDirectory, GetProperty(snapshot, KnownProperties.Resource.SourcePath).Value);
 
        resource.Annotations.Add(new ForceProcessExecutionAnnotation());
        snapshot = snapshotBuilder.ToSnapshot(executable, snapshot);
 
        Assert.Empty(snapshot.Properties.Where(property => property.Name == propertyName));
        Assert.Equal(resource.WorkingDirectory, GetProperty(snapshot, KnownProperties.Resource.SourcePath).Value);
 
        resource.Annotations.Remove(resource.Annotations.OfType<ForceProcessExecutionAnnotation>().Single());
        resource.Annotations.Add(new ContainerLifetimeAnnotation
        {
            Lifetime = ContainerLifetime.Persistent
        });
        snapshot = snapshotBuilder.ToSnapshot(executable, snapshot);
        Assert.Empty(snapshot.Properties.Where(property => property.Name == propertyName));
        Assert.Equal(resource.WorkingDirectory, GetProperty(snapshot, KnownProperties.Resource.SourcePath).Value);
    }
 
    [Fact]
    public void ProjectSnapshotRejectsMultipleProjectMetadataAnnotations()
    {
        var project = new ProjectResource("project");
        project.Annotations.Add(new TestProjectMetadata());
        project.Annotations.Add(new OverrideTestProjectMetadata());
 
        var executable = Executable.Create("project", "dotnet");
        executable.Annotate(DcpCustomResource.ResourceNameAnnotation, project.Name);
        executable.Status = new ExecutableStatus
        {
            EffectiveArgs = ["run"],
            ProcessId = 1234
        };
 
        var exception = Assert.Throws<InvalidOperationException>(() =>
            CreateSnapshotBuilder(new Dictionary<string, IResource>
            {
                [project.Name] = project
            }).ToSnapshot(executable, CreatePreviousSnapshot()));
 
        Assert.Contains(project.Name, exception.Message);
        Assert.Contains("more than one", exception.Message);
    }
 
    [Fact]
    public void ExecutableWithProjectMetadataSnapshotAddsProjectPropertiesAndPreservesCustomResourceType()
    {
        // A plain ExecutableResource that carries IProjectMetadata (e.g. DotnetProjectResource, an
        // ExecutableResource launched via `dotnet run --project`) should render like a project in the
        // dashboard — with the project path + launch profile — for parity with AddProject.
        var resource = new TestDotnetProjectResource("proj");
        resource.Annotations.Add(new TestProjectMetadata());
        resource.Annotations.Add(new LaunchProfileAnnotation("https"));
 
        var executable = Executable.Create("proj", "dotnet");
        executable.Annotate(DcpCustomResource.ResourceNameAnnotation, resource.Name);
        executable.Spec.WorkingDirectory = "/app";
        executable.Status = new ExecutableStatus
        {
            EffectiveArgs = ["run"],
            ProcessId = 1234
        };
 
        var snapshotBuilder = CreateSnapshotBuilder(new Dictionary<string, IResource>
        {
            [resource.Name] = resource
        });
        var projectSnapshot = snapshotBuilder.ToSnapshot(executable, CreatePreviousSnapshot(KnownResourceTypes.Project));
 
        AssertHighlightedProperty(projectSnapshot, KnownProperties.Project.Path, "Project path", isSensitive: false, sortOrder: 0);
        AssertHighlightedProperty(projectSnapshot, KnownProperties.Project.LaunchProfile, "Launch profile", isSensitive: false, sortOrder: 1);
 
        var customSnapshot = snapshotBuilder.ToSnapshot(executable, CreatePreviousSnapshot("custom-type"));
        Assert.Equal("custom-type", customSnapshot.ResourceType);
    }
 
    [Fact]
    public void ExecutableSnapshotPreservesLaunchArgumentSensitivityWhenUsingEffectiveArgs()
    {
        var executable = CreateExecutable(
            [
                new("--secret", isSensitive: false, effectiveArgumentIndex: 0),
                new("{{- secretRef \"connectionString\" -}}", isSensitive: true, effectiveArgumentIndex: 1)
            ],
            ["--secret", "resolved-secret"]);
 
        var snapshot = CreateSnapshotBuilder().ToSnapshot(executable, CreatePreviousSnapshot());
 
        Assert.Equal(["--secret", "resolved-secret"], GetEnumerablePropertyValue<string>(snapshot, KnownProperties.Resource.AppArgs).ToArray());
        Assert.Equal([0, 1], GetEnumerablePropertyValue<int>(snapshot, KnownProperties.Resource.AppArgsSensitivity).ToArray());
        Assert.True(GetProperty(snapshot, KnownProperties.Resource.AppArgs).IsSensitive);
        Assert.True(GetProperty(snapshot, KnownProperties.Resource.AppArgsSensitivity).IsSensitive);
    }
 
    [Fact]
    public void ExecutableSnapshotFallsBackToAnnotationValueWhenEffectiveArgMissing()
    {
        var executable = CreateExecutable(
            [
                new("-port", isSensitive: false, effectiveArgumentIndex: 0),
                new(DcpTemplateArgument, isSensitive: false, effectiveArgumentIndex: 9)
            ],
            ["-port", ResolvedPortArgument]);
 
        var snapshot = CreateSnapshotBuilder().ToSnapshot(executable, CreatePreviousSnapshot());
 
        Assert.Equal(["-port", DcpTemplateArgument], GetEnumerablePropertyValue<string>(snapshot, KnownProperties.Resource.AppArgs).ToArray());
    }
 
    [Fact]
    public void ExplicitStartExecutableSnapshotWithUnknownStateIsNotStarted()
    {
        var executable = Executable.Create("exe", "pwsh");
        executable.Spec.Start = false;
        executable.Status = new ExecutableStatus
        {
            State = ExecutableState.Unknown
        };
 
        var snapshot = CreateSnapshotBuilder().ToSnapshot(executable, CreatePreviousSnapshot());
 
        Assert.Equal(KnownResourceStates.NotStarted, snapshot.State?.Text);
    }
 
    [Fact]
    public void ExplicitStartExecutableStatusWithUnknownStateIsNotStarted()
    {
        var executable = Executable.Create("exe", "pwsh");
        executable.Spec.Start = false;
        executable.Status = new ExecutableStatus
        {
            State = ExecutableState.Unknown
        };
 
        var status = DcpResourceWatcher.GetResourceStatus(executable);
 
        Assert.Equal(KnownResourceStates.NotStarted, status.State);
    }
 
    [Fact]
    public void ExplicitStartExecutableSnapshotWithEmptyStateIsNotStarted()
    {
        var executable = Executable.Create("exe", "pwsh");
        executable.Spec.Start = false;
        executable.Status = new ExecutableStatus
        {
            State = ""
        };
 
        var snapshot = CreateSnapshotBuilder().ToSnapshot(executable, CreatePreviousSnapshot());
 
        Assert.Equal(KnownResourceStates.NotStarted, snapshot.State?.Text);
    }
 
    [Fact]
    public void ExplicitStartExecutableStatusWithEmptyStateIsNotStarted()
    {
        var executable = Executable.Create("exe", "pwsh");
        executable.Spec.Start = false;
        executable.Status = new ExecutableStatus
        {
            State = ""
        };
 
        var status = DcpResourceWatcher.GetResourceStatus(executable);
 
        Assert.Equal(KnownResourceStates.NotStarted, status.State);
    }
 
    private static Executable CreateExecutable(AppLaunchArgumentAnnotation[] launchArgumentAnnotations, IReadOnlyList<string> effectiveArgs)
    {
        var executable = Executable.Create("exe", "pwsh");
        executable.Spec.Args = [.. launchArgumentAnnotations.Select(a => a.Argument)];
        executable.Status = new ExecutableStatus
        {
            EffectiveArgs = [.. effectiveArgs]
        };
        executable.SetAnnotationAsObjectList(DcpCustomResource.ResourceAppArgsAnnotation, launchArgumentAnnotations);
 
        return executable;
    }
 
    private static DcpResourceSnapshotBuilder CreateSnapshotBuilder(IDictionary<string, IResource>? applicationModel = null)
    {
        return new(new DcpResourceState(applicationModel ?? new Dictionary<string, IResource>(), []));
    }
 
    private static CustomResourceSnapshot CreatePreviousSnapshot(string resourceType = "resource")
    {
        return new()
        {
            ResourceType = resourceType,
            Properties = []
        };
    }
 
    private static ResourcePropertySnapshot GetProperty(CustomResourceSnapshot snapshot, string name)
    {
        return Assert.Single(snapshot.Properties, p => p.Name == name);
    }
 
    private static void AssertHighlightedProperty(CustomResourceSnapshot snapshot, string name, string displayName, bool isSensitive, int sortOrder)
    {
        var property = GetProperty(snapshot, name);
        Assert.Equal(displayName, property.DisplayName);
        Assert.True(property.IsHighlighted);
        Assert.Equal(isSensitive, property.IsSensitive);
        Assert.Equal(sortOrder, property.SortOrder);
    }
 
    private static void AssertDefaultProperty(CustomResourceSnapshot snapshot, string name, bool isSensitive)
    {
        var property = GetProperty(snapshot, name);
        Assert.Null(property.DisplayName);
        Assert.False(property.IsHighlighted);
        Assert.Equal(isSensitive, property.IsSensitive);
        Assert.Null(property.SortOrder);
    }
 
    private static IEnumerable<T> GetEnumerablePropertyValue<T>(CustomResourceSnapshot snapshot, string name)
    {
        var property = GetProperty(snapshot, name);
        return Assert.IsAssignableFrom<IEnumerable<T>>(property.Value);
    }
 
    private sealed class TestProjectMetadata : IProjectMetadata
    {
        public string ProjectPath { get; init; } = "/app/project.csproj";
 
        public bool SuppressBuild { get; init; }
 
        public string? BuildWorkingDirectory { get; init; }
 
        public ProjectBuildTarget? BuildTarget { get; init; }
 
        public LaunchSettings LaunchSettings { get; } = new()
        {
            Profiles =
            {
                ["https"] = new LaunchProfile
                {
                    CommandName = "Project"
                }
            }
        };
    }
 
    private sealed class OverrideTestProjectMetadata : IProjectMetadata
    {
        public string ProjectPath => "/app/override.csproj";
 
        // Launch settings are supplied inline so launch profile resolution never falls back to reading
        // Properties/launchSettings.json from disk for this fake project path.
        public LaunchSettings LaunchSettings { get; } = new()
        {
            Profiles =
            {
                ["http"] = new LaunchProfile
                {
                    CommandName = "Project"
                }
            }
        };
    }
 
    private sealed class TestDotnetProjectResource(string name) : ExecutableResource(name, "dotnet", "/app");
}