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 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 ExecutableSnapshotPublishesLaunchConfigurationTypeOnlyWhenInstallingDebuggerCanEnableDebugging()
    {
        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);
 
        resource.Annotations.Add(new ForceProcessExecutionAnnotation());
        snapshot = snapshotBuilder.ToSnapshot(executable, snapshot);
 
        Assert.Empty(snapshot.Properties.Where(property => property.Name == propertyName));
 
        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));
    }
 
    [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 => "/app/project.csproj";
 
        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");
}