File: Projects\GuestAppHostProjectTests.cs
Web Access
Project: src\tests\Aspire.Cli.Tests\Aspire.Cli.Tests.csproj (Aspire.Cli.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.IO.Hashing;
using System.Net.Sockets;
using System.Text;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Configuration;
using Aspire.Cli.Diagnostics;
using Aspire.Cli.Interaction;
using Aspire.Cli.Packaging;
using Aspire.Cli.Processes;
using Aspire.Cli.Projects;
using Aspire.Cli.Resources;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Aspire.Cli.Utils;
using Aspire.Hosting;
using Aspire.Hosting.Backchannel;
using Aspire.Hosting.Utils;
using Aspire.TypeSystem;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Spectre.Console;
 
namespace Aspire.Cli.Tests.Projects;
 
public class GuestAppHostProjectTests : IDisposable
{
    private readonly TemporaryWorkspace _workspace;
    private readonly IConfiguration _configuration;
    private readonly ProfilingTelemetry _profilingTelemetry;
 
    public GuestAppHostProjectTests(ITestOutputHelper outputHelper)
    {
        _workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        _configuration = new ConfigurationBuilder().Build();
        _profilingTelemetry = new ProfilingTelemetry(_configuration);
    }
 
    public void Dispose()
    {
        _profilingTelemetry.Dispose();
        _workspace.Dispose();
        GC.SuppressFinalize(this);
    }
 
    [Fact]
    public async Task PruneObsoleteGeneratedFilesAsync_RemovesFilesTheGeneratorNoLongerProduces()
    {
        var outputPath = Path.Combine(_workspace.WorkspaceRoot.FullName, ".aspire", "modules");
        Directory.CreateDirectory(Path.Combine(outputPath, "aspire"));
        var kept = Path.Combine(outputPath, "aspire", "Kept.java");
        var obsolete = Path.Combine(outputPath, "aspire", "RemovedResource.java");
        await File.WriteAllTextAsync(kept, "class Kept { }");
        await File.WriteAllTextAsync(obsolete, "class RemovedResource { }");
 
        // The first generation records what it wrote.
        await GuestAppHostProject.PruneObsoleteGeneratedFilesAsync(
            outputPath,
            [Path.Combine("aspire", "Kept.java"), Path.Combine("aspire", "RemovedResource.java")],
            CancellationToken.None);
 
        // Dropping the package that produced RemovedResource.java means the generator stops emitting it.
        // javac compiles everything under the source root, so leaving it behind fails the build with a
        // reference to a type that no longer exists.
        await GuestAppHostProject.PruneObsoleteGeneratedFilesAsync(
            outputPath,
            [Path.Combine("aspire", "Kept.java")],
            CancellationToken.None);
 
        Assert.True(File.Exists(kept));
        Assert.False(File.Exists(obsolete));
    }
 
    [Fact]
    public async Task PruneObsoleteGeneratedFilesAsync_LeavesFilesTheGeneratorNeverWrote()
    {
        var outputPath = Path.Combine(_workspace.WorkspaceRoot.FullName, ".aspire", "modules");
        Directory.CreateDirectory(outputPath);
        var handWritten = Path.Combine(outputPath, "HandWritten.java");
        await File.WriteAllTextAsync(handWritten, "class HandWritten { }");
 
        // Only files a previous generation recorded are eligible, so nothing Aspire did not write is
        // ever deleted - including on the very first run, where there is no manifest at all.
        await GuestAppHostProject.PruneObsoleteGeneratedFilesAsync(outputPath, ["Generated.java"], CancellationToken.None);
        await GuestAppHostProject.PruneObsoleteGeneratedFilesAsync(outputPath, ["Generated.java"], CancellationToken.None);
 
        Assert.True(File.Exists(handWritten));
    }
 
    [Fact]
    public async Task WriteGeneratedFileAsync_WhenContentIsUnchanged_LeavesTimestampAlone()
    {
        var filePath = Path.Combine(_workspace.WorkspaceRoot.FullName, "Generated.java");
        await File.WriteAllTextAsync(filePath, "class Generated { }");
        var originalWriteTime = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc);
        File.SetLastWriteTimeUtc(filePath, originalWriteTime);
 
        var written = await GuestAppHostProject.WriteGeneratedFileAsync(filePath, "class Generated { }", preserveUnchangedFiles: true, CancellationToken.None);
 
        Assert.False(written);
        // The timestamp is the contract: every downstream incremental build decides what to recompile
        // from it, so regenerating identical content must not look like a change.
        Assert.Equal(originalWriteTime, File.GetLastWriteTimeUtc(filePath));
    }
 
    [Fact]
    public async Task WriteGeneratedFileAsync_WhenContentDiffers_WritesFile()
    {
        var filePath = Path.Combine(_workspace.WorkspaceRoot.FullName, "Generated.java");
        await File.WriteAllTextAsync(filePath, "class Generated { }");
        File.SetLastWriteTimeUtc(filePath, new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc));
 
        var written = await GuestAppHostProject.WriteGeneratedFileAsync(filePath, "class Generated { int x; }", preserveUnchangedFiles: true, CancellationToken.None);
 
        Assert.True(written);
        Assert.Equal("class Generated { int x; }", await File.ReadAllTextAsync(filePath));
        Assert.True(File.GetLastWriteTimeUtc(filePath) > new DateTime(2020, 1, 2, 0, 0, 0, DateTimeKind.Utc));
    }
 
    [Fact]
    public async Task WriteGeneratedFileAsync_WhenFileIsMissing_WritesFile()
    {
        var filePath = Path.Combine(_workspace.WorkspaceRoot.FullName, "New.java");
 
        var written = await GuestAppHostProject.WriteGeneratedFileAsync(filePath, "class New { }", preserveUnchangedFiles: true, CancellationToken.None);
 
        Assert.True(written);
        Assert.Equal("class New { }", await File.ReadAllTextAsync(filePath));
    }
 
    [Fact]
    public async Task WriteGeneratedFileAsync_WhenNotPreservingUnchangedFiles_RewritesIdenticalContent()
    {
        var filePath = Path.Combine(_workspace.WorkspaceRoot.FullName, "aspire_app.py");
        await File.WriteAllTextAsync(filePath, "def add_redis(): ...");
        var originalWriteTime = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc);
        File.SetLastWriteTimeUtc(filePath, originalWriteTime);
 
        var written = await GuestAppHostProject.WriteGeneratedFileAsync(filePath, "def add_redis(): ...", preserveUnchangedFiles: false, CancellationToken.None);
 
        // Languages that install the generated sources into an environment (uv/pip for Python)
        // rebuild off the source timestamp, so an unchanged file must still be rewritten or the
        // stale install is silently reused.
        Assert.True(written);
        Assert.True(File.GetLastWriteTimeUtc(filePath) > originalWriteTime);
    }
 
    [Fact]
    public void JavaIsTheOnlyLanguageThatPreservesUnchangedGeneratedFiles()
    {
        var languages = DefaultLanguageDiscovery.AllLanguages;
 
        Assert.Equal(
            ["Java"],
            languages.Where(language => language.PreserveUnchangedGeneratedFiles).Select(language => language.CodeGenerator));
    }
 
    [Fact]
    public void AspireJsonConfiguration_LoadOrCreate_SetsDefaultSdkVersion()
    {
        // Arrange
        var directory = _workspace.WorkspaceRoot.FullName;
 
        // Act
        var config = AspireJsonConfiguration.LoadOrCreate(directory, "13.1.0");
 
        // Assert
        Assert.Equal("13.1.0", config.SdkVersion);
    }
 
    [Fact]
    public void AspireJsonConfiguration_LoadOrCreate_PreservesExistingSdkVersion()
    {
        // Arrange - create settings.json with existing SDK version
        var settingsDir = _workspace.CreateDirectory(".aspire");
        var settingsPath = Path.Combine(settingsDir.FullName, "settings.json");
        File.WriteAllText(settingsPath, """
            {
                "sdkVersion": "12.0.0",
                "language": "typescript"
            }
            """);
 
        // Act
        var config = AspireJsonConfiguration.LoadOrCreate(_workspace.WorkspaceRoot.FullName, "13.1.0");
 
        // Assert - should preserve existing version, not override with default
        Assert.Equal("12.0.0", config.SdkVersion);
    }
 
    [Fact]
    public void AspireJsonConfiguration_Save_UpdatesSdkVersion()
    {
        // Arrange - create initial settings.json
        var settingsDir = _workspace.CreateDirectory(".aspire");
        var settingsPath = Path.Combine(settingsDir.FullName, "settings.json");
        File.WriteAllText(settingsPath, """
            {
                "sdkVersion": "12.0.0",
                "language": "typescript",
                "packages": {
                    "Aspire.Hosting.Redis": "12.0.0"
                }
            }
            """);
 
        // Act - load, update SDK version, and save
        var config = AspireJsonConfiguration.Load(_workspace.WorkspaceRoot.FullName);
        Assert.NotNull(config);
        config.SdkVersion = "13.1.0";
        config.Save(_workspace.WorkspaceRoot.FullName);
 
        // Assert - reload and verify
        var reloaded = AspireJsonConfiguration.Load(_workspace.WorkspaceRoot.FullName);
        Assert.NotNull(reloaded);
        Assert.Equal("13.1.0", reloaded.SdkVersion);
        Assert.Equal("typescript", reloaded.Language);
        Assert.NotNull(reloaded.Packages);
        Assert.Equal("12.0.0", reloaded.Packages["Aspire.Hosting.Redis"]);
    }
 
    [Fact]
    public void AspireJsonConfiguration_AddOrUpdatePackage_AddsNewPackage()
    {
        // Arrange
        var config = new AspireJsonConfiguration
        {
            SdkVersion = "13.1.0",
            Language = "typescript"
        };
 
        // Act
        config.AddOrUpdatePackage("Aspire.Hosting.Redis", "13.1.0");
 
        // Assert
        Assert.NotNull(config.Packages);
        Assert.Single(config.Packages);
        Assert.Equal("13.1.0", config.Packages["Aspire.Hosting.Redis"]);
    }
 
    [Fact]
    public void AspireJsonConfiguration_AddOrUpdatePackage_UpdatesExistingPackage()
    {
        // Arrange
        var config = new AspireJsonConfiguration
        {
            SdkVersion = "13.1.0",
            Language = "typescript",
            Packages = new Dictionary<string, string>
            {
                ["Aspire.Hosting.Redis"] = "12.0.0"
            }
        };
 
        // Act
        config.AddOrUpdatePackage("Aspire.Hosting.Redis", "13.1.0");
 
        // Assert
        Assert.NotNull(config.Packages);
        Assert.Single(config.Packages);
        Assert.Equal("13.1.0", config.Packages["Aspire.Hosting.Redis"]);
    }
 
    [Fact]
    public void AspireJsonConfiguration_GetIntegrationReferences_IncludesBasePackages()
    {
        // Arrange
        var config = new AspireJsonConfiguration
        {
            SdkVersion = "13.1.0",
            Language = "typescript",
            Packages = new Dictionary<string, string>
            {
                ["Aspire.Hosting.Redis"] = "13.1.0"
            }
        };
 
        // Act
        var refs = config.GetIntegrationReferences("13.1.0", "/tmp").ToList();
 
        // Assert - should include base package (Aspire.Hosting) plus explicit packages
        Assert.Contains(refs, r => r.Name == "Aspire.Hosting" && r.Version == "13.1.0" && !r.IsProjectReference);
        Assert.Contains(refs, r => r.Name == "Aspire.Hosting.Redis" && r.Version == "13.1.0" && !r.IsProjectReference);
        Assert.Equal(2, refs.Count);
    }
 
    [Fact]
    public void AspireJsonConfiguration_GetIntegrationReferences_WithNoExplicitPackages_ReturnsBasePackagesOnly()
    {
        // Arrange
        var config = new AspireJsonConfiguration
        {
            SdkVersion = "13.1.0",
            Language = "typescript"
        };
 
        // Act
        var refs = config.GetIntegrationReferences("13.1.0", "/tmp").ToList();
 
        // Assert - should include base package only (Aspire.Hosting)
        Assert.Single(refs);
        Assert.Contains(refs, r => r.Name == "Aspire.Hosting" && r.Version == "13.1.0");
    }
 
    [Fact]
    public void AspireJsonConfiguration_GetIntegrationReferences_WithEmptyVersion_UsesFallbackVersion()
    {
        // Arrange
        var config = new AspireJsonConfiguration
        {
            Language = "typescript",
            Packages = new Dictionary<string, string>
            {
                ["Aspire.Hosting.Redis"] = string.Empty
            }
        };
 
        // Act
        var refs = config.GetIntegrationReferences("13.1.0", "/tmp").ToList();
 
        // Assert
        Assert.Contains(refs, r => r.Name == "Aspire.Hosting" && r.Version == "13.1.0");
        Assert.Contains(refs, r => r.Name == "Aspire.Hosting.Redis" && r.Version == "13.1.0");
    }
 
    [Fact]
    public void AspireJsonConfiguration_GetIntegrationReferences_WithConfiguredSdkVersion_ReturnsConfiguredVersions()
    {
        // Arrange
        var config = new AspireJsonConfiguration
        {
            SdkVersion = "13.1.0",
            Language = "typescript",
            Channel = "daily",
            Packages = new Dictionary<string, string>
            {
                ["Aspire.Hosting.Redis"] = "13.1.0"
            }
        };
 
        // Act
        var refs = config.GetIntegrationReferences("13.1.0", "/tmp").ToList();
 
        // Assert
        Assert.Contains(refs, r => r.Name == "Aspire.Hosting" && r.Version == "13.1.0");
        Assert.Contains(refs, r => r.Name == "Aspire.Hosting.Redis" && r.Version == "13.1.0");
    }
 
    [Fact]
    public void AspireJsonConfiguration_GetIntegrationReferences_WithProjectReference_ReturnsProjectRef()
    {
        // Arrange
        var config = new AspireJsonConfiguration
        {
            SdkVersion = "13.1.0",
            Language = "typescript",
            Packages = new Dictionary<string, string>
            {
                ["Aspire.Hosting.Redis"] = "13.1.0",
                ["Aspire.Hosting.MyCustom"] = "../src/Aspire.Hosting.MyCustom/Aspire.Hosting.MyCustom.csproj"
            }
        };
 
        // Act
        var refs = config.GetIntegrationReferences("13.1.0", "/home/user/app").ToList();
 
        // Assert
        Assert.Contains(refs, r => r.Name == "Aspire.Hosting" && r.IsPackageReference);
        Assert.Contains(refs, r => r.Name == "Aspire.Hosting.Redis" && r.IsPackageReference);
        var projectRef = Assert.Single(refs, r => r.IsProjectReference);
        Assert.Equal("Aspire.Hosting.MyCustom", projectRef.Name);
        Assert.Null(projectRef.Version);
        Assert.NotNull(projectRef.ProjectPath);
        Assert.EndsWith(".csproj", projectRef.ProjectPath);
    }
 
    [Fact]
    public void AspireJsonConfiguration_Save_PreservesExtensionData()
    {
        // Arrange - create settings.json with extra properties
        var settingsDir = _workspace.CreateDirectory(".aspire");
        var settingsPath = Path.Combine(settingsDir.FullName, "settings.json");
        File.WriteAllText(settingsPath, """
            {
                "sdkVersion": "13.1.0",
                "language": "typescript",
                "features": {
                    "experimental": true
                },
                "customProperty": "customValue"
            }
            """);
 
        // Act - load, modify, and save
        var config = AspireJsonConfiguration.Load(_workspace.WorkspaceRoot.FullName);
        Assert.NotNull(config);
        config.SdkVersion = "13.2.0";
        config.Save(_workspace.WorkspaceRoot.FullName);
 
        // Assert - reload and verify extension data is preserved
        var json = File.ReadAllText(settingsPath);
        Assert.Contains("features", json);
        Assert.Contains("experimental", json);
        Assert.Contains("customProperty", json);
        Assert.Contains("customValue", json);
    }
 
    [Fact]
    public async Task AspireJsonConfiguration_MatchesSnapshot()
    {
        // Arrange - create a full settings.json
        var config = new AspireJsonConfiguration
        {
            Schema = "https://json.schemastore.org/aspire-settings.json",
            AppHostPath = "apphost.ts",
            Language = "typescript",
            SdkVersion = "13.1.0",
            Packages = new Dictionary<string, string>
            {
                ["Aspire.Hosting.Redis"] = "13.1.0",
                ["Aspire.Hosting.PostgreSQL"] = "13.1.0"
            }
        };
 
        // Act
        config.Save(_workspace.WorkspaceRoot.FullName);
 
        // Assert
        var settingsPath = AspireJsonConfiguration.GetFilePath(_workspace.WorkspaceRoot.FullName);
        var content = await File.ReadAllTextAsync(settingsPath);
 
        await Verify(content, extension: "json")
            .UseFileName("AspireJsonConfiguration_SettingsJson");
    }
 
    [Fact]
    public void GetServerEnvironmentVariables_ParsesLaunchSettingsWithComments()
    {
        var project = CreateGuestAppHostProject();
 
        var propertiesDir = _workspace.CreateDirectory("Properties");
        var launchSettingsPath = Path.Combine(propertiesDir.FullName, "launchSettings.json");
        File.WriteAllText(launchSettingsPath, """
            {
              "profiles": {
                "https": {
                  "commandName": "Project",
                  "applicationUrl": "https://localhost:16319;http://localhost:16320",
                  "environmentVariables": {
                    "ASPNETCORE_ENVIRONMENT": "Development",
                    "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:17269",
                    // This is a commented-out environment variable
                    //"ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL": "https://localhost:17269",
                    "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:17269"
                  }
                }
              }
            }
            """);
 
        var envVars = project.GetServerEnvironmentVariables(_workspace.WorkspaceRoot);
 
        Assert.Equal("https://localhost:16319;http://localhost:16320", envVars[KnownAspNetCoreConfigNames.Urls]);
        Assert.Equal("Development", envVars[KnownAspNetCoreConfigNames.Environment]);
        Assert.Equal("https://localhost:17269", envVars["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"]);
        Assert.Equal("https://localhost:17269", envVars["ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"]);
        Assert.False(envVars.ContainsKey("ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL"));
    }
 
    [Fact]
    public void GetServerEnvironmentVariables_UsesRequestedDefaultEnvironment()
    {
        var envVars = GuestAppHostProject.GetServerEnvironmentVariables(
            launchProfileEnvironmentVariables: null,
            defaultEnvironment: AppHostEnvironmentDefaults.ProductionEnvironmentName,
            inheritedEnvironmentVariables: new Dictionary<string, string?>());
 
        Assert.Equal("Production", envVars[KnownAspNetCoreConfigNames.DotNetEnvironment]);
        Assert.False(envVars.ContainsKey(KnownAspNetCoreConfigNames.Environment));
    }
 
    [Fact]
    public void GetServerEnvironmentVariables_IgnoresLaunchProfileEnvironmentVariablesWhenRequested()
    {
        var envVars = GuestAppHostProject.GetServerEnvironmentVariables(
            launchProfileEnvironmentVariables: new Dictionary<string, string>
            {
                [KnownAspNetCoreConfigNames.Urls] = "https://localhost:16319;http://localhost:16320",
                [KnownAspNetCoreConfigNames.Environment] = "Development",
                [KnownAspNetCoreConfigNames.DotNetEnvironment] = "Development",
                ["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"] = "https://localhost:17269",
                ["ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"] = "https://localhost:18269"
            },
            defaultEnvironment: AppHostEnvironmentDefaults.ProductionEnvironmentName,
            includeLaunchProfileEnvironmentVariables: false,
            inheritedEnvironmentVariables: new Dictionary<string, string?>());
 
        Assert.Equal("Production", envVars[KnownAspNetCoreConfigNames.DotNetEnvironment]);
        Assert.False(envVars.ContainsKey(KnownAspNetCoreConfigNames.Environment));
        Assert.Equal("https://localhost:16319;http://localhost:16320", envVars[KnownAspNetCoreConfigNames.Urls]);
        Assert.Equal("https://localhost:17269", envVars["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"]);
        Assert.Equal("https://localhost:18269", envVars["ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"]);
        Assert.False(envVars.ContainsKey("ASPIRE_ENVIRONMENT"));
    }
 
    [Fact]
    public void GetServerEnvironmentVariables_EnvironmentArgumentTakesPrecedenceOverLaunchProfileEnvironmentVariables()
    {
        var envVars = GuestAppHostProject.GetServerEnvironmentVariables(
            launchProfileEnvironmentVariables: new Dictionary<string, string>
            {
                [KnownAspNetCoreConfigNames.Urls] = "https://localhost:16319;http://localhost:16320",
                ["ASPIRE_ENVIRONMENT"] = "Development",
                [KnownAspNetCoreConfigNames.Environment] = "Development",
                [KnownAspNetCoreConfigNames.DotNetEnvironment] = "Development",
            },
            defaultEnvironment: AppHostEnvironmentDefaults.ProductionEnvironmentName,
            inheritedEnvironmentVariables: new Dictionary<string, string?>(),
            args: ["--environment", "Staging"]);
 
        Assert.Equal("Staging", envVars[KnownAspNetCoreConfigNames.DotNetEnvironment]);
        Assert.Equal("Development", envVars[KnownAspNetCoreConfigNames.Environment]);
        Assert.Equal("Development", envVars["ASPIRE_ENVIRONMENT"]);
    }
 
    [Fact]
    public void CreateGuestEnvironmentVariables_MergesLaunchProfileContextAndAdditionalEnvironmentVariables()
    {
        var project = CreateGuestAppHostProject();
 
        var aspireConfigPath = Path.Combine(_workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName);
        File.WriteAllText(aspireConfigPath, """
            {
              "profiles": {
                "https": {
                  "applicationUrl": "https://localhost:16319;http://localhost:16320",
                  "environmentVariables": {
                    "ASPIRE_ENVIRONMENT": "Staging",
                    "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:17269",
                    "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:18269"
                  }
                }
              }
            }
            """);
 
        var envVars = project.CreateGuestEnvironmentVariables(
            _workspace.WorkspaceRoot,
            new Dictionary<string, string>
            {
                ["CUSTOM_CONTEXT_VARIABLE"] = "context",
                [KnownAspNetCoreConfigNames.Urls] = "http://context"
            },
            new Dictionary<string, string>
            {
                ["SSL_CERT_DIR"] = "/tmp/certs"
            });
 
        Assert.Equal("context", envVars["CUSTOM_CONTEXT_VARIABLE"]);
        Assert.Equal("https://localhost:16319;http://localhost:16320", envVars[KnownAspNetCoreConfigNames.Urls]);
        Assert.Equal("Staging", envVars["ASPIRE_ENVIRONMENT"]);
        Assert.Equal("Staging", envVars[KnownAspNetCoreConfigNames.DotNetEnvironment]);
        Assert.False(envVars.ContainsKey(KnownAspNetCoreConfigNames.Environment));
        Assert.Equal("https://localhost:17269", envVars["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"]);
        Assert.Equal("https://localhost:18269", envVars["ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"]);
        Assert.Equal("/tmp/certs", envVars["SSL_CERT_DIR"]);
    }
 
    [Fact]
    public void CreateGuestEnvironmentVariables_IgnoresLaunchProfileEnvironmentVariablesWhenRequested()
    {
        var envVars = GuestAppHostProject.CreateGuestEnvironmentVariables(
            contextEnvironmentVariables: new Dictionary<string, string>(),
            launchProfileEnvironmentVariables: new Dictionary<string, string>
            {
                [KnownAspNetCoreConfigNames.Urls] = "https://localhost:16319;http://localhost:16320",
                ["ASPIRE_ENVIRONMENT"] = "Development",
                [KnownAspNetCoreConfigNames.Environment] = "Development",
                [KnownAspNetCoreConfigNames.DotNetEnvironment] = "Development",
                ["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"] = "https://localhost:17269",
                ["ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"] = "https://localhost:18269"
            },
            defaultEnvironment: AppHostEnvironmentDefaults.ProductionEnvironmentName,
            includeLaunchProfileEnvironmentVariables: false,
            inheritedEnvironmentVariables: new Dictionary<string, string?>());
 
        Assert.Equal("Production", envVars[KnownAspNetCoreConfigNames.DotNetEnvironment]);
        Assert.False(envVars.ContainsKey(KnownAspNetCoreConfigNames.Environment));
        Assert.Equal("https://localhost:16319;http://localhost:16320", envVars[KnownAspNetCoreConfigNames.Urls]);
        Assert.Equal("https://localhost:17269", envVars["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"]);
        Assert.Equal("https://localhost:18269", envVars["ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"]);
        Assert.False(envVars.ContainsKey("ASPIRE_ENVIRONMENT"));
    }
 
    [Fact]
    public void CreateGuestEnvironmentVariables_ForwardsAppHostArgumentsForGuestsThatCannotReadArgv()
    {
        var envVars = GuestAppHostProject.CreateGuestEnvironmentVariables(
            contextEnvironmentVariables: new Dictionary<string, string>(),
            launchProfileEnvironmentVariables: null,
            inheritedEnvironmentVariables: new Dictionary<string, string?>(),
            args: ["--operation", "publish", "--step", "publish", "--output-path", "/tmp/out dir"]);
 
        Assert.Equal(
            "--operation\npublish\n--step\npublish\n--output-path\n/tmp/out dir",
            envVars["ASPIRE_APPHOST_ARGS"]);
    }
 
    [Fact]
    public void CreateGuestEnvironmentVariables_DoesNotForwardAppHostArgumentsWhenThereAreNone()
    {
        var envVars = GuestAppHostProject.CreateGuestEnvironmentVariables(
            contextEnvironmentVariables: new Dictionary<string, string>(),
            launchProfileEnvironmentVariables: null,
            inheritedEnvironmentVariables: new Dictionary<string, string?>(),
            args: []);
 
        Assert.False(envVars.ContainsKey("ASPIRE_APPHOST_ARGS"));
    }
 
    [Fact]
    public void CreateGuestEnvironmentVariables_EnvironmentArgumentTakesPrecedenceOverLaunchProfileEnvironmentVariables()
    {
        var envVars = GuestAppHostProject.CreateGuestEnvironmentVariables(
            contextEnvironmentVariables: new Dictionary<string, string>(),
            launchProfileEnvironmentVariables: new Dictionary<string, string>
            {
                ["ASPIRE_ENVIRONMENT"] = "Development",
                [KnownAspNetCoreConfigNames.Environment] = "Development",
                [KnownAspNetCoreConfigNames.DotNetEnvironment] = "Development",
            },
            defaultEnvironment: AppHostEnvironmentDefaults.ProductionEnvironmentName,
            inheritedEnvironmentVariables: new Dictionary<string, string?>(),
            args: ["--environment", "Staging"]);
 
        Assert.Equal("Staging", envVars[KnownAspNetCoreConfigNames.DotNetEnvironment]);
        Assert.Equal("Development", envVars[KnownAspNetCoreConfigNames.Environment]);
        Assert.Equal("Development", envVars["ASPIRE_ENVIRONMENT"]);
    }
 
    [Fact]
    public void CreateGuestEnvironmentVariables_InheritedAspireEnvironmentOverridesDefaultEnvironment()
    {
        var envVars = GuestAppHostProject.CreateGuestEnvironmentVariables(
            contextEnvironmentVariables: new Dictionary<string, string>(),
            launchProfileEnvironmentVariables: null,
            defaultEnvironment: AppHostEnvironmentDefaults.ProductionEnvironmentName,
            inheritedEnvironmentVariables: new Dictionary<string, string?>
            {
                [AppHostEnvironmentDefaults.AspireEnvironmentVariableName] = "Staging"
            });
 
        Assert.Equal("Staging", envVars[KnownAspNetCoreConfigNames.DotNetEnvironment]);
        Assert.False(envVars.ContainsKey(KnownAspNetCoreConfigNames.Environment));
    }
 
    [Fact]
    public void CreateGuestEnvironmentVariables_DotnetEnvironmentTakesPrecedenceOverAspireEnvironment()
    {
        var envVars = GuestAppHostProject.CreateGuestEnvironmentVariables(
            contextEnvironmentVariables: new Dictionary<string, string>
            {
                [KnownAspNetCoreConfigNames.DotNetEnvironment] = "Production",
                [AppHostEnvironmentDefaults.AspireEnvironmentVariableName] = "Staging"
            },
            launchProfileEnvironmentVariables: null,
            inheritedEnvironmentVariables: new Dictionary<string, string?>());
 
        Assert.Equal("Production", envVars[KnownAspNetCoreConfigNames.DotNetEnvironment]);
        Assert.False(envVars.ContainsKey(KnownAspNetCoreConfigNames.Environment));
        Assert.Equal("Staging", envVars["ASPIRE_ENVIRONMENT"]);
    }
 
    [Fact]
    public void CreateGuestEnvironmentVariables_AspireEnvironmentTakesPrecedenceOverAspNetCoreEnvironment()
    {
        var envVars = GuestAppHostProject.CreateGuestEnvironmentVariables(
            contextEnvironmentVariables: new Dictionary<string, string>
            {
                [AppHostEnvironmentDefaults.AspireEnvironmentVariableName] = "Testing",
                [KnownAspNetCoreConfigNames.Environment] = "Staging"
            },
            launchProfileEnvironmentVariables: null,
            inheritedEnvironmentVariables: new Dictionary<string, string?>());
 
        Assert.Equal("Testing", envVars[KnownAspNetCoreConfigNames.DotNetEnvironment]);
        Assert.Equal("Staging", envVars[KnownAspNetCoreConfigNames.Environment]);
        Assert.Equal("Testing", envVars["ASPIRE_ENVIRONMENT"]);
    }
 
    [Fact]
    public void ConvertGeneratedFilesForLegacyTypeScriptAppHost_UsesTsFilesAndJsSpecifiers()
    {
        var files = new Dictionary<string, string>
        {
            ["aspire.mts"] = "import { refExpr } from './base.mjs';\n// aspire.mts",
            ["base.mts"] = "export type { MarshalledHandle } from './transport.mjs';\n// base.mts",
            ["transport.mts"] = "// transport.mts"
        };
 
        var convertedFiles = GuestAppHostProject.ConvertGeneratedFilesForLegacyTypeScriptAppHost(files);
 
        Assert.Equal(["aspire.ts", "base.ts", "transport.ts"], convertedFiles.Keys.Order(StringComparer.Ordinal));
        Assert.Equal("import { refExpr } from './base.js';\n// aspire.ts", convertedFiles["aspire.ts"]);
        Assert.Equal("export type { MarshalledHandle } from './transport.js';\n// base.ts", convertedFiles["base.ts"]);
        Assert.Equal("// transport.ts", convertedFiles["transport.ts"]);
    }
 
    /// <summary>
    /// Regression test for issue #17077: <c>aspire update</c> must not leave
    /// <c>aspire.config.json</c> advanced to newer package versions when guest SDK
    /// regeneration fails.
    /// </summary>
    /// <remarks>
    /// The test drives <see cref="GuestAppHostProject.UpdatePackagesAsync"/> through the
    /// code path that detects updates, then expects the call to throw from
    /// <c>BuildAndGenerateSdkAsync</c> because <see cref="TestAppHostServerProjectFactory.CreateAsync"/>
    /// throws. The on-disk config should still contain the original versions.
    /// </remarks>
    [Fact]
    public async Task UpdatePackagesAsync_WhenRegenerationFails_DoesNotMutateConfig()
    {
        var configPath = Path.Combine(_workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName);
        await File.WriteAllTextAsync(configPath, """
            {
              "sdk": { "version": "1.0.0" },
              "packages": { "Aspire.Hosting": "1.0.0" }
            }
            """);
 
        var appHostPath = Path.Combine(_workspace.WorkspaceRoot.FullName, "apphost.ts");
        await File.WriteAllTextAsync(appHostPath, "// test apphost");
 
        var fakeCache = new FakeNuGetPackageCache
        {
            GetPackagesAsyncCallback = (_, packageId, _, _, _, _, _) =>
                Task.FromResult<IEnumerable<Aspire.Shared.NuGetPackageCli>>(
                [
                    new Aspire.Shared.NuGetPackageCli { Id = packageId, Version = "2.0.0", Source = "test" }
                ])
        };
 
        var implicitChannel = PackageChannel.CreateImplicitChannel(fakeCache, new TestFeatures(), NullLogger.Instance);
 
        var interactionService = new TestInteractionService
        {
            ConfirmCallback = (_, _) => true
        };
 
        var project = CreateGuestAppHostProject(
            interactionService: interactionService,
            identityChannel: "pr-99999");
 
        var context = new UpdatePackagesContext
        {
            AppHostFile = new FileInfo(appHostPath),
            Channel = implicitChannel,
            ConfirmBinding = PromptBinding.CreateDefault<bool>(false),
            NuGetConfigDirBinding = PromptBinding.CreateDefault<string?>(null),
        };
 
        await Assert.ThrowsAnyAsync<Exception>(
            () => project.UpdatePackagesAsync(context, CancellationToken.None));
 
        var reloaded = AspireConfigFile.Load(_workspace.WorkspaceRoot.FullName);
        Assert.NotNull(reloaded);
        Assert.Equal("1.0.0", reloaded.SdkVersion);
        Assert.NotNull(reloaded.Packages);
        Assert.Equal("1.0.0", reloaded.Packages["Aspire.Hosting"]);
        Assert.Null(reloaded.Channel);
    }
 
    [Fact]
    public async Task AddPackageAsync_WhenRegenerationFails_DoesNotMutateConfig()
    {
        var configPath = Path.Combine(_workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName);
        await File.WriteAllTextAsync(configPath, """
            {
              "sdk": { "version": "1.0.0" },
              "packages": { "Aspire.Hosting": "1.0.0" }
            }
            """);
 
        var appHostPath = Path.Combine(_workspace.WorkspaceRoot.FullName, "apphost.ts");
        await File.WriteAllTextAsync(appHostPath, "// test apphost");
 
        var factory = new TestAppHostServerProjectFactory
        {
            CreateAsyncCallback = (appPath, _) =>
                Task.FromResult<IAppHostServerProject>(new FakeFailingAppHostServerProject(appPath))
        };
 
        var project = CreateGuestAppHostProject(appHostServerProjectFactory: factory);
 
        var result = await project.AddPackageAsync(
            new AddPackageContext
            {
                AppHostFile = new FileInfo(appHostPath),
                PackageId = "Aspire.Hosting.Redis",
                PackageVersion = "2.0.0",
            },
            CancellationToken.None);
 
        Assert.False(result);
 
        var reloaded = AspireConfigFile.Load(_workspace.WorkspaceRoot.FullName);
        Assert.NotNull(reloaded);
        Assert.Equal("1.0.0", reloaded.SdkVersion);
        Assert.NotNull(reloaded.Packages);
        Assert.Equal("1.0.0", reloaded.Packages["Aspire.Hosting"]);
        Assert.False(reloaded.Packages.ContainsKey("Aspire.Hosting.Redis"));
    }
 
    [Fact]
    public async Task FindAndStopRunningInstanceAsync_CleansUpDeadPidSocketAndReturnsNoRunningInstance()
    {
        var appHostPath = Path.Combine(_workspace.WorkspaceRoot.FullName, "apphost.ts");
        await File.WriteAllTextAsync(appHostPath, "// test apphost");
 
        var factory = new TestAppHostServerProjectFactory
        {
            CreateAsyncCallback = (appPath, _) =>
                Task.FromResult<IAppHostServerProject>(new FakeFailingAppHostServerProject(appPath))
        };
 
        var project = CreateGuestAppHostProject(appHostServerProjectFactory: factory);
        var socketPath = CreateMatchingSocketFile(_workspace.WorkspaceRoot.FullName, int.MaxValue - 1);
 
        var result = await project.FindAndStopRunningInstanceAsync(
            new FileInfo(appHostPath),
            _workspace.WorkspaceRoot,
            CancellationToken.None);
 
        Assert.Equal(RunningInstanceResult.NoRunningInstance, result);
        Assert.False(File.Exists(socketPath));
    }
 
    [Fact]
    public async Task UpdatePackagesAsync_ExplicitStableChannel_WhenRegenerationFails_DoesNotMutateConfig()
    {
        var configPath = Path.Combine(_workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName);
        await File.WriteAllTextAsync(configPath, """
            {
              "sdk": { "version": "1.0.0" },
              "channel": "staging",
              "packages": { "Aspire.Hosting": "1.0.0" }
            }
            """);
 
        var appHostPath = Path.Combine(_workspace.WorkspaceRoot.FullName, "apphost.ts");
        await File.WriteAllTextAsync(appHostPath, "// test apphost");
 
        var stableCache = new FakeNuGetPackageCache
        {
            GetPackagesAsyncCallback = (_, packageId, _, _, _, _, _) =>
                Task.FromResult<IEnumerable<Aspire.Shared.NuGetPackageCli>>(
                [
                    new Aspire.Shared.NuGetPackageCli { Id = packageId, Version = "2.0.0", Source = "stable" }
                ])
        };
 
        var stableChannel = PackageChannel.CreateExplicitChannel(
            PackageChannelNames.Stable,
            PackageChannelQuality.Both,
            [new PackageMapping("Aspire.*", "stable")],
            stableCache,
            features: new TestFeatures(), NullLogger.Instance);
 
        var interactionService = new TestInteractionService
        {
            ConfirmCallback = (_, _) => true
        };
 
        var project = CreateGuestAppHostProject(interactionService: interactionService);
 
        var context = new UpdatePackagesContext
        {
            AppHostFile = new FileInfo(appHostPath),
            Channel = stableChannel,
            ConfirmBinding = PromptBinding.CreateDefault<bool>(false),
            NuGetConfigDirBinding = PromptBinding.CreateDefault<string?>(null),
        };
 
        await Assert.ThrowsAnyAsync<Exception>(
            () => project.UpdatePackagesAsync(context, CancellationToken.None));
 
        var reloaded = AspireConfigFile.Load(_workspace.WorkspaceRoot.FullName);
        Assert.NotNull(reloaded);
        Assert.Equal(PackageChannelNames.Staging, reloaded.Channel);
        Assert.Equal("1.0.0", reloaded.SdkVersion);
        Assert.Equal("1.0.0", reloaded.Packages?["Aspire.Hosting"]);
    }
 
    [Fact]
    public async Task UpdatePackagesAsync_ExplicitStagingChannel_WhenRegenerationFails_DoesNotMutateConfig()
    {
        var configPath = Path.Combine(_workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName);
        await File.WriteAllTextAsync(configPath, """
            {
              "sdk": { "version": "1.0.0" },
              "packages": { "Aspire.Hosting": "1.0.0" }
            }
            """);
 
        var appHostPath = Path.Combine(_workspace.WorkspaceRoot.FullName, "apphost.ts");
        await File.WriteAllTextAsync(appHostPath, "// test apphost");
 
        var stagingCache = new FakeNuGetPackageCache
        {
            GetPackagesAsyncCallback = (_, packageId, _, _, _, _, _) =>
                Task.FromResult<IEnumerable<Aspire.Shared.NuGetPackageCli>>(
                [
                    new Aspire.Shared.NuGetPackageCli { Id = packageId, Version = "2.0.0", Source = "staging" }
                ])
        };
 
        var stagingChannel = PackageChannel.CreateExplicitChannel(
            PackageChannelNames.Staging,
            PackageChannelQuality.Both,
            [new PackageMapping("Aspire*", "staging")],
            stagingCache,
            features: new TestFeatures(), NullLogger.Instance);
 
        var interactionService = new TestInteractionService
        {
            ConfirmCallback = (_, _) => true
        };
 
        var project = CreateGuestAppHostProject(interactionService: interactionService);
 
        var context = new UpdatePackagesContext
        {
            AppHostFile = new FileInfo(appHostPath),
            Channel = stagingChannel,
            ConfirmBinding = PromptBinding.CreateDefault<bool>(false),
            NuGetConfigDirBinding = PromptBinding.CreateDefault<string?>(null),
        };
 
        await Assert.ThrowsAnyAsync<Exception>(
            () => project.UpdatePackagesAsync(context, CancellationToken.None));
 
        var reloaded = AspireConfigFile.Load(_workspace.WorkspaceRoot.FullName);
        Assert.NotNull(reloaded);
        Assert.Null(reloaded.Channel);
        Assert.Equal("1.0.0", reloaded.SdkVersion);
        Assert.Equal("1.0.0", reloaded.Packages?["Aspire.Hosting"]);
    }
 
    [Fact]
    public async Task UpdatePackagesAsync_ExplicitStableChannel_DoesNotPersistStableChannelWhenProjectIsUpToDate()
    {
        var configPath = Path.Combine(_workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName);
        await File.WriteAllTextAsync(configPath, """
            {
              "sdk": { "version": "2.0.0" },
              "channel": "staging",
              "packages": { "Aspire.Hosting": "2.0.0" }
            }
            """);
 
        var appHostPath = Path.Combine(_workspace.WorkspaceRoot.FullName, "apphost.ts");
        await File.WriteAllTextAsync(appHostPath, "// test apphost");
 
        var stableCache = new FakeNuGetPackageCache
        {
            GetPackagesAsyncCallback = (_, packageId, _, _, _, _, _) =>
                Task.FromResult<IEnumerable<Aspire.Shared.NuGetPackageCli>>(
                [
                    new Aspire.Shared.NuGetPackageCli { Id = packageId, Version = "2.0.0", Source = "stable" }
                ])
        };
 
        var stableChannel = PackageChannel.CreateExplicitChannel(
            PackageChannelNames.Stable,
            PackageChannelQuality.Both,
            [new PackageMapping("Aspire.*", "stable")],
            stableCache,
            features: new TestFeatures(), NullLogger.Instance);
 
        var project = CreateGuestAppHostProject();
 
        var context = new UpdatePackagesContext
        {
            AppHostFile = new FileInfo(appHostPath),
            Channel = stableChannel,
            ConfirmBinding = PromptBinding.CreateDefault<bool>(false),
            NuGetConfigDirBinding = PromptBinding.CreateDefault<string?>(null),
        };
 
        var result = await project.UpdatePackagesAsync(context, CancellationToken.None);
 
        Assert.False(result.UpdatesApplied);
        var reloaded = AspireConfigFile.Load(_workspace.WorkspaceRoot.FullName);
        Assert.NotNull(reloaded);
        Assert.Equal(PackageChannelNames.Staging, reloaded.Channel);
        Assert.Equal("2.0.0", reloaded.SdkVersion);
        Assert.Equal("2.0.0", reloaded.Packages?["Aspire.Hosting"]);
    }
 
    /// <summary>
    /// Regression test for the v3 channel refactor: <c>aspire run</c> must be a pure read
    /// for <c>aspire.config.json#channel</c>. A no-op rewrite (same value) or a silent
    /// identity-channel pin (when unset) on every invocation is not useful work and
    /// hides intent — the seed write at <c>aspire init</c> / scaffolding time and the
    /// explicit channel resolution in <c>aspire update</c> are the only legitimate
    /// channel-write paths.
    /// </summary>
    /// <remarks>
    /// The test seeds <c>aspire.config.json</c> with a known channel value, drives
    /// <see cref="GuestAppHostProject.RunAsync"/> past the channel-write site (via a
    /// fake <see cref="IAppHostServerProject"/> that returns a failed prepare result so
    /// <c>RunAsync</c> takes the early <c>FailedToBuildArtifacts</c> return), and then
    /// reloads <c>aspire.config.json</c> from disk to assert the on-disk channel is
    /// unchanged. The identity channel is set to a distinctive value
    /// (<c>pr-99999</c>) so any accidental identity pin would be detectable.
    /// </remarks>
    [Theory]
    [InlineData("stable")]
    [InlineData(null)]
    public async Task RunAsync_DoesNotMutateConfigChannel(string? seededChannel)
    {
        var configPath = Path.Combine(_workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName);
        var seededJson = seededChannel is null
            ? """
              {
                "sdk": { "version": "1.0.0" },
                "packages": { "Aspire.Hosting": "1.0.0" }
              }
              """
            : $$"""
              {
                "sdk": { "version": "1.0.0" },
                "channel": "{{seededChannel}}",
                "packages": { "Aspire.Hosting": "1.0.0" }
              }
              """;
        await File.WriteAllTextAsync(configPath, seededJson);
 
        var appHostPath = Path.Combine(_workspace.WorkspaceRoot.FullName, "apphost.ts");
        await File.WriteAllTextAsync(appHostPath, "// test apphost");
 
        // Drive RunAsync past the (now-removed) channel-write site by returning a fake
        // apphost server whose PrepareAsync reports failure. RunAsync takes the early
        // FailedToBuildArtifacts return without touching the network or starting a server.
        var factory = new TestAppHostServerProjectFactory
        {
            CreateAsyncCallback = (path, _) =>
                Task.FromResult<IAppHostServerProject>(new FakeFailingAppHostServerProject(path))
        };
 
        var project = CreateGuestAppHostProject(
            identityChannel: "pr-99999",
            appHostServerProjectFactory: factory);
 
        var context = new AppHostProjectContext
        {
            AppHostFile = new FileInfo(appHostPath),
            WorkingDirectory = _workspace.WorkspaceRoot,
        };
 
        var exitCode = await project.RunAsync(context, CancellationToken.None);
        Assert.Equal(CliExitCodes.FailedToBuildArtifacts, exitCode);
 
        var reloaded = AspireConfigFile.Load(_workspace.WorkspaceRoot.FullName);
        Assert.NotNull(reloaded);
        // Pre-fix, RunAsync would have written `seededChannel ?? "pr-99999"` here on every
        // invocation. Post-fix, RunAsync is a pure read for the channel.
        Assert.Equal(seededChannel, reloaded.Channel);
    }
 
    [Theory]
    [InlineData(CliExitCodes.Success, "The AppHost server process exited")]
    [InlineData(42, "The AppHost server process exited unexpectedly with exit code 42")]
    [InlineData(null, "The AppHost server process exited unexpectedly")]
    public async Task StartBackchannelConnectionAsync_WhenGuestServerExitsBeforeBackchannelConnects_ReportsExitCodeWhenKnown(
        int? serverExitCode,
        string expectedMessage)
    {
        var backchannel = new TestAppHostBackchannel
        {
            ConnectAsyncCallback = (_, _) => throw new SocketException((int)SocketError.ConnectionRefused)
        };
        var project = CreateGuestAppHostProject(backchannel: backchannel);
        var serverSession = new FakeAppHostServerSession
        {
            ServerHasExited = true,
            ServerExitCode = serverExitCode
        };
        var backchannelCompletionSource = new TaskCompletionSource<IAppHostCliBackchannel>(TaskCreationOptions.RunContinuationsAsynchronously);
 
        await InvokeStartBackchannelConnectionAsync(project, serverSession, backchannelCompletionSource);
 
        var exception = await Assert.ThrowsAsync<FailedToConnectBackchannelConnection>(
            () => backchannelCompletionSource.Task).DefaultTimeout();
        Assert.Equal(expectedMessage, exception.Message);
    }
 
    [Fact]
    public async Task StartBackchannelConnectionAsync_UsesConfiguredBackchannelTimeout()
    {
        var configuration = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?>
            {
                [KnownConfigNames.CliBackchannelConnectTimeoutSeconds] = "0"
            })
            .Build();
        var backchannel = new TestAppHostBackchannel
        {
            ConnectAsyncCallback = (_, _) => throw new SocketException((int)SocketError.ConnectionRefused)
        };
        var project = CreateGuestAppHostProject(backchannel: backchannel, configuration: configuration);
        var serverSession = new FakeAppHostServerSession();
        var backchannelCompletionSource = new TaskCompletionSource<IAppHostCliBackchannel>(TaskCreationOptions.RunContinuationsAsynchronously);
        using var cancellationSource = new CancellationTokenSource(TimeSpan.FromSeconds(1));
 
        try
        {
            await InvokeStartBackchannelConnectionAsync(
                project,
                serverSession,
                backchannelCompletionSource,
                cancellationSource.Token);
        }
        catch (OperationCanceledException) when (cancellationSource.IsCancellationRequested)
        {
        }
 
        Assert.True(backchannelCompletionSource.Task.IsFaulted, "The configured immediate timeout should fault the guest backchannel wait before test cancellation.");
        var exception = await Assert.ThrowsAsync<TimeoutException>(() => backchannelCompletionSource.Task);
        Assert.Contains("after 0 seconds", exception.Message);
    }
 
    [Fact]
    public async Task RunAsync_PassesWorkloadIdToAppHostServerEnvironment()
    {
        var appHostPath = Path.Combine(_workspace.WorkspaceRoot.FullName, "apphost.ts");
        await File.WriteAllTextAsync(appHostPath, "// test apphost");
        var appHostFile = new FileInfo(appHostPath);
        var expectedWorkloadId = AppHostWorkloadId.Create(appHostFile);
 
        var projectFactory = new TestAppHostServerProjectFactory
        {
            CreateAsyncCallback = (path, _) =>
                Task.FromResult<IAppHostServerProject>(new FakeSucceedingAppHostServerProject(path))
        };
 
        var serverSession = new FakeAppHostServerSession
        {
            GetRpcClientAsyncCallback = _ => Task.FromException<IAppHostRpcClient>(
                new InvalidOperationException("Stop after the server launch environment has been captured."))
        };
        var sessionFactory = new FakeAppHostServerSessionFactory
        {
            Session = serverSession
        };
        var project = CreateGuestAppHostProject(
            appHostServerProjectFactory: projectFactory,
            serverSessionFactory: sessionFactory);
 
        var context = new AppHostProjectContext
        {
            AppHostFile = appHostFile,
            WorkingDirectory = _workspace.WorkspaceRoot,
            EnvironmentVariables = new Dictionary<string, string>()
        };
 
        var exitCode = await project.RunAsync(context, CancellationToken.None);
 
        Assert.Equal(CliExitCodes.FailedToDotnetRunAppHost, exitCode);
        Assert.True(serverSession.StartAsyncCalled);
        Assert.NotNull(sessionFactory.CapturedEnvironmentVariables);
        Assert.Equal(expectedWorkloadId, sessionFactory.CapturedEnvironmentVariables[KnownConfigNames.DcpWorkloadId]);
    }
 
    [Fact]
    public async Task RunAsync_SignalsBuildCompletionAfterGuestAppHostLaunches()
    {
        var appHostPath = Path.Combine(_workspace.WorkspaceRoot.FullName, "apphost.ts");
        await File.WriteAllTextAsync(appHostPath, "// test apphost");
        var appHostFile = new FileInfo(appHostPath);
        var buildCompletionSource = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
        var launchStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var allowLaunchToComplete = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
 
        var extensionBackchannel = new TestExtensionBackchannel
        {
            HasCapabilityAsyncCallback = (capability, _) => Task.FromResult(capability == "node")
        };
        using var extensionServices = new ServiceCollection()
            .AddSingleton<IExtensionBackchannel>(extensionBackchannel)
            .BuildServiceProvider();
        var interactionService = new TestExtensionInteractionService(extensionServices)
        {
            LaunchAppHostAsyncCallback = async () =>
            {
                launchStarted.TrySetResult();
                await allowLaunchToComplete.Task;
            }
        };
 
        var runtimeSpec = new RuntimeSpec
        {
            Language = "typescript/nodejs",
            DisplayName = "TypeScript (Node.js)",
            CodeGenLanguage = "TypeScript",
            DetectionPatterns = ["apphost.ts"],
            Execute = new CommandSpec { Command = "node", Args = ["apphost.js"] },
            ExtensionLaunchCapability = "node"
        };
        var serverSession = new FakeAppHostServerSession(new FakeAppHostRpcClient { RuntimeSpec = runtimeSpec });
        var sessionFactory = new FakeAppHostServerSessionFactory { Session = serverSession };
        var projectFactory = new TestAppHostServerProjectFactory
        {
            CreateAsyncCallback = (path, _) =>
                Task.FromResult<IAppHostServerProject>(new FakeSucceedingAppHostServerProject(path))
        };
        var project = CreateGuestAppHostProject(
            interactionService: interactionService,
            appHostServerProjectFactory: projectFactory,
            serverSessionFactory: sessionFactory);
        var context = new AppHostProjectContext
        {
            AppHostFile = appHostFile,
            WorkingDirectory = _workspace.WorkspaceRoot,
            EnvironmentVariables = new Dictionary<string, string>(),
            BuildCompletionSource = buildCompletionSource,
            BackchannelCompletionSource = new TaskCompletionSource<IAppHostCliBackchannel>(TaskCreationOptions.RunContinuationsAsynchronously)
        };
        using var cancellationSource = new CancellationTokenSource();
 
        var runTask = project.RunAsync(context, cancellationSource.Token);
        await launchStarted.Task.DefaultTimeout();
 
        Assert.False(buildCompletionSource.Task.IsCompleted);
 
        allowLaunchToComplete.TrySetResult();
        Assert.True(await buildCompletionSource.Task.DefaultTimeout());
 
        await cancellationSource.CancelAsync();
        Assert.Equal(CliExitCodes.Cancelled, await runTask.DefaultTimeout());
    }
 
    [Fact]
    public async Task PublishAsync_PassesResolvedAspireHomeToAppHostServerEnvironment()
    {
        var appHostPath = Path.Combine(_workspace.WorkspaceRoot.FullName, "apphost.ts");
        await File.WriteAllTextAsync(appHostPath, "// test apphost");
        var appHostFile = new FileInfo(appHostPath);
 
        var projectFactory = new TestAppHostServerProjectFactory
        {
            CreateAsyncCallback = (path, _) =>
                Task.FromResult<IAppHostServerProject>(new FakeSucceedingAppHostServerProject(path))
        };
        var serverSession = new FakeAppHostServerSession
        {
            GetRpcClientAsyncCallback = _ => Task.FromException<IAppHostRpcClient>(
                new InvalidOperationException("Stop after the server launch environment has been captured."))
        };
        var sessionFactory = new FakeAppHostServerSessionFactory
        {
            Session = serverSession
        };
        var project = CreateGuestAppHostProject(
            appHostServerProjectFactory: projectFactory,
            serverSessionFactory: sessionFactory);
        var context = new PublishContext
        {
            AppHostFile = appHostFile,
            WorkingDirectory = _workspace.WorkspaceRoot
        };
 
        var exitCode = await project.PublishAsync(context, CancellationToken.None);
 
        Assert.Equal(CliExitCodes.FailedToDotnetRunAppHost, exitCode);
        Assert.True(serverSession.StartAsyncCalled);
        Assert.NotNull(sessionFactory.CapturedEnvironmentVariables);
        Assert.Equal(
            Path.Combine(AppContext.BaseDirectory, ".home", ".aspire"),
            sessionFactory.CapturedEnvironmentVariables[KnownConfigNames.AspireHome]);
    }
 
    [Fact]
    public void IsUsingProjectReferencesReturnsFalseWhenIdentityIsOverridden()
    {
        // When ASPIRE_CLI_* identity overrides (or the install sidecar) are active the CLI is
        // emulating an installed build, which is never resolving Aspire packages through in-repo
        // project references. This must hold even for a source (DEBUG) build run from inside the
        // Aspire repo, where AspireRepositoryDetector would otherwise match the repo's Aspire.slnx
        // (via its Environment.ProcessPath fallback) and force project-reference mode — which
        // short-circuits channel resolution so an emulated staging/daily apphost silently resolves
        // stable nuget.org packages instead of its pinned channel's feed.
        var project = CreateGuestAppHostProject(identityOverridden: true);
 
        var appHostPath = Path.Combine(_workspace.WorkspaceRoot.FullName, "apphost.ts");
 
        Assert.False(project.IsUsingProjectReferences(new FileInfo(appHostPath)));
    }
 
    private GuestAppHostProject CreateGuestAppHostProject()
        => CreateGuestAppHostProject(interactionService: null, identityChannel: "local");
 
    /// <summary>
    /// Regression test for https://github.com/microsoft/aspire/issues/18103:
    /// During <c>aspire update</c>, the code-generation step calls
    /// <c>WarnIfCliSdkVersionSkew</c> which reads the SDK version from disk. At that
    /// point the in-memory config has already been updated to the CLI's version, but
    /// the file hasn't been saved yet. The method should not emit a version-skew warning
    /// when the update is actively aligning versions.
    /// </summary>
    /// <remarks>
    /// The test drives <see cref="GuestAppHostProject.UpdatePackagesAsync"/> to demonstrate
    /// the update scenario (stale on-disk SDK version, update available to match CLI). With
    /// <see cref="FakeSucceedingAppHostServerProject"/> and <see cref="FakeAppHostServerSession"/>
    /// (which returns empty results from <c>GenerateCodeAsync</c>), the full update flow
    /// succeeds. The assertion validates that the skew-warning method does not emit a spurious
    /// warning for the stale on-disk version when the update is aligning versions to the CLI.
    /// </remarks>
    [Fact]
    public async Task UpdatePackagesAsync_DoesNotEmitStaleVersionSkewWarningDuringUpdate()
    {
        var cliVersion = VersionHelper.GetDefaultSdkVersion();
        var staleVersion = "1.0.0";
 
        var configPath = Path.Combine(_workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName);
        await File.WriteAllTextAsync(configPath, $$"""
            {
              "sdk": { "version": "{{staleVersion}}" },
              "packages": { "Aspire.Hosting": "{{staleVersion}}" }
            }
            """);
 
        var appHostPath = Path.Combine(_workspace.WorkspaceRoot.FullName, "apphost.ts");
        await File.WriteAllTextAsync(appHostPath, "// test apphost");
 
        // Return the CLI version as the latest available, so aspire update would align them.
        var fakeCache = new FakeNuGetPackageCache
        {
            GetPackagesAsyncCallback = (_, packageId, _, _, _, _, _) =>
                Task.FromResult<IEnumerable<Aspire.Shared.NuGetPackageCli>>(
                [
                    new Aspire.Shared.NuGetPackageCli { Id = packageId, Version = cliVersion, Source = "test" }
                ])
        };
 
        var implicitChannel = PackageChannel.CreateImplicitChannel(fakeCache, new TestFeatures(), NullLogger.Instance);
 
        var interactionService = new TestInteractionService
        {
            ConfirmCallback = (_, _) => true
        };
 
        var factory = new TestAppHostServerProjectFactory
        {
            CreateAsyncCallback = (appPath, _) =>
                Task.FromResult<IAppHostServerProject>(new FakeSucceedingAppHostServerProject(appPath))
        };
 
        IAppHostServerSessionFactory sessionFactory = new FakeAppHostServerSessionFactory();
 
        var project = CreateGuestAppHostProject(
            interactionService: interactionService,
            appHostServerProjectFactory: factory,
            serverSessionFactory: sessionFactory);
 
        var context = new UpdatePackagesContext
        {
            AppHostFile = new FileInfo(appHostPath),
            Channel = implicitChannel,
            ConfirmBinding = PromptBinding.CreateDefault<bool>(false),
            NuGetConfigDirBinding = PromptBinding.CreateDefault<string?>(null),
        };
 
        // UpdatePackagesAsync will go through BuildAndGenerateSdkAsync → GenerateCodeViaRpcAsync
        // which calls WarnIfCliSdkVersionSkew reading the stale on-disk config.
        // It should NOT warn because the update is aligning versions to match the CLI.
        await project.UpdatePackagesAsync(context, CancellationToken.None);
 
        Assert.Empty(interactionService.DisplayedErrors);
        Assert.Collection(interactionService.DisplayedMessages,
            m =>
            {
                Assert.Equal("package", m.Emoji.Name);
                Assert.Equal($"Aspire SDK {staleVersion} to {cliVersion}", Markup.Remove(m.Message));
            },
            m =>
            {
                Assert.Equal("package", m.Emoji.Name);
                Assert.Equal($"Aspire.Hosting {staleVersion} to {cliVersion}", Markup.Remove(m.Message));
            },
            m =>
            {
                Assert.Equal("warning", m.Emoji.Name);
                Assert.Equal(ErrorStrings.LegacyTypeScriptAppHostWarning, Markup.Remove(m.Message));
            },
            m =>
            {
                Assert.Equal("package", m.Emoji.Name);
                Assert.Equal(UpdateCommandStrings.RegeneratedSdkCode, m.Message);
            });
    }
 
    /// <summary>
    /// Verifies that <c>WarnIfCliSdkVersionSkew</c> emits the
    /// <see cref="ErrorStrings.CodegenVersionSkewWarning"/> when the on-disk SDK version
    /// genuinely differs from the CLI version and the update target does NOT align them.
    /// </summary>
    [Fact]
    public async Task UpdatePackagesAsync_EmitsVersionSkewWarningWhenTargetDiffersFromCli()
    {
        var staleVersion = "1.0.0";
        var updateTargetVersion = "2.0.0"; // Different from CLI version — legitimate skew
 
        var configPath = Path.Combine(_workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName);
        await File.WriteAllTextAsync(configPath, $$"""
            {
              "sdk": { "version": "{{staleVersion}}" },
              "packages": { "Aspire.Hosting": "{{staleVersion}}" }
            }
            """);
 
        var appHostPath = Path.Combine(_workspace.WorkspaceRoot.FullName, "apphost.ts");
        await File.WriteAllTextAsync(appHostPath, "// test apphost");
 
        // Return a version that does NOT match the CLI version — the skew is genuine.
        var fakeCache = new FakeNuGetPackageCache
        {
            GetPackagesAsyncCallback = (_, packageId, _, _, _, _, _) =>
                Task.FromResult<IEnumerable<Aspire.Shared.NuGetPackageCli>>(
                [
                    new Aspire.Shared.NuGetPackageCli { Id = packageId, Version = updateTargetVersion, Source = "test" }
                ])
        };
 
        var implicitChannel = PackageChannel.CreateImplicitChannel(fakeCache, new TestFeatures(), NullLogger.Instance);
 
        var interactionService = new TestInteractionService
        {
            ConfirmCallback = (_, _) => true
        };
 
        var factory = new TestAppHostServerProjectFactory
        {
            CreateAsyncCallback = (appPath, _) =>
                Task.FromResult<IAppHostServerProject>(new FakeSucceedingAppHostServerProject(appPath))
        };
 
        IAppHostServerSessionFactory sessionFactory = new FakeAppHostServerSessionFactory();
 
        var project = CreateGuestAppHostProject(
            interactionService: interactionService,
            appHostServerProjectFactory: factory,
            serverSessionFactory: sessionFactory);
 
        var context = new UpdatePackagesContext
        {
            AppHostFile = new FileInfo(appHostPath),
            Channel = implicitChannel,
            ConfirmBinding = PromptBinding.CreateDefault<bool>(false),
            NuGetConfigDirBinding = PromptBinding.CreateDefault<string?>(null),
        };
 
        await project.UpdatePackagesAsync(context, CancellationToken.None);
 
        var cliVersion = VersionHelper.GetDefaultSdkVersion();
        var expectedWarning = string.Format(
            System.Globalization.CultureInfo.CurrentCulture,
            ErrorStrings.CodegenVersionSkewWarning,
            cliVersion,
            staleVersion);
 
        Assert.Empty(interactionService.DisplayedErrors);
        Assert.Collection(interactionService.DisplayedMessages,
            m =>
            {
                Assert.Equal("package", m.Emoji.Name);
                Assert.Equal($"Aspire SDK {staleVersion} to {updateTargetVersion}", Markup.Remove(m.Message));
            },
            m =>
            {
                Assert.Equal("package", m.Emoji.Name);
                Assert.Equal($"Aspire.Hosting {staleVersion} to {updateTargetVersion}", Markup.Remove(m.Message));
            },
            m =>
            {
                Assert.Equal("warning", m.Emoji.Name);
                Assert.Contains(expectedWarning, m.Message);
            },
            m =>
            {
                Assert.Equal("warning", m.Emoji.Name);
                Assert.Equal(ErrorStrings.LegacyTypeScriptAppHostWarning, Markup.Remove(m.Message));
            },
            m =>
            {
                Assert.Equal("package", m.Emoji.Name);
                Assert.Equal(UpdateCommandStrings.RegeneratedSdkCode, m.Message);
            });
    }
 
    private string CreateMatchingSocketFile(string appHostPath, int pid)
    {
        var backchannelsDir = Path.Combine(_workspace.WorkspaceRoot.FullName, ".aspire", "cli", "bch");
        Directory.CreateDirectory(backchannelsDir);
 
        var resolvedAppHostPath = PathNormalizer.ResolveSymlinks(appHostPath);
        var prefix = BackchannelConstants.ComputeSocketPrefix(resolvedAppHostPath, _workspace.WorkspaceRoot.FullName);
        var appHostId = Path.GetFileName(prefix);
        var socketPath = Path.Combine(
            backchannelsDir,
            $"{appHostId}a1b2C3d4.{pid.ToString(System.Globalization.CultureInfo.InvariantCulture)}");
        File.WriteAllText(socketPath, "");
        return socketPath;
    }
 
    private GuestAppHostProject CreateGuestAppHostProject(
        IInteractionService? interactionService = null,
        string identityChannel = "local",
        TestAppHostBackchannel? backchannel = null,
        TestAppHostServerProjectFactory? appHostServerProjectFactory = null,
        IAppHostServerSessionFactory? serverSessionFactory = null,
        bool identityOverridden = false,
        string languageId = "typescript/nodejs",
        IEnvironment? environment = null,
        DirectoryInfo? homeDirectory = null,
        IConfiguration? configuration = null)
    {
        var effectiveConfiguration = configuration ?? _configuration;
 
        var language = new LanguageInfo(
            LanguageId: languageId,
            DisplayName: "TypeScript (Node.js)",
            PackageName: "Aspire.Hosting.CodeGeneration.TypeScript",
            DetectionPatterns: ["apphost.ts"],
            CodeGenerator: "TypeScript");
 
        var logFilePath = Path.Combine(_workspace.WorkspaceRoot.FullName, $"test-guest-{Guid.NewGuid()}.log");
 
        var executionContext = TestExecutionContextHelper.CreateExecutionContext(
            new DirectoryInfo(AppContext.BaseDirectory),
            identityChannel: identityChannel,
            logFilePath: logFilePath,
            identityOverridden: identityOverridden,
            homeDirectory: homeDirectory);
 
        // Construct a real graceful-shutdown window so the contract matches production:
        // GuestAppHostProject requires it even when a test exits the Run path early
        // (e.g. via FailedToBuildArtifacts) without exercising shutdown. The test fake stands in for
        // ConsoleCancellationManager so the fixture doesn't register process-global OS signal handlers;
        // none of the tests here drive the launcher or AppHostServerSession paths that would fire it.
        var shutdownWindow = new TestGracefulShutdownWindow();
 
        return new GuestAppHostProject(
            language: language,
            interactionService: interactionService ?? new TestInteractionService(),
            backchannel: backchannel ?? new TestAppHostBackchannel(),
            appHostServerProjectFactory: appHostServerProjectFactory ?? new TestAppHostServerProjectFactory(),
            certificateService: new TestCertificateService(),
            runner: new TestDotNetCliRunner(),
            packagingService: new TestPackagingService(),
            configuration: effectiveConfiguration,
            features: new Features(effectiveConfiguration, NullLogger<Features>.Instance),
            languageDiscovery: new TestLanguageDiscovery(),
            executionContext: executionContext,
            environment: environment ?? new TestEnvironment(),
            logger: NullLogger<GuestAppHostProject>.Instance,
            fileLoggerProvider: new FileLoggerProvider(logFilePath, new TestStartupErrorWriter()),
            profilingTelemetry: _profilingTelemetry,
            gracefulShutdownSignaler: new NoOpGracefulSignaler(),
            shutdownService: shutdownWindow,
            serverSessionFactory: serverSessionFactory ?? new FakeAppHostServerSessionFactory(),
            timeProvider: TimeProvider.System);
    }
 
    [Theory]
    [InlineData(null)]
    [InlineData("")]
    [InlineData(" ")]
    public async Task ConfigureCertificateBundleEnvironmentAsync_SetsEnvironmentVariable_WhenExistingValueIsNotUsable(string? existingValue)
    {
        var project = CreateGuestAppHostProject();
        var envVars = new Dictionary<string, string>();
        if (existingValue is not null)
        {
            envVars["NODE_EXTRA_CA_CERTS"] = existingValue;
        }
 
        await project.ConfigureCertificateBundleEnvironmentAsync(
            envVars,
            _workspace.WorkspaceRoot,
            "/path/to/cert.pem",
            "NODE_EXTRA_CA_CERTS",
            "typescript-nodejs",
            TestContext.Current.CancellationToken);
 
        Assert.Equal("/path/to/cert.pem", envVars["NODE_EXTRA_CA_CERTS"]);
    }
 
    [Fact]
    public async Task ConfigureCertificateBundleEnvironmentAsync_ReusesDevCertificate_WhenAlreadyConfigured()
    {
        var devCertificatePath = Path.Combine(_workspace.WorkspaceRoot.FullName, "aspire-dev-cert.pem");
        var project = CreateGuestAppHostProject();
        var envVars = new Dictionary<string, string>
        {
            ["NODE_EXTRA_CA_CERTS"] = Path.GetFileName(devCertificatePath)
        };
 
        await project.ConfigureCertificateBundleEnvironmentAsync(
            envVars,
            _workspace.WorkspaceRoot,
            devCertificatePath,
            "NODE_EXTRA_CA_CERTS",
            "typescript-nodejs",
            TestContext.Current.CancellationToken);
 
        Assert.Equal(devCertificatePath, envVars["NODE_EXTRA_CA_CERTS"]);
        Assert.False(Directory.Exists(Path.Combine(_workspace.WorkspaceRoot.FullName, "bundles")));
    }
 
    [Fact]
    public async Task ConfigureCertificateBundleEnvironmentAsync_UsesCaseInsensitivePathComparisonOnWindows()
    {
        var devCertificatePath = Path.Combine(_workspace.WorkspaceRoot.FullName, "aspire-dev-cert.pem");
        var project = CreateGuestAppHostProject(environment: TestEnvironment.CreateWindows());
        var envVars = new Dictionary<string, string>
        {
            ["NODE_EXTRA_CA_CERTS"] = devCertificatePath.ToUpperInvariant()
        };
 
        await project.ConfigureCertificateBundleEnvironmentAsync(
            envVars,
            _workspace.WorkspaceRoot,
            devCertificatePath,
            "NODE_EXTRA_CA_CERTS",
            "typescript-nodejs",
            TestContext.Current.CancellationToken);
 
        Assert.Equal(devCertificatePath, envVars["NODE_EXTRA_CA_CERTS"]);
    }
 
    [Fact]
    public async Task ConfigureCertificateBundleEnvironmentAsync_DoesNotAssumeMacOSPathsAreCaseInsensitive()
    {
        var lowerCaseDirectory = Path.Combine(_workspace.WorkspaceRoot.FullName, "certificates");
        var upperCaseDirectory = Path.Combine(_workspace.WorkspaceRoot.FullName, "CERTIFICATES");
        Directory.CreateDirectory(lowerCaseDirectory);
        Directory.CreateDirectory(upperCaseDirectory);
        var devCertificatePath = Path.Combine(lowerCaseDirectory, "aspire.pem");
        var existingBundlePath = Path.Combine(upperCaseDirectory, "ASPIRE.PEM");
        await File.WriteAllTextAsync(existingBundlePath, "existing certificate", TestContext.Current.CancellationToken);
        await File.WriteAllTextAsync(devCertificatePath, "development certificate", TestContext.Current.CancellationToken);
        var expectedDevCertificateContents = await File.ReadAllBytesAsync(devCertificatePath, TestContext.Current.CancellationToken);
        var expectedExistingBundleContents = await File.ReadAllBytesAsync(existingBundlePath, TestContext.Current.CancellationToken);
        byte[] expectedBundleContents = [.. expectedDevCertificateContents, (byte)'\n', .. expectedExistingBundleContents];
        var project = CreateGuestAppHostProject(environment: TestEnvironment.CreateMacOS());
        var envVars = new Dictionary<string, string>
        {
            ["NODE_EXTRA_CA_CERTS"] = existingBundlePath
        };
 
        await project.ConfigureCertificateBundleEnvironmentAsync(
            envVars,
            _workspace.WorkspaceRoot,
            devCertificatePath,
            "NODE_EXTRA_CA_CERTS",
            "typescript-nodejs",
            TestContext.Current.CancellationToken);
 
        Assert.NotEqual(devCertificatePath, envVars["NODE_EXTRA_CA_CERTS"]);
        Assert.Equal(
            expectedBundleContents,
            await File.ReadAllBytesAsync(envVars["NODE_EXTRA_CA_CERTS"], TestContext.Current.CancellationToken));
    }
 
    [Fact]
    public async Task ConfigureCertificateBundleEnvironmentAsync_DoesNotSet_WhenPemPathIsNull()
    {
        var project = CreateGuestAppHostProject();
        var envVars = new Dictionary<string, string>();
 
        await project.ConfigureCertificateBundleEnvironmentAsync(
            envVars,
            _workspace.WorkspaceRoot,
            devCertPemPath: null,
            environmentVariableName: "NODE_EXTRA_CA_CERTS",
            cacheFilePrefix: "typescript-nodejs",
            cancellationToken: TestContext.Current.CancellationToken);
 
        Assert.False(envVars.ContainsKey("NODE_EXTRA_CA_CERTS"));
    }
 
    [Fact]
    public async Task ConfigureCertificateBundleEnvironmentAsync_CreatesAndReusesCombinedBundle_WhenAlreadySet()
    {
        const string certificateBundleEnvironmentVariable = "NODE_EXTRA_CA_CERTS";
        const string configuredNodeExtraCaCertsKey = "Node_Extra_Ca_Certs";
        var homeDirectory = _workspace.CreateDirectory("bundle-home");
        var existingBundlePath = Path.Combine(_workspace.WorkspaceRoot.FullName, "existing-ca-certs.pem");
        var inheritedBundlePath = Path.Combine(_workspace.WorkspaceRoot.FullName, "inherited-ca-certs.pem");
        var devCertificateDirectory = Path.Combine(homeDirectory.FullName, ".aspire", "dev-certs");
        var devCertificatePath = Path.Combine(devCertificateDirectory, "aspire-dev-cert.pem");
        const string existingBundleContents = "-----BEGIN CERTIFICATE-----\nexisting\n-----END CERTIFICATE-----\n";
        const string inheritedBundleContents = "-----BEGIN CERTIFICATE-----\ninherited\n-----END CERTIFICATE-----\n";
        const string devCertificateContents = "-----BEGIN CERTIFICATE-----\ndev\n-----END CERTIFICATE-----";
        Directory.CreateDirectory(devCertificateDirectory);
        await File.WriteAllTextAsync(existingBundlePath, existingBundleContents, TestContext.Current.CancellationToken);
        await File.WriteAllTextAsync(inheritedBundlePath, inheritedBundleContents, TestContext.Current.CancellationToken);
        await File.WriteAllTextAsync(devCertificatePath, devCertificateContents, TestContext.Current.CancellationToken);
 
        var environment = TestEnvironment.CreateWindows(new Dictionary<string, string?>
        {
            [certificateBundleEnvironmentVariable] = inheritedBundlePath
        });
        var project = CreateGuestAppHostProject(environment: environment, homeDirectory: homeDirectory);
        var envVars = new Dictionary<string, string>
        {
            [certificateBundleEnvironmentVariable] = inheritedBundlePath,
            [configuredNodeExtraCaCertsKey] = Path.GetFileName(existingBundlePath)
        };
        var expectedBundleContents = Encoding.UTF8.GetBytes($"{devCertificateContents}\n{existingBundleContents}");
        var expectedHash = Convert.ToHexString(XxHash128.Hash(expectedBundleContents)).ToLowerInvariant();
        var expectedBundlePath = Path.Combine(
            homeDirectory.FullName,
            ".aspire",
            "dev-certs",
            "bundles",
            $"typescript-nodejs-{expectedHash}.pem");
 
        await project.ConfigureCertificateBundleEnvironmentAsync(
            envVars,
            _workspace.WorkspaceRoot,
            devCertificatePath,
            certificateBundleEnvironmentVariable,
            "typescript-nodejs",
            TestContext.Current.CancellationToken);
 
        Assert.Equal(expectedBundlePath, envVars[certificateBundleEnvironmentVariable]);
        Assert.DoesNotContain(configuredNodeExtraCaCertsKey, envVars.Keys);
        Assert.Equal(expectedBundleContents, await File.ReadAllBytesAsync(expectedBundlePath, TestContext.Current.CancellationToken));
 
        var cachedWriteTime = DateTime.UtcNow.AddDays(-1);
        File.SetLastWriteTimeUtc(expectedBundlePath, cachedWriteTime);
        cachedWriteTime = File.GetLastWriteTimeUtc(expectedBundlePath);
        envVars.Remove(certificateBundleEnvironmentVariable);
        envVars[configuredNodeExtraCaCertsKey] = Path.GetFileName(existingBundlePath);
        await project.ConfigureCertificateBundleEnvironmentAsync(
            envVars,
            _workspace.WorkspaceRoot,
            devCertificatePath,
            certificateBundleEnvironmentVariable,
            "typescript-nodejs",
            TestContext.Current.CancellationToken);
 
        Assert.Equal(cachedWriteTime, File.GetLastWriteTimeUtc(expectedBundlePath));
        Assert.DoesNotContain(configuredNodeExtraCaCertsKey, envVars.Keys);
 
        if (!OperatingSystem.IsWindows())
        {
            Assert.Equal(
                UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute,
                File.GetUnixFileMode(Path.GetDirectoryName(expectedBundlePath)!));
            Assert.Equal(
                UnixFileMode.UserRead | UnixFileMode.UserWrite,
                File.GetUnixFileMode(expectedBundlePath));
        }
    }
 
    [Fact]
    public async Task ConfigureCertificateBundleEnvironmentAsync_PreservesExistingBundle_WhenCombinationFails()
    {
        var interactionService = new TestInteractionService();
        var project = CreateGuestAppHostProject(interactionService: interactionService);
        var devCertificatePath = Path.Combine(_workspace.WorkspaceRoot.FullName, "aspire-dev-cert.pem");
        await File.WriteAllTextAsync(devCertificatePath, "dev certificate", TestContext.Current.CancellationToken);
        var envVars = new Dictionary<string, string>
        {
            ["NODE_EXTRA_CA_CERTS"] = "missing-ca-certs.pem"
        };
 
        await project.ConfigureCertificateBundleEnvironmentAsync(
            envVars,
            _workspace.WorkspaceRoot,
            devCertificatePath,
            "NODE_EXTRA_CA_CERTS",
            "typescript-nodejs",
            TestContext.Current.CancellationToken);
 
        Assert.Equal("missing-ca-certs.pem", envVars["NODE_EXTRA_CA_CERTS"]);
        Assert.Single(interactionService.DisplayedMessages);
        Assert.Contains("existing certificate bundle will be used unchanged", interactionService.DisplayedMessages[0].Message);
    }
 
    private static async Task InvokeStartBackchannelConnectionAsync(
        GuestAppHostProject project,
        IAppHostServerSession serverSession,
        TaskCompletionSource<IAppHostCliBackchannel> backchannelCompletionSource,
        CancellationToken cancellationToken = default)
    {
        var method = typeof(GuestAppHostProject).GetMethod(
            "StartBackchannelConnectionAsync",
            System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
        Assert.NotNull(method);
 
        var task = Assert.IsAssignableFrom<Task>(method.Invoke(project, [
            serverSession,
            "fake.sock",
            backchannelCompletionSource,
            false,
            default(System.Diagnostics.ActivityContext),
            cancellationToken
        ]));
        await task.DefaultTimeout();
    }
 
    private sealed class NoOpGracefulSignaler : IProcessTreeGracefulShutdownSignaler
    {
        public Task<bool> RequestProcessTreeGracefulShutdownAsync(int pid, DateTimeOffset? startTime, bool includeStartTimeForDcp, CancellationToken cancellationToken)
            => Task.FromResult(true);
    }
}