File: DebugSupportExtensionsTests.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 APIs are experimental.
#pragma warning disable ASPIREPERSISTENCE001 // Resource lifetime APIs are experimental.
 
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;
using Aspire.Hosting.Dcp;
using Aspire.Hosting.Tests.Utils;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.Configuration;
 
namespace Aspire.Hosting.Tests;
 
[Trait("Partition", "2")]
public class DebugSupportExtensionsTests
{
    [Fact]
    public void LaunchConfigurationCallbackContextExposesOnlyLaunchProducerInputs()
    {
        var contextType = typeof(LaunchConfigurationCallbackContext);
 
        Assert.Empty(contextType.GetConstructors(BindingFlags.Public | BindingFlags.Instance));
        Assert.Equal(
            [
                nameof(LaunchConfigurationCallbackContext.CancellationToken),
                nameof(LaunchConfigurationCallbackContext.EnvironmentVariables),
                nameof(LaunchConfigurationCallbackContext.Mode),
                nameof(LaunchConfigurationCallbackContext.Resource)
            ],
            contextType.GetProperties().Select(property => property.Name).Order());
        Assert.All(contextType.GetProperties(), property => Assert.Null(property.SetMethod));
    }
 
    [Fact]
    public void CreateLaunchConfigurationOverloadsPreservePublicModeAndInternalContextContracts()
    {
        var methods = typeof(DebugSupportExtensions)
            .GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static)
            .Where(method => method.Name == nameof(DebugSupportExtensions.CreateLaunchConfigurationAsync))
            .ToList();
 
        var modeOverload = Assert.Single(methods, method =>
        {
            var parameters = method.GetParameters();
            return parameters.Length == 3 &&
                parameters[0].ParameterType == typeof(IResource) &&
                parameters[1].ParameterType == typeof(string) &&
                parameters[2].ParameterType == typeof(CancellationToken);
        });
        Assert.True(modeOverload.IsPublic);
        Assert.True(modeOverload.GetParameters()[2].IsOptional);
 
        var contextOverload = Assert.Single(methods, method =>
        {
            var parameters = method.GetParameters();
            return parameters.Length == 2 &&
                parameters[0].ParameterType == typeof(IResource) &&
                parameters[1].ParameterType == typeof(LaunchConfigurationCallbackContext);
        });
        Assert.True(contextOverload.IsAssembly);
    }
 
    [Fact]
    public async Task CreateLaunchConfigurationInspectionOverloadCreatesResourceBoundContext()
    {
        var inspectionOverload = typeof(DebugSupportExtensions).GetMethod(
            nameof(DebugSupportExtensions.CreateLaunchConfigurationAsync),
            BindingFlags.Public | BindingFlags.Static,
            [typeof(IResource), typeof(string), typeof(CancellationToken)]);
 
        Assert.NotNull(inspectionOverload);
 
        using var builder = TestDistributedApplicationBuilder.Create();
        using var cts = new CancellationTokenSource();
        LaunchConfigurationCallbackContext? observedContext = null;
 
        var executable = builder.AddExecutable("app", "go", ".")
                                .WithDebugSupport(context =>
                                {
                                    observedContext = context;
                                    return Task.FromResult(new TestGoLaunchConfiguration { Mode = context.Mode });
                                }, "go");
 
        var launchConfiguration = Assert.IsType<TestGoLaunchConfiguration>(
            await executable.Resource.CreateLaunchConfigurationAsync(ExecutableLaunchMode.NoDebug, cts.Token));
 
        Assert.NotNull(observedContext);
        Assert.Same(executable.Resource, observedContext.Resource);
        Assert.Equal(ExecutableLaunchMode.NoDebug, observedContext.Mode);
        Assert.Empty(observedContext.EnvironmentVariables);
        Assert.Equal(cts.Token, observedContext.CancellationToken);
        Assert.Equal(ExecutableLaunchMode.NoDebug, launchConfiguration.Mode);
    }
 
    [Fact]
    public async Task CreateLaunchConfigurationResolvesTheLaunchProfileForProjectResources()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var project = builder.AddProject<Projects.ServiceA>("proj", launchProfileName: "http");
 
        var launchConfiguration = Assert.IsType<ProjectLaunchConfiguration>(await CreateLaunchConfigurationForTestAsync(project.Resource, ExecutableLaunchMode.Debug));
 
        Assert.Equal(ExecutableLaunchMode.Debug, launchConfiguration.Mode);
        Assert.Equal(GetProjectPath(project.Resource), launchConfiguration.ProjectPath);
        Assert.Equal("http", launchConfiguration.LaunchProfile);
        Assert.False(launchConfiguration.DisableLaunchProfile);
    }
 
    [Fact]
    public async Task CreateLaunchConfigurationDisablesTheLaunchProfileWhenTheResourceExcludesIt()
    {
        // The producer registered by AddProject never sets DisableLaunchProfile; it is derived from
        // ExcludeLaunchProfileAnnotation when the configuration is finalized. Passing a null launch profile
        // name applies that annotation.
        using var builder = TestDistributedApplicationBuilder.Create();
        var project = builder.AddProject<Projects.ServiceA>("proj", launchProfileName: null);
 
        var launchConfiguration = Assert.IsType<ProjectLaunchConfiguration>(await CreateLaunchConfigurationForTestAsync(project.Resource, ExecutableLaunchMode.Debug));
 
        Assert.True(launchConfiguration.DisableLaunchProfile);
        Assert.Equal(string.Empty, launchConfiguration.LaunchProfile);
    }
 
    [Fact]
    public void AddProjectRegistersProjectDebugSupportOnce()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var project = builder.AddProject<Projects.ServiceA>("proj", launchProfileName: "http");
 
        var debugSupport = Assert.Single(project.Resource.Annotations.OfType<SupportsDebuggingAnnotation>());
 
        Assert.Equal(KnownLaunchConfigurationTypes.Project, debugSupport.LaunchConfigurationType);
    }
 
    [Fact]
    public async Task CreateLaunchConfigurationReturnsTheProducerOutputForACustomProjectProducer()
    {
        // A resource can replace the project debug support that WithProjectDefaults registers. The producer
        // owns the whole configuration, so its output is returned (and sent) verbatim.
        using var builder = TestDistributedApplicationBuilder.Create();
        var project = builder.AddProject<Projects.ServiceA>("proj", launchProfileName: "http")
                             .WithDebugSupport(mode => new ProjectLaunchConfiguration
                             {
                                 Mode = mode,
                                 ProjectPath = "custom-path",
                                 LaunchProfile = "https"
                             }, KnownLaunchConfigurationTypes.Project);
 
        var launchConfiguration = Assert.IsType<ProjectLaunchConfiguration>(await CreateLaunchConfigurationForTestAsync(project.Resource, ExecutableLaunchMode.NoDebug));
 
        Assert.Equal(ExecutableLaunchMode.NoDebug, launchConfiguration.Mode);
        Assert.Equal("custom-path", launchConfiguration.ProjectPath);
        Assert.Equal("https", launchConfiguration.LaunchProfile);
    }
 
    [Fact]
    public async Task CreateLaunchConfigurationReturnsTheProducerOutputForNonProjectLaunchTypes()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var executable = builder.AddExecutable("app", "go", ".")
                                .WithDebugSupport(mode => new TestGoLaunchConfiguration { Mode = mode, Package = "./cmd/api" }, "go");
 
        var launchConfiguration = Assert.IsType<TestGoLaunchConfiguration>(await CreateLaunchConfigurationForTestAsync(executable.Resource, ExecutableLaunchMode.NoDebug));
 
        Assert.Equal("go", launchConfiguration.Type);
        Assert.Equal(ExecutableLaunchMode.NoDebug, launchConfiguration.Mode);
        Assert.Equal("./cmd/api", launchConfiguration.Package);
    }
 
    [Fact]
    public async Task CreateLaunchConfigurationAwaitsAnAsynchronousProducer()
    {
        // The producer is asynchronous so integrations can resolve the configuration from callbacks that are
        // themselves asynchronous (for example build-argument callbacks contributed by other annotations).
        using var builder = TestDistributedApplicationBuilder.Create();
        var executable = builder.AddExecutable("app", "go", ".")
                                .WithDebugSupport(async (mode, _) =>
                                {
                                    await Task.Yield();
                                    return new TestGoLaunchConfiguration { Mode = mode, Package = "./cmd/api" };
                                }, "go");
 
        var launchConfiguration = Assert.IsType<TestGoLaunchConfiguration>(await CreateLaunchConfigurationForTestAsync(executable.Resource, ExecutableLaunchMode.Debug));
 
        Assert.Equal(ExecutableLaunchMode.Debug, launchConfiguration.Mode);
        Assert.Equal("./cmd/api", launchConfiguration.Package);
    }
 
    [Fact]
    public async Task CreateLaunchConfigurationPropagatesTheCancellationTokenToTheProducer()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        using var cts = new CancellationTokenSource();
        CancellationToken observedToken = default;
 
        var executable = builder.AddExecutable("app", "go", ".")
                                .WithDebugSupport((mode, cancellationToken) =>
                                {
                                    observedToken = cancellationToken;
                                    return Task.FromResult(new TestGoLaunchConfiguration { Mode = mode });
                                }, "go");
 
        await CreateLaunchConfigurationForTestAsync(executable.Resource, ExecutableLaunchMode.Debug, cts.Token);
 
        Assert.Equal(cts.Token, observedToken);
    }
 
    [Fact]
    public async Task CreateLaunchConfigurationPublicOverloadInvokesContextProducerWithLegacyInputs()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        using var cts = new CancellationTokenSource();
        LaunchConfigurationCallbackContext? observedContext = null;
 
        var executable = builder.AddExecutable("app", "go", ".")
                                .WithDebugSupport(context =>
                                {
                                    observedContext = context;
                                    return Task.FromResult(new TestGoLaunchConfiguration
                                    {
                                        Mode = context.Mode,
                                        Package = "./cmd/api"
                                    });
                                }, "go");
 
        var launchConfiguration = Assert.IsType<TestGoLaunchConfiguration>(
            await executable.Resource.CreateLaunchConfigurationAsync(ExecutableLaunchMode.NoDebug, cts.Token));
 
        var actualContext = Assert.IsType<LaunchConfigurationCallbackContext>(observedContext);
        Assert.Equal(ExecutableLaunchMode.NoDebug, launchConfiguration.Mode);
        Assert.Equal("./cmd/api", launchConfiguration.Package);
        Assert.Equal(ExecutableLaunchMode.NoDebug, actualContext.Mode);
        Assert.Same(executable.Resource, actualContext.Resource);
        Assert.Empty(actualContext.EnvironmentVariables);
        Assert.Equal(cts.Token, actualContext.CancellationToken);
    }
 
    [Fact]
    public async Task CreateLaunchConfigurationPublicOverloadValidatesMode()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var executable = builder.AddExecutable("app", "go", ".")
                                .WithDebugSupport(mode => new TestGoLaunchConfiguration { Mode = mode }, "go");
 
        await Assert.ThrowsAsync<ArgumentNullException>(
            () => executable.Resource.CreateLaunchConfigurationAsync(null!, CancellationToken.None));
    }
 
    [Fact]
    public async Task CreateLaunchConfigurationWithContextUsesResolvedEnvironmentAndCancellation()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        using var cts = new CancellationTokenSource();
        LaunchConfigurationCallbackContext? observedContext = null;
 
        var executable = builder.AddExecutable("app", "go", ".")
                                .WithDebugSupport(context =>
                                {
                                    observedContext = context;
                                    return Task.FromResult(new TestGoLaunchConfiguration
                                    {
                                        Mode = context.Mode,
                                        Package = context.EnvironmentVariables["GO_PACKAGE"]
                                    });
                                }, "go");
        var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext(
            executable.Resource,
            ExecutableLaunchMode.NoDebug,
            new Dictionary<string, string> { ["GO_PACKAGE"] = "./cmd/api" },
            cts.Token);
 
        var launchConfiguration = Assert.IsType<TestGoLaunchConfiguration>(
            await executable.Resource.CreateLaunchConfigurationAsync(callbackContext));
 
        var actualContext = Assert.IsType<LaunchConfigurationCallbackContext>(observedContext);
        Assert.Same(callbackContext, actualContext);
        Assert.Equal(ExecutableLaunchMode.NoDebug, launchConfiguration.Mode);
        Assert.Equal("./cmd/api", launchConfiguration.Package);
        Assert.Equal(cts.Token, actualContext.CancellationToken);
    }
 
    [Fact]
    public async Task CreateLaunchConfigurationThrowsWhenTheResourceHasNoDebugSupport()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var executable = builder.AddExecutable("app", "go", ".");
 
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => CreateLaunchConfigurationForTestAsync(executable.Resource, ExecutableLaunchMode.Debug));
 
        Assert.Contains("does not declare debug launch support", exception.Message);
    }
 
    [Fact]
    public async Task CreateLaunchConfigurationThrowsWhenTheResourceHasNoProjectMetadata()
    {
        // The producer resolves project metadata when it runs, so a resource that declares "project" debug
        // support without carrying metadata fails with a clear message rather than a sequence error.
        using var builder = TestDistributedApplicationBuilder.Create();
        var executable = builder.AddExecutable("app", "dotnet", ".");
        executable.WithDebugSupport(
            mode => ProjectLaunchConfigurationFactory.Create(executable.Resource, mode),
            KnownLaunchConfigurationTypes.Project);
 
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => CreateLaunchConfigurationForTestAsync(executable.Resource, ExecutableLaunchMode.Debug));
 
        Assert.Contains("has no project metadata", exception.Message);
    }
 
    [Fact]
    public async Task CreateLaunchConfigurationThrowsWhenTheProducerReturnsNull()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var executable = builder.AddExecutable("app", "go", ".")
                                .WithDebugSupport(_ => (TestGoLaunchConfiguration)null!, "go");
 
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => CreateLaunchConfigurationForTestAsync(executable.Resource, ExecutableLaunchMode.Debug));
 
        Assert.Contains("returned null", exception.Message);
        Assert.Contains("app", exception.Message);
        Assert.Contains("go", exception.Message);
    }
 
    [Fact]
    public void SupportsDebuggingReturnsFalseWhenTheResourceHasNoDebugSupport()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var executable = builder.AddExecutable("app", "go", ".");
 
        Assert.False(executable.Resource.SupportsDebugging(CreateConfiguration(), out var annotation));
        Assert.Null(annotation);
    }
 
    [Fact]
    public void SupportsDebuggingReturnsFalseWhenNoDebugSessionIsActive()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var executable = builder.AddExecutable("app", "go", ".")
                                .WithDebugSupport(mode => new TestGoLaunchConfiguration { Mode = mode }, "go");
 
        Assert.False(executable.Resource.SupportsDebugging(CreateConfiguration(debugSessionPort: null), out _));
    }
 
    [Fact]
    public void SupportsDebuggingTreatsProjectAsSupportedWhenTheIdeSendsNoCapabilityList()
    {
        // Visual Studio does not send DEBUG_SESSION_INFO at all. It launches every project resource
        // natively, so "project" stays implicitly supported instead of falling back to a plain process.
        using var builder = TestDistributedApplicationBuilder.Create();
        var project = builder.AddProject<Projects.ServiceA>("proj", launchProfileName: "http");
 
        Assert.True(project.Resource.SupportsDebugging(CreateConfiguration(), out var annotation));
        Assert.Equal(KnownLaunchConfigurationTypes.Project, annotation.LaunchConfigurationType);
    }
 
    [Fact]
    public void SupportsDebuggingReturnsFalseForANonProjectTypeWhenTheIdeSendsNoCapabilityList()
    {
        // The implicit rule is deliberately limited to "project": an IDE that never advertised its
        // capabilities cannot be assumed to know how to launch a "go" resource.
        using var builder = TestDistributedApplicationBuilder.Create();
        var executable = builder.AddExecutable("app", "go", ".")
                                .WithDebugSupport(mode => new TestGoLaunchConfiguration { Mode = mode }, "go");
 
        Assert.False(executable.Resource.SupportsDebugging(CreateConfiguration(), out _));
    }
 
    [Theory]
    [InlineData(new[] { "go" }, true)]
    [InlineData(new[] { "project", "go" }, true)]
    [InlineData(new[] { "project" }, false)]
    [InlineData(new string[0], false)]
    public void SupportsDebuggingHonorsTheAdvertisedCapabilityList(string[] supportedLaunchConfigurations, bool expected)
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var executable = builder.AddExecutable("app", "go", ".")
                                .WithDebugSupport(mode => new TestGoLaunchConfiguration { Mode = mode }, "go");
 
        var configuration = CreateConfiguration(debugSessionInfo: CreateDebugSessionInfo(supportedLaunchConfigurations));
 
        Assert.Equal(expected, executable.Resource.SupportsDebugging(configuration, out _));
    }
 
    [Fact]
    public void SupportsDebuggingReturnsFalseForProjectWhenTheAdvertisedCapabilityListOmitsIt()
    {
        // "project" gets no special treatment once the IDE advertises a list. The VS Code extension only
        // includes it when the C# extension is installed; treating it as implicit would route the
        // resource to an IDE that cannot launch it and leave it stuck.
        using var builder = TestDistributedApplicationBuilder.Create();
        var project = builder.AddProject<Projects.ServiceA>("proj", launchProfileName: "http");
 
        var configuration = CreateConfiguration(debugSessionInfo: CreateDebugSessionInfo(["go", "python"]));
 
        Assert.False(project.Resource.SupportsDebugging(configuration, out _));
    }
 
    [Fact]
    public void SupportsDebuggingReturnsFalseWhenTheResourceForcesProcessExecution()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var project = builder.AddProject<Projects.ServiceA>("proj", launchProfileName: "http")
                             .WithAnnotation(new ForceProcessExecutionAnnotation());
 
        Assert.False(project.Resource.SupportsDebugging(CreateConfiguration(), out _));
    }
 
    [Fact]
    public void SupportsDebuggingReturnsFalseForAPersistentResource()
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        var project = builder.AddProject<Projects.ServiceA>("proj", launchProfileName: "http")
                             .WithPersistentLifetime();
 
        Assert.False(project.Resource.SupportsDebugging(CreateConfiguration(), out _));
    }
 
    [Fact]
    public void SupportsDebuggingFallsBackToTheImplicitProjectRuleWhenDebugSessionInfoIsMalformed()
    {
        // Malformed DEBUG_SESSION_INFO is swallowed and treated as "no capability list", so the resource
        // keeps the Visual Studio behavior instead of failing the run outright.
        using var builder = TestDistributedApplicationBuilder.Create();
        var project = builder.AddProject<Projects.ServiceA>("proj", launchProfileName: "http");
        var executable = builder.AddExecutable("app", "go", ".")
                                .WithDebugSupport(mode => new TestGoLaunchConfiguration { Mode = mode }, "go");
 
        var configuration = CreateConfiguration(debugSessionInfo: "{ not json");
 
        Assert.True(project.Resource.SupportsDebugging(configuration, out _));
        Assert.False(executable.Resource.SupportsDebugging(configuration, out _));
    }
 
    private static IConfiguration CreateConfiguration(string? debugSessionPort = "12345", string? debugSessionInfo = null)
    {
        return new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?>
            {
                [DcpExecutor.DebugSessionPortVar] = debugSessionPort,
                [KnownConfigNames.DebugSessionInfo] = debugSessionInfo
            })
            .Build();
    }
 
    // DEBUG_SESSION_INFO is the JSON the IDE sends, for example:
    //   {"protocols_supported":["2024-03-03"],"supported_launch_configurations":["project","go"]}
    // protocols_supported is required, so it must be present for the payload to deserialize at all.
    private static string CreateDebugSessionInfo(string[] supportedLaunchConfigurations)
    {
        return JsonSerializer.Serialize(new
        {
            protocols_supported = new[] { "2024-03-03" },
            supported_launch_configurations = supportedLaunchConfigurations
        });
    }
 
    private static string GetProjectPath(IResource resource) => resource.Annotations.OfType<IProjectMetadata>().Last().ProjectPath;
 
    private static Task<object> CreateLaunchConfigurationForTestAsync(
        IResource resource,
        string mode = ExecutableLaunchMode.Debug,
        CancellationToken cancellationToken = default)
    {
        var callbackContext = LaunchConfigurationTestHelpers.CreateCallbackContext(
            resource,
            mode,
            cancellationToken: cancellationToken);
 
        return resource.CreateLaunchConfigurationAsync(callbackContext);
    }
 
    private sealed class TestGoLaunchConfiguration() : ExecutableLaunchConfiguration("go")
    {
        [JsonPropertyName("package")]
        public string Package { get; set; } = string.Empty;
    }
}