File: Dcp\ExecutableLaunchPlanTests.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
 
using System.Text.Json;
using Aspire.Hosting.Dcp;
using Aspire.Hosting.Dcp.Model;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
 
namespace Aspire.Hosting.Tests.Dcp;
 
[Trait("Partition", "4")]
public class ExecutableLaunchPlanTests
{
    [Fact]
    public async Task OwnedLaunchToolArgumentsAreSeparatedFromIdeArguments()
    {
        var resource = new ExecutableResource("app", "tool", "/tmp");
        resource.Annotations.Add(new LaunchToolArgsCallbackAnnotation(
            static _ => Task.CompletedTask,
            owningLaunchConfigurationType: "test",
            showInCommandLine: true));
        resource.Annotations.Add(SupportsDebuggingAnnotation.Create(
            resource.Name,
            "test",
            static _ => Task.FromResult(new ExecutableLaunchConfiguration("test"))));
        var configurationValues =
            new Dictionary<string, string?>
            {
                [DcpExecutor.DebugSessionPortVar] = "12345",
                [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo
                {
                    ProtocolsSupported = ["test"],
                    SupportedLaunchConfigurations = ["test"]
                })
            };
        var configuration = CreateExecutionConfiguration(
            [("run", false), ("app-arg", false)],
            launchToolArgumentCount: 1);
 
        var plan = await ResolveLaunchPlanAsync(resource, configuration, configurationValues);
 
        Assert.Equal(ExecutableLaunchMechanism.Ide, plan.Mechanism);
        Assert.Equal(["app-arg"], plan.Arguments);
        Assert.Collection(
            plan.DisplayArguments,
            argument =>
            {
                Assert.Equal("run", argument.Value);
                Assert.Equal(ExecutableLaunchArgumentRole.LaunchTool, argument.Role);
                Assert.Null(argument.EffectiveArgumentIndex);
            },
            argument =>
            {
                Assert.Equal("app-arg", argument.Value);
                Assert.Equal(ExecutableLaunchArgumentRole.Application, argument.Role);
                Assert.Equal(0, argument.EffectiveArgumentIndex);
            });
        Assert.Single(plan.LaunchConfigurations);
    }
 
    [Fact]
    public async Task UnsupportedIdeCapabilitySelectsCompleteProcessPlan()
    {
        var resource = new ExecutableResource("app", "tool", "/tmp");
        resource.Annotations.Add(new LaunchToolArgsCallbackAnnotation(
            static _ => Task.CompletedTask,
            owningLaunchConfigurationType: "test",
            showInCommandLine: true));
        resource.Annotations.Add(SupportsDebuggingAnnotation.Create(
            resource.Name,
            "test",
            static _ => Task.FromResult(new ExecutableLaunchConfiguration("test"))));
        var configurationValues =
            new Dictionary<string, string?>
            {
                [DcpExecutor.DebugSessionPortVar] = "12345",
                [KnownConfigNames.DebugSessionInfo] = JsonSerializer.Serialize(new RunSessionInfo
                {
                    ProtocolsSupported = ["test"],
                    SupportedLaunchConfigurations = ["other"]
                })
            };
        var configuration = CreateExecutionConfiguration(
            [("run", false), ("app-arg", false)],
            launchToolArgumentCount: 1);
 
        var plan = await ResolveLaunchPlanAsync(resource, configuration, configurationValues);
 
        Assert.Equal(ExecutableLaunchMechanism.Process, plan.Mechanism);
        Assert.Equal(["run", "app-arg"], plan.Arguments);
        Assert.Empty(plan.LaunchConfigurations);
    }
 
    [Fact]
    public async Task LegacyProjectRecipeProducesCompleteProcessInvocation()
    {
        var projectPath = Path.Combine("tmp", "project.csproj");
        var resource = new ProjectResource("project");
        resource.Annotations.Add(new TestProjectMetadata(projectPath));
 
        var plan = await ResolveLaunchPlanAsync(
            resource,
            CreateExecutionConfiguration([]),
            []);
 
        Assert.Equal(ExecutableLaunchMechanism.Process, plan.Mechanism);
        Assert.Equal("dotnet", plan.Command);
        Assert.Equal(Path.GetDirectoryName(projectPath), plan.WorkingDirectory);
        var expectedArguments = new List<string> { "run", "--project", projectPath };
        if (new DistributedApplicationOptions().Configuration is { } configuration)
        {
            expectedArguments.AddRange(["--configuration", configuration]);
        }
        expectedArguments.Add("--no-launch-profile");
        Assert.Equal(expectedArguments, plan.Arguments);
        Assert.Empty(plan.DisplayArguments);
    }
 
    [Fact]
    public async Task CompatibilityProjectLaunchDoesNotWithholdInactiveOwnedToolArguments()
    {
        var resource = new ProjectResource("project");
        resource.Annotations.Add(new TestProjectMetadata("/tmp/project.csproj"));
        resource.Annotations.Add(new LaunchToolArgsCallbackAnnotation(
            static _ => Task.CompletedTask,
            owningLaunchConfigurationType: KnownLaunchConfigurationTypes.Project,
            showInCommandLine: true));
        resource.Annotations.Add(SupportsDebuggingAnnotation.Create(
            resource.Name,
            "custom",
            static _ => Task.FromResult(new ExecutableLaunchConfiguration("custom"))));
        var configurationValues =
            new Dictionary<string, string?>
            {
                [DcpExecutor.DebugSessionPortVar] = "12345"
            };
        var configuration = CreateExecutionConfiguration(
            [("run", false), ("app-arg", false)],
            launchToolArgumentCount: 1);
 
        var plan = await ResolveLaunchPlanAsync(resource, configuration, configurationValues);
 
        Assert.Equal(ExecutableLaunchMechanism.Ide, plan.Mechanism);
        Assert.Equal(["run", "app-arg"], plan.Arguments);
        Assert.Single(plan.LaunchConfigurations);
    }
 
    [Fact]
    public void ForcedProcessProjectRetainsProjectLaunchMode()
    {
        var resource = new ProjectResource("project");
        resource.Annotations.Add(new ForceProcessExecutionAnnotation());
        var configuration = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?>
            {
                [KnownConfigNames.DebugSessionRunMode] = ExecutableLaunchMode.Debug
            })
            .Build();
        var policy = new ExecutableLaunchPolicy(configuration);
 
        var decision = policy.Decide(resource);
 
        Assert.Equal(ExecutableLaunchMechanism.Process, decision.Mechanism);
        Assert.Equal(ExecutableLaunchMode.NoDebug, decision.LaunchMode);
        Assert.Equal(ExecutableLaunchMode.Debug, decision.ProjectLaunchMode);
    }
 
    [Fact]
    public void RendererAppliesPlanWithoutRuntimeFallback()
    {
        var resource = new ExecutableResource("app", "tool", "/tmp");
        var executable = Executable.Create("app-12345678", "stale-tool");
        executable.Spec.ExecutionType = ExecutionType.IDE;
        executable.Spec.FallbackExecutionTypes = [ExecutionType.Process];
        executable.Spec.Args = ["stale"];
        var renderedResource = new RenderedModelResource<Executable>(resource, executable);
        var plan = new ExecutableLaunchPlan(
            "tool",
            "/tmp",
            ExecutableLaunchMechanism.Process,
            ["app-arg"],
            [new("ENV", "value")],
            [],
            [new ExecutableLaunchArgument(
                "app-arg",
                isSensitive: false,
                executable: true,
                display: true,
                effectiveArgumentIndex: 0,
                role: ExecutableLaunchArgumentRole.Application)]);
        ExecutableCreator.Render(
            renderedResource,
            plan,
            pemCertificates: null,
            NullLogger<ExecutableCreator>.Instance);
 
        Assert.Equal(ExecutionType.Process, executable.Spec.ExecutionType);
        Assert.Null(executable.Spec.FallbackExecutionTypes);
        Assert.Equal(["app-arg"], executable.Spec.Args);
        Assert.False(executable.Metadata.Annotations?.ContainsKey(Executable.LaunchConfigurationsAnnotation) is true);
        Assert.Collection(
            executable.Spec.Env!,
            variable =>
            {
                Assert.Equal("ENV", variable.Name);
                Assert.Equal("value", variable.Value);
            });
    }
 
    [Fact]
    public async Task ResolverRejectsMultipleLaunchRecipes()
    {
        var resource = new ExecutableResource("app", "tool", "/tmp");
        resource.Annotations.Add(new ExecutableLaunchRecipeAnnotation(DirectExecutableLaunchRecipe.Instance));
 
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(
            () => ResolveLaunchPlanAsync(
                resource,
                CreateExecutionConfiguration([]),
                []));
 
        Assert.Equal(
            "Resource 'app' must have exactly one executable launch recipe, but 2 were found.",
            exception.Message);
    }
 
    private static Task<ExecutableLaunchPlan> ResolveLaunchPlanAsync(
        IResource resource,
        IExecutionConfigurationResult executionConfiguration,
        IEnumerable<KeyValuePair<string, string?>> configurationValues)
    {
        var configuration = new ConfigurationBuilder()
            .AddInMemoryCollection(configurationValues)
            .Build();
 
        return ExecutableCreator.ResolveLaunchPlanAsync(
            resource,
            executionConfiguration,
            configuration,
            new DistributedApplicationOptions(),
            new ExecutableLaunchPolicy(configuration),
            NullLogger.Instance,
            CancellationToken.None);
    }
 
    private static ExecutionConfigurationResult CreateExecutionConfiguration(
        IReadOnlyList<(string Value, bool IsSensitive)> arguments,
        int launchToolArgumentCount = 0)
    {
        IExecutionConfigurationData[] additionalData = launchToolArgumentCount > 0
            ? [new LaunchToolArgumentsData(launchToolArgumentCount, ShowInCommandLine: true)]
            : [];
        return new()
        {
            References = [],
            ArgumentsWithUnprocessed = arguments.Select(static argument =>
                ((object)argument.Value, argument.Value, argument.IsSensitive)),
            EnvironmentVariablesWithUnprocessed = [],
            AdditionalConfigurationData = additionalData,
            Exception = null
        };
    }
 
    private sealed class TestProjectMetadata(string projectPath) : IProjectMetadata
    {
        public string ProjectPath { get; } = projectPath;
 
        public LaunchSettings LaunchSettings { get; } = new();
    }
}