File: AppHostSdkTargetsTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Sdk.Tests\Aspire.Hosting.Sdk.Tests.csproj (Aspire.Hosting.Sdk.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.Diagnostics;
using System.Globalization;
using System.Reflection;
using System.Security;
using System.Text.Json;
using System.Text.RegularExpressions;
using Aspire.Hosting.Tasks;
using Xunit;
 
namespace Aspire.Hosting.Sdk.Tests;
 
public class AppHostSdkTargetsTests(ITestOutputHelper outputHelper)
{
    private const string AspireCliVersion = "13.5.0";
    private const string HangingCommandPidEnvironmentVariable = "ASPIRE_TEST_HANG_PID_PATH";
    private const string SuppressCliRunHookEnvironmentVariable = "ASPIRE_SUPPRESS_CLI_RUN_HOOK";
 
    private static readonly string[] s_supportedRids =
    [
        "win-x64",
        "win-arm64",
        "linux-x64",
        "linux-arm64",
        "linux-musl-x64",
        "osx-x64",
        "osx-arm64"
    ];
 
    [Fact]
    public async Task AddReferenceToDashboardAndDcpIsAddedWhenCliBundleIsDefaulted()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var packageReferences = await RunAddReferenceToDashboardAndDcpAsync(workspace, extraProjectXml: null);
 
        AssertDashboardAndOrchestrationReferences(packageReferences);
    }
 
    [Fact]
    public async Task AddReferenceToDashboardAndDcpIsSkippedWhenCliBundleIsEnabled()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var packageReferences = await RunAddReferenceToDashboardAndDcpAsync(workspace,
            """
              <PropertyGroup>
                <AspireUseCliBundle>true</AspireUseCliBundle>
              </PropertyGroup>
            """);
 
        Assert.DoesNotContain(packageReferences, static packageReference => packageReference.StartsWith("Aspire.Dashboard.Sdk.", StringComparison.Ordinal));
        Assert.DoesNotContain(packageReferences, static packageReference => packageReference.StartsWith("Aspire.Hosting.Orchestration.", StringComparison.Ordinal));
        Assert.Contains("Aspire.Hosting.AppHost=13.4.0", packageReferences);
    }
 
    [Fact]
    public async Task AddReferenceToDashboardAndDcpUsesSdkRidSelectionTask()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var packageReferences = await RunAddReferenceToDashboardAndDcpAsync(workspace,
            """
              <PropertyGroup>
                <AspireUseCliBundle>false</AspireUseCliBundle>
              </PropertyGroup>
            """);
 
        Assert.Contains("UseSdkPickBestRid=true", packageReferences);
        Assert.Contains("RunRidToolFallback=false", packageReferences);
        AssertDashboardAndOrchestrationReferences(packageReferences);
    }
 
    [Fact]
    public async Task AddReferenceToDashboardAndDcpFallsBackToRuntimeIdentifierToolForOlderSdks()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        // Force the pre-.NET 10 code path by disabling the in-proc PickBestRid task and pointing the
        // Exec call at the locally-built Aspire.RuntimeIdentifier.Tool assembly (which is normally
        // resolved out of the packed SDK's tools folder).
        var ridToolPath = GetAspireRuntimeIdentifierToolPath();
 
        var extraProjectXml = $"""
              <PropertyGroup>
                <AspireUseCliBundle>false</AspireUseCliBundle>
                <_AspireUseSdkPickBestRid>false</_AspireUseSdkPickBestRid>
                <AspireRidToolExecutable>{SecurityElement.Escape(ridToolPath)}</AspireRidToolExecutable>
              </PropertyGroup>
            """;
 
        var packageReferences = await RunAddReferenceToDashboardAndDcpAsync(workspace, extraProjectXml);
 
        Assert.Contains("UseSdkPickBestRid=false", packageReferences);
        Assert.Contains("RunRidToolFallback=true", packageReferences);
        AssertDashboardAndOrchestrationReferences(packageReferences);
    }
 
    [Fact]
    public async Task ComputeRunArgumentsUsesAspireCliWhenCliBundleIsEnabled()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var project = await CreateRunHookProjectAsync(workspace.Path, aspireUseCliBundle: true);
        var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "fake-cli"));
        var aspireCliPath = await CreateFakeAspireCliAsync(fakeCliDirectory.FullName);
        await CreateFakeDnxAsync(fakeCliDirectory.FullName);
 
        var properties = await GetComputeRunArgumentsPropertiesAsync(
            project,
            ["-p:RunArguments=--custom foo"],
            CreatePathEnvironment(fakeCliDirectory.FullName));
 
        Assert.Equal("Aspire", properties["_AspireResolvedCliInvocationMode"]);
        Assert.Equal("13.5.0", properties["_AspireResolvedCliVersion"]);
        Assert.Equal("true", properties["_AspireCliVersionSupportsRunHook"]);
        Assert.Equal(GetExpectedAspireRunCommand(aspireCliPath), properties["RunCommand"]);
        Assert.Equal(GetExpectedAspireRunArguments(aspireCliPath, project, "--custom foo"), properties["RunArguments"]);
        Assert.Equal(project.ProjectDirectory, properties["RunWorkingDirectory"]);
    }
 
    [Theory]
    [InlineData(null)]
    [InlineData("DnxPinned")]
    [InlineData("dNxPiNnEd")]
    public async Task ComputeRunArgumentsUsesPinnedDnxAspireCliWhenSelected(string? invocationMode)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "fake-cli"));
        var dnxPath = await CreateFakeDnxAsync(fakeCliDirectory.FullName);
        if (invocationMode is not null)
        {
            await CreateFakeAspireCliAsync(fakeCliDirectory.FullName);
        }
 
        var extraProjectXml = invocationMode is null
            ? null
            : $$"""
              <PropertyGroup>
                <AspireCliInvocationMode>{{invocationMode}}</AspireCliInvocationMode>
              </PropertyGroup>
            """;
        var project = await CreateRunHookProjectAsync(workspace.Path, aspireUseCliBundle: true, extraProjectXml);
 
        var properties = await GetComputeRunArgumentsPropertiesAsync(
            project,
            ["-p:RunArguments=--custom foo"],
            new Dictionary<string, string>
            {
                [GetPathEnvironmentVariableName()] = CreatePathWithoutAspire(fakeCliDirectory.FullName)
            });
 
        Assert.Equal("Dnx", properties["_AspireResolvedCliInvocationMode"]);
        Assert.Equal("13.5.0", properties["_AspireResolvedCliVersion"]);
        Assert.Equal("true", properties["_AspireCliVersionSupportsRunHook"]);
        Assert.Equal(GetExpectedDnxRunCommand(dnxPath), properties["RunCommand"]);
        Assert.Equal(GetExpectedDnxRunArguments(dnxPath, project, "--custom foo"), properties["RunArguments"]);
        Assert.Contains($"aspire.cli@{AspireCliVersion}", properties["_AspireCliVersionCommand"]);
        Assert.Equal(project.ProjectDirectory, properties["RunWorkingDirectory"]);
    }
 
    [Theory]
    [InlineData("Dnx")]
    [InlineData("dNx")]
    public async Task ComputeRunArgumentsUsesManifestAwareDnxAspireCliWhenSelected(string invocationMode)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "fake-cli"));
        var dnxPath = await CreateFakeDnxAsync(fakeCliDirectory.FullName);
        await CreateFakeAspireCliAsync(fakeCliDirectory.FullName);
        var project = await CreateRunHookProjectAsync(workspace.Path, aspireUseCliBundle: true,
            $$"""
              <PropertyGroup>
                <AspireCliInvocationMode>{{invocationMode}}</AspireCliInvocationMode>
              </PropertyGroup>
            """);
 
        var properties = await GetComputeRunArgumentsPropertiesAsync(
            project,
            ["-p:RunArguments=--custom foo"],
            CreatePathEnvironment(fakeCliDirectory.FullName));
 
        Assert.Equal("Dnx", properties["_AspireResolvedCliInvocationMode"]);
        Assert.Equal("13.5.0", properties["_AspireResolvedCliVersion"]);
        Assert.Equal("true", properties["_AspireCliVersionSupportsRunHook"]);
        Assert.Equal(GetExpectedDnxRunCommand(dnxPath), properties["RunCommand"]);
        Assert.Equal(GetExpectedDnxRunArguments(dnxPath, project, "--custom foo", pinned: false), properties["RunArguments"]);
        Assert.Contains("--yes aspire.cli -- --version", properties["_AspireCliVersionCommand"]);
        Assert.Equal(project.ProjectDirectory, properties["RunWorkingDirectory"]);
    }
 
    [Theory]
    [InlineData("Dnx")]
    [InlineData("DnxPinned")]
    public async Task ComputeRunArgumentsFailsWhenDnxModeIsConfiguredAndDnxIsMissing(string invocationMode)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var emptyPathDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "empty-path"));
        var project = await CreateRunHookProjectAsync(workspace.Path, aspireUseCliBundle: true,
            $$"""
              <PropertyGroup>
                <AspireCliInvocationMode>{{invocationMode}}</AspireCliInvocationMode>
              </PropertyGroup>
            """);
 
        var result = await RunDotNetWithArgumentsAsync(
            project.ProjectDirectory,
            ["msbuild", "-nologo", "-restore", "-t:ComputeRunArguments", project.ProjectFile],
            CreatePathEnvironment(emptyPathDirectory.FullName, includeCurrentPath: false));
 
        Assert.NotEqual(0, result.ExitCode);
        Assert.Contains("ASPIRE011", result.Output);
        Assert.Contains($"AspireCliInvocationMode={invocationMode}", result.Output);
        Assert.Contains("dnx command could not be found on PATH", result.Output);
        Assert.Contains("Install or use the .NET SDK 10.0 or later", result.Output);
    }
 
    [Fact]
    public async Task ComputeRunArgumentsSkipsNonExecutableDnxOnUnix()
    {
        Assert.SkipWhen(OperatingSystem.IsWindows(), "Unix execute permissions are not used on Windows.");
 
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var nonExecutableDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "non-executable"));
        var nonExecutableDnxPath = Path.Combine(nonExecutableDirectory.FullName, "dnx");
        await File.WriteAllTextAsync(nonExecutableDnxPath, "");
        if (!OperatingSystem.IsWindows())
        {
            File.SetUnixFileMode(nonExecutableDnxPath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
        }
 
        var executableDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "executable"));
        var executableDnxPath = await CreateFakeDnxAsync(executableDirectory.FullName);
        var project = await CreateRunHookProjectAsync(workspace.Path, aspireUseCliBundle: true);
        var path = $"{nonExecutableDirectory.FullName}{Path.PathSeparator}{CreatePathWithoutAspire(executableDirectory.FullName)}";
 
        var properties = await GetComputeRunArgumentsPropertiesAsync(
            project,
            environment: new Dictionary<string, string>
            {
                [GetPathEnvironmentVariableName()] = path
            });
 
        Assert.Equal(executableDnxPath, properties["_AspireResolvedDnxPath"]);
    }
 
    [Fact]
    public async Task ComputeRunArgumentsNormalizesRelativePathEntries()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var project = await CreateRunHookProjectAsync(workspace.Path, aspireUseCliBundle: true);
        const string RelativeCliDirectory = "relative-cli";
        var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(project.ProjectDirectory, RelativeCliDirectory));
        var aspireCliPath = await CreateFakeAspireCliAsync(fakeCliDirectory.FullName);
 
        var properties = await GetComputeRunArgumentsPropertiesAsync(
            project,
            environment: new Dictionary<string, string>
            {
                [GetPathEnvironmentVariableName()] = CreatePathWithoutDnx(RelativeCliDirectory)
            });
 
        Assert.Equal(aspireCliPath, properties["_AspireResolvedCliPath"]);
        Assert.True(Path.IsPathFullyQualified(properties["_AspireResolvedCliPath"]));
    }
 
    [Fact]
    public async Task ComputeRunArgumentsFailsWhenDnxVersionProbeFails()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var project = await CreateRunHookProjectAsync(workspace.Path, aspireUseCliBundle: true);
        var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "fake-cli"));
        await CreateFakeDnxThatFailsVersionAsync(fakeCliDirectory.FullName);
 
        var result = await RunDotNetWithArgumentsAsync(
            project.ProjectDirectory,
            ["msbuild", "-nologo", "-restore", "-t:ComputeRunArguments", project.ProjectFile],
            new Dictionary<string, string>
            {
                [GetPathEnvironmentVariableName()] = CreatePathWithoutAspire(fakeCliDirectory.FullName)
            });
 
        Assert.NotEqual(0, result.ExitCode);
        Assert.Contains($"DNX could not restore or run aspire.cli@{AspireCliVersion}", result.Output);
        Assert.Contains("package sources, authentication, or connectivity", result.Output);
    }
 
    [Fact]
    public async Task ComputeRunArgumentsUsesConfiguredAspireCliPathWhenCliBundleIsEnabled()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var project = await CreateRunHookProjectAsync(workspace.Path, aspireUseCliBundle: true);
        var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "fake-cli"));
        var aspireCliPath = await CreateFakeAspireCliAsync(fakeCliDirectory.FullName);
 
        var properties = await GetComputeRunArgumentsPropertiesAsync(project, [$"-p:AspireCliPath={aspireCliPath}"]);
 
        AssertUsesExplicitAspireCli(properties, project, aspireCliPath);
    }
 
    [Theory]
    [InlineData(".cmd")]
    [InlineData(".BAT")]
    public async Task ComputeRunArgumentsWrapsConfiguredAspireCommandShimOnWindows(string extension)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var project = await CreateRunHookProjectAsync(workspace.Path, aspireUseCliBundle: true);
        var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "fake cli !& (shim)^,;=+[]{}~@"));
        var aspireCliPath = await CreateFakeAspireCommandShimAsync(fakeCliDirectory.FullName, extension);
 
        var properties = await GetComputeRunArgumentsPropertiesAsync(
            project,
            ["-p:OS=Windows_NT", "-p:RunArguments=--custom foo"],
            new Dictionary<string, string> { ["AspireCliPath"] = aspireCliPath });
 
        Assert.Equal("cmd", properties["RunCommand"]);
        Assert.Equal(GetExpectedWindowsCommandShimRunArguments(project, aspireCliPath, "--custom foo"), properties["RunArguments"]);
    }
 
    [Theory]
    [InlineData("Dnx")]
    [InlineData("DnxPinned")]
    public async Task ComputeRunArgumentsUsesConfiguredAspireCliPathWhenDnxModeIsConfigured(string invocationMode)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var emptyPathDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "empty-path"));
        var project = await CreateRunHookProjectAsync(workspace.Path, aspireUseCliBundle: true,
            $$"""
              <PropertyGroup>
                <AspireCliInvocationMode>{{invocationMode}}</AspireCliInvocationMode>
              </PropertyGroup>
            """);
        var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "fake-cli"));
        var aspireCliPath = await CreateFakeAspireCliAsync(fakeCliDirectory.FullName);
 
        var properties = await GetComputeRunArgumentsPropertiesAsync(
            project,
            [$"-p:AspireCliPath={aspireCliPath}"],
            new Dictionary<string, string>
            {
                [GetPathEnvironmentVariableName()] = CreatePathWithoutDnx(emptyPathDirectory.FullName)
            });
 
        AssertUsesExplicitAspireCli(properties, project, aspireCliPath);
    }
 
    [Fact]
    public async Task ComputeRunArgumentsUsesFileBasedAppHostPathWhenCliBundleIsEnabled()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var appHostDirectory = Path.Combine(workspace.Path, "FileApp");
        Directory.CreateDirectory(appHostDirectory);
        var appHostFile = Path.Combine(appHostDirectory, "apphost.cs");
        await File.WriteAllTextAsync(appHostFile, """
            var builder = DistributedApplication.CreateBuilder(args);
            builder.Build().Run();
            """);
 
        var extraProjectXml = $$"""
              <PropertyGroup>
                <FileBasedProgram>true</FileBasedProgram>
              </PropertyGroup>
 
              <ItemGroup>
                <RuntimeHostConfigurationOption Include="EntryPointFileDirectoryPath">
                  <Value>{{SecurityElement.Escape(appHostDirectory)}}</Value>
                </RuntimeHostConfigurationOption>
                <RuntimeHostConfigurationOption Include="EntryPointFilePath">
                  <Value>{{SecurityElement.Escape(appHostFile)}}</Value>
                </RuntimeHostConfigurationOption>
              </ItemGroup>
            """;
        var project = await CreateRunHookProjectAsync(workspace.Path, aspireUseCliBundle: true, extraProjectXml: extraProjectXml);
        var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "fake-cli"));
        var aspireCliPath = await CreateFakeAspireCliAsync(fakeCliDirectory.FullName);
 
        var properties = await GetComputeRunArgumentsPropertiesAsync(
            project,
            ["-p:RunArguments=--custom foo"],
            CreatePathEnvironment(fakeCliDirectory.FullName));
 
        Assert.Equal(GetExpectedAspireRunCommand(aspireCliPath), properties["RunCommand"]);
        Assert.Equal(GetExpectedAspireRunArguments(aspireCliPath, appHostFile, "--custom foo"), properties["RunArguments"]);
        Assert.Equal(appHostDirectory, properties["RunWorkingDirectory"]);
    }
 
    [Fact]
    public async Task ComputeRunArgumentsDoesNotUseAspireCliWhenCliBundleIsDisabled()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var project = await CreateRunHookProjectAsync(workspace.Path, aspireUseCliBundle: false);
 
        var properties = await GetComputeRunArgumentsPropertiesAsync(project);
 
        AssertUsesDotNetRun(properties, project);
    }
 
    [Theory]
    [InlineData(SuppressCliRunHookEnvironmentVariable, "true")]
    [InlineData(SuppressCliRunHookEnvironmentVariable, "1")]
    [InlineData("_AspireSuppressCliRunHook", "true")]
    [InlineData("_AspireSuppressCliRunHook", "1")]
    public async Task ComputeRunArgumentsDoesNotUseAspireCliWhenHookIsSuppressed(string suppressionPropertyName, string suppressionValue)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var project = await CreateRunHookProjectAsync(workspace.Path, aspireUseCliBundle: true);
 
        Dictionary<string, string>? environment = suppressionPropertyName == SuppressCliRunHookEnvironmentVariable
            ? new Dictionary<string, string> { [SuppressCliRunHookEnvironmentVariable] = suppressionValue }
            : null;
        var extraArguments = suppressionPropertyName == "_AspireSuppressCliRunHook"
            ? new[] { $"-p:_AspireSuppressCliRunHook={suppressionValue}" }
            : [];
 
        var properties = await GetComputeRunArgumentsPropertiesAsync(project, extraArguments, environment);
 
        AssertUsesDotNetRun(properties, project);
    }
 
    [Fact]
    public async Task DotNetRunUsesAspireCliWhenCliBundleIsEnabled()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var project = await CreateRunHookProjectAsync(workspace.Path, aspireUseCliBundle: true);
        var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "selected cli"));
        var captureFile = Path.Combine(workspace.Path, "aspire-args.txt");
        await CreateFakeAspireCliAsync(fakeCliDirectory.FullName);
        var shadowCaptureFile = await CreateFailingWorkingDirectoryShadowAsync(project.ProjectDirectory, "aspire");
 
        var pathEnvironmentVariable = GetPathEnvironmentVariableName();
        var environment = new Dictionary<string, string>
        {
            ["ASPIRE_TEST_CAPTURE_PATH"] = captureFile,
            [pathEnvironmentVariable] = CreatePathWithoutAspire(fakeCliDirectory.FullName)
        };
 
        var result = await RunDotNetWithArgumentsAsync(
            project.ProjectDirectory,
            ["run", "--project", project.ProjectFile, "--", "--custom", "foo"],
            environment);
 
        Assert.True(result.ExitCode == 0, result.Output);
        Assert.Equal(
            [
                "run",
                "--project",
                project.ProjectFile,
                "--no-build",
                "--",
                "--custom",
                "foo"
            ],
            await File.ReadAllLinesAsync(captureFile));
        Assert.False(File.Exists(shadowCaptureFile), "The working-directory Aspire shim was invoked instead of the resolved PATH command.");
    }
 
    [Fact]
    public async Task DotNetRunUsesDnxAspireCliWhenAspireCliIsUnavailable()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var project = await CreateRunHookProjectAsync(workspace.Path, aspireUseCliBundle: true);
        var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "selected cli"));
        var captureFile = Path.Combine(workspace.Path, "dnx-args.txt");
        await CreateFakeDnxAsync(fakeCliDirectory.FullName);
        var shadowCaptureFile = await CreateFailingWorkingDirectoryShadowAsync(project.ProjectDirectory, "dnx");
 
        var pathEnvironmentVariable = GetPathEnvironmentVariableName();
        var environment = new Dictionary<string, string>
        {
            ["ASPIRE_TEST_CAPTURE_PATH"] = captureFile,
            [pathEnvironmentVariable] = CreatePathWithoutAspire(fakeCliDirectory.FullName)
        };
 
        var result = await RunDotNetWithArgumentsAsync(
            project.ProjectDirectory,
            ["run", "--project", project.ProjectFile, "--", "--custom", "https://host/?a=1&b=2", "%ASPIRE_TEST_LITERAL%"],
            environment);
 
        Assert.True(result.ExitCode == 0, result.Output);
        Assert.Equal(
            [
                "--yes",
                $"aspire.cli@{AspireCliVersion}",
                "--",
                "run",
                "--project",
                project.ProjectFile,
                "--no-build",
                "--",
                "--custom",
                "https://host/?a=1&b=2",
                "%ASPIRE_TEST_LITERAL%"
            ],
            await File.ReadAllLinesAsync(captureFile));
        Assert.False(File.Exists(shadowCaptureFile), "The working-directory DNX shim was invoked instead of the resolved PATH command.");
    }
 
    [Fact]
    public async Task DotNetRunUsesNativeAspireCliWithoutCmdArgumentReparsingOnWindows()
    {
        Assert.SkipUnless(OperatingSystem.IsWindows(), "This test validates native Windows executable argument forwarding.");
 
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var project = await CreateRunHookProjectAsync(workspace.Path, aspireUseCliBundle: true);
        var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "native aspire"));
        var captureFile = Path.Combine(workspace.Path, "aspire-native-args.txt");
        CreateFakeNativeAspireCli(fakeCliDirectory.FullName);
        var environment = new Dictionary<string, string>
        {
            ["ASPIRE_TEST_CAPTURE_PATH"] = captureFile,
            ["ASPIRE_TEST_LITERAL"] = "expanded",
            [GetPathEnvironmentVariableName()] = CreatePathWithoutDnx(fakeCliDirectory.FullName)
        };
 
        var result = await RunDotNetWithArgumentsAsync(
            project.ProjectDirectory,
            ["run", "--project", project.ProjectFile, "--", "--custom", "https://host/?a=1&b=2", "%ASPIRE_TEST_LITERAL%"],
            environment);
 
        Assert.True(result.ExitCode == 0, result.Output);
        Assert.Equal(
            [
                "run",
                "--project",
                project.ProjectFile,
                "--no-build",
                "--",
                "--custom",
                "https://host/?a=1&b=2",
                "%ASPIRE_TEST_LITERAL%"
            ],
            await File.ReadAllLinesAsync(captureFile));
    }
 
    [Fact]
    public async Task DotNetRunUsesConfiguredAspireCommandShimWhenCliBundleIsEnabled()
    {
        if (!OperatingSystem.IsWindows())
        {
            Assert.Skip("This test validates native cmd.exe command-shim execution.");
        }
 
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var project = await CreateRunHookProjectAsync(workspace.Path, aspireUseCliBundle: true);
        var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "fake cli !& (shim)^,;=+[]{}~@"));
        var captureFile = Path.Combine(workspace.Path, "aspire-args.txt");
        var aspireCliPath = await CreateFakeAspireCommandShimAsync(fakeCliDirectory.FullName);
 
        var environment = new Dictionary<string, string>
        {
            ["ASPIRE_TEST_CAPTURE_PATH"] = captureFile,
            ["AspireCliPath"] = aspireCliPath
        };
 
        var result = await RunDotNetWithArgumentsAsync(
            project.ProjectDirectory,
            ["run", "--project", project.ProjectFile, "--", "--custom", "value with spaces"],
            environment);
 
        Assert.True(result.ExitCode == 0, result.Output);
        Assert.Equal(
            [
                "run",
                "--project",
                project.ProjectFile,
                "--no-build",
                "--",
                "--custom",
                "value with spaces"
            ],
            await File.ReadAllLinesAsync(captureFile));
    }
 
    [Theory]
    [InlineData("13.4.0")]
    [InlineData("13.4.1")]
    [InlineData("13.4.5")]
    public async Task ComputeRunArgumentsDoesNotUseAspireCliWhenCliVersionIsBelowMinimum(string cliVersion)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var project = await CreateRunHookProjectAsync(workspace.Path, aspireUseCliBundle: true);
        var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "fake-cli"));
        var cliPath = Path.Combine(fakeCliDirectory.FullName, OperatingSystem.IsWindows() ? "aspire.cmd" : "aspire");
        await CreateFakeAspireCliWithVersionAsync(fakeCliDirectory.FullName, cliVersion);
 
        var properties = await GetComputeRunArgumentsPropertiesAsync(
            project,
            [$"-p:AspireCliPath={cliPath}", "-p:RunArguments=--custom foo"]);
 
        AssertUsesDotNetRun(properties, project, "--custom foo");
    }
 
    [Fact]
    public async Task ComputeRunArgumentsFallsBackToDnxWhenPathAspireCliVersionIsBelowMinimum()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var project = await CreateRunHookProjectAsync(workspace.Path, aspireUseCliBundle: true, includeBundlePaths: false);
        var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "fake-cli"));
        await CreateFakeAspireCliWithVersionAsync(fakeCliDirectory.FullName, "13.4.5");
        var dnxPath = await CreateFakeDnxAsync(fakeCliDirectory.FullName);
        var environment = new Dictionary<string, string>
        {
            [GetPathEnvironmentVariableName()] = CreatePathWithoutAspire(fakeCliDirectory.FullName)
        };
        var aspireHome = Path.Combine(workspace.Path, "aspire-home");
        environment["ASPIRE_HOME"] = aspireHome;
 
        var buildResult = await RunDotNetWithArgumentsAsync(
            project.ProjectDirectory,
            ["build", "-nologo", project.ProjectFile],
            environment);
        Assert.True(buildResult.ExitCode == 0, buildResult.Output);
 
        var properties = await GetComputeRunArgumentsPropertiesAsync(
            project,
            ["-p:RunArguments=--custom foo"],
            environment);
 
        Assert.Equal("Dnx", properties["_AspireResolvedCliInvocationMode"]);
        Assert.Equal("13.5.0", properties["_AspireResolvedCliVersion"]);
        Assert.Equal("true", properties["_AspireCliVersionSupportsRunHook"]);
        Assert.Equal(GetExpectedDnxRunCommand(dnxPath), properties["RunCommand"]);
        Assert.Equal(GetExpectedDnxRunArguments(dnxPath, project, "--custom foo"), properties["RunArguments"]);
        AssertCliBundleExists(aspireHome, buildResult.Output);
    }
 
    [Fact]
    public async Task ResolveAspireCliBundlePathsUsesDnxBundleWhenDnxModeIsSelected()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var staleCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "stale-cli"));
        await CreateFakeAspireCliWithVersionAsync(staleCliDirectory.FullName, AspireCliVersion);
        var staleBundle = CreateFakeCliBundle(staleCliDirectory.FullName);
        var dnxDirectoryName = OperatingSystem.IsWindows()
            ? "dnx"
            : "dnx $HOME `aspire-test-command`";
        var dnxDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, dnxDirectoryName));
        await CreateFakeDnxAsync(dnxDirectory.FullName);
        var aspireHomeDirectoryName = OperatingSystem.IsWindows()
            ? "aspire-home"
            : "aspire-home $HOME `aspire-test-command`";
        var aspireHome = Path.Combine(workspace.Path, aspireHomeDirectoryName);
        var project = await CreateRunHookProjectAsync(workspace.Path, aspireUseCliBundle: true,
            """
              <PropertyGroup>
                <AspireCliInvocationMode>Dnx</AspireCliInvocationMode>
              </PropertyGroup>
            """,
            includeBundlePaths: false);
        var environment = new Dictionary<string, string>
        {
            ["ASPIRE_HOME"] = aspireHome,
            [GetPathEnvironmentVariableName()] = CreatePathWithoutAspire(staleCliDirectory.FullName, dnxDirectory.FullName)
        };
 
        // Explicit Dnx mode can use an unversioned package reference so a tool manifest can select
        // the CLI version. Force that shape here because the fake setup must support both forms.
        var properties = await GetResolveAspireCliBundlePathPropertiesAsync(
            project,
            environment,
            ["-p:_AspireCliDnxPackageReference=aspire.cli"]);
        var dnxBundle = GetFakeCliBundlePaths(aspireHome);
 
        Assert.Equal("Dnx", properties["_AspireResolvedCliInvocationMode"]);
        Assert.Equal(dnxBundle.DcpDirectory, Path.TrimEndingDirectorySeparator(properties["DcpDir"]));
        Assert.Equal(dnxBundle.ManagedDirectory, Path.TrimEndingDirectorySeparator(properties["AspireDashboardDir"]));
        Assert.Equal(dnxBundle.ManagedPath, properties["AspireDashboardPath"]);
        Assert.NotEqual(staleBundle.DcpDirectory, Path.TrimEndingDirectorySeparator(properties["DcpDir"]));
        AssertCliBundleExists(aspireHome, JsonSerializer.Serialize(properties));
    }
 
    [Fact]
    public async Task ResolveAspireCliBundlePathsPreservesUnixShellMetacharactersInAspireCliPath()
    {
        Assert.SkipWhen(OperatingSystem.IsWindows(), "This test validates Unix process argument handling.");
 
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var fakeCliDirectory = Directory.CreateDirectory(
            Path.Combine(workspace.Path, "cli $HOME `aspire-test-command`"));
        var aspireCliPath = await CreateFakeAspireCliThatSetsUpBundleAsync(fakeCliDirectory.FullName);
        var aspireHome = Path.Combine(workspace.Path, "empty-aspire-home");
        var project = await CreateRunHookProjectAsync(workspace.Path, aspireUseCliBundle: true, includeBundlePaths: false);
        var environment = new Dictionary<string, string>
        {
            ["ASPIRE_HOME"] = aspireHome,
            ["AspireCliPath"] = aspireCliPath
        };
 
        var properties = await GetResolveAspireCliBundlePathPropertiesAsync(project, environment, ["-warnaserror"]);
        var selectedBundle = GetFakeCliBundlePaths(fakeCliDirectory.FullName);
 
        Assert.Equal(aspireCliPath, properties["_AspireResolvedCliPath"]);
        Assert.Equal(selectedBundle.DcpDirectory, Path.TrimEndingDirectorySeparator(properties["DcpDir"]));
        Assert.Equal(selectedBundle.ManagedDirectory, Path.TrimEndingDirectorySeparator(properties["AspireDashboardDir"]));
        Assert.Equal(selectedBundle.ManagedPath, properties["AspireDashboardPath"]);
        AssertCliBundleExists(fakeCliDirectory.FullName, JsonSerializer.Serialize(properties));
    }
 
    [Fact]
    public async Task ResolveAspireCliBundlePathsUsesSelectedAspireCliWhenLaterPathCandidateHasBundleWithWarningsAsErrors()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var selectedCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "selected-cli"));
        var selectedCliPath = await CreateFakeAspireCliThatSetsUpBundleAsync(selectedCliDirectory.FullName);
        var staleCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "stale-cli"));
        await CreateFakeAspireCliWithVersionAsync(staleCliDirectory.FullName, AspireCliVersion);
        var staleBundle = CreateFakeCliBundle(staleCliDirectory.FullName);
        var aspireHome = Path.Combine(workspace.Path, "aspire-home");
        var project = await CreateRunHookProjectAsync(workspace.Path, aspireUseCliBundle: true, includeBundlePaths: false);
        var environment = new Dictionary<string, string>
        {
            ["ASPIRE_HOME"] = aspireHome,
            [GetPathEnvironmentVariableName()] = CreatePathWithoutAspire(selectedCliDirectory.FullName, staleCliDirectory.FullName)
        };
 
        var properties = await GetResolveAspireCliBundlePathPropertiesAsync(project, environment, ["-warnaserror"]);
        var selectedBundle = GetFakeCliBundlePaths(selectedCliDirectory.FullName);
 
        Assert.Equal("Aspire", properties["_AspireResolvedCliInvocationMode"]);
        Assert.Equal(selectedCliPath, properties["_AspireResolvedCliPath"]);
        Assert.Equal(selectedBundle.DcpDirectory, Path.TrimEndingDirectorySeparator(properties["DcpDir"]));
        Assert.Equal(selectedBundle.ManagedDirectory, Path.TrimEndingDirectorySeparator(properties["AspireDashboardDir"]));
        Assert.Equal(selectedBundle.ManagedPath, properties["AspireDashboardPath"]);
        Assert.NotEqual(staleBundle.DcpDirectory, Path.TrimEndingDirectorySeparator(properties["DcpDir"]));
        AssertCliBundleExists(selectedCliDirectory.FullName, JsonSerializer.Serialize(properties));
    }
 
    [Fact]
    public async Task ResolveAspireCliBundlePathsEscapesPercentSignsInNativeAspireCliPathOnWindows()
    {
        Assert.SkipUnless(OperatingSystem.IsWindows(), "This test validates native Windows executable setup.");
 
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "native%ASPIRE_TEST_LITERAL%"));
        var aspireCliPath = CreateFakeNativeAspireCli(fakeCliDirectory.FullName);
        var aspireHome = Path.Combine(workspace.Path, "aspire-home");
        var project = await CreateRunHookProjectAsync(workspace.Path, aspireUseCliBundle: true, includeBundlePaths: false);
        var environment = new Dictionary<string, string>
        {
            ["ASPIRE_HOME"] = aspireHome,
            ["ASPIRE_TEST_LITERAL"] = "expanded",
            ["AspireCliPath"] = aspireCliPath
        };
 
        var properties = await GetResolveAspireCliBundlePathPropertiesAsync(project, environment, ["-warnaserror"]);
        var selectedBundle = GetFakeCliBundlePaths(fakeCliDirectory.FullName);
 
        Assert.Equal(selectedBundle.DcpDirectory, Path.TrimEndingDirectorySeparator(properties["DcpDir"]));
        Assert.Equal(selectedBundle.ManagedDirectory, Path.TrimEndingDirectorySeparator(properties["AspireDashboardDir"]));
        Assert.Equal(selectedBundle.ManagedPath, properties["AspireDashboardPath"]);
        AssertCliBundleExists(fakeCliDirectory.FullName, JsonSerializer.Serialize(properties));
        Assert.False(Directory.Exists(Path.Combine(workspace.Path, "nativeexpanded")));
    }
 
    [Fact]
    public async Task BuildFallsBackToDnxWhenPathAspireCliSetupTimesOut()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var project = await CreateRunHookProjectAsync(workspace.Path, aspireUseCliBundle: true, includeBundlePaths: false);
        var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "fake-cli"));
        await CreateFakeAspireCliThatHangsOnSetupAsync(fakeCliDirectory.FullName);
        await CreateFakeDnxAsync(fakeCliDirectory.FullName);
        var aspireHome = Path.Combine(workspace.Path, "aspire-home");
        var environment = new Dictionary<string, string>
        {
            ["ASPIRE_HOME"] = aspireHome,
            [GetPathEnvironmentVariableName()] = CreatePathWithoutAspire(fakeCliDirectory.FullName)
        };
 
        var buildResult = await RunDotNetWithArgumentsAsync(
            project.ProjectDirectory,
            ["build", "-nologo", project.ProjectFile, "-p:_AspireCliBundleSetupTimeout=1000", "-warnaserror"],
            environment);
 
        Assert.True(buildResult.ExitCode == 0, buildResult.Output);
        AssertCliBundleExists(aspireHome, buildResult.Output);
    }
 
    [Fact]
    public async Task RunAspireCliCommandKillsCommandShimProcessTreeOnTimeoutInFullFrameworkMsBuild()
    {
        Assert.SkipUnless(OperatingSystem.IsWindows(), "This test validates the net472 task under full-framework MSBuild.");
 
        var msbuildPath = await FindFullFrameworkMSBuildAsync();
        Assert.SkipUnless(msbuildPath is not null, "Full-framework MSBuild could not be found with vswhere.");
 
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var fakeCommandDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "fake-command"));
        CopyFakeCommandHost(fakeCommandDirectory.FullName);
        var commandShimPath = Path.Combine(fakeCommandDirectory.FullName, "aspire.cmd");
        await File.WriteAllTextAsync(commandShimPath, """
            @echo off
            "%~dp0dotnet.exe" --hang
            """);
 
        var childPidPath = Path.Combine(workspace.Path, "child.pid");
        var taskAssemblyPath = GetAssemblyMetadataPath("AspireHostingTasksNetFrameworkAssemblyPath");
        var projectPath = Path.Combine(workspace.Path, "RunAspireCliCommand.proj");
        await File.WriteAllTextAsync(projectPath, $$"""
            <Project>
              <UsingTask TaskName="RunAspireCliCommand" AssemblyFile="{{SecurityElement.Escape(taskAssemblyPath)}}" />
 
              <Target Name="Test">
                <RunAspireCliCommand FileName="{{SecurityElement.Escape(commandShimPath)}}" TimeoutMilliseconds="5000">
                  <Output TaskParameter="TimedOut" PropertyName="CommandTimedOut" />
                  <Output TaskParameter="FailureMessage" PropertyName="CommandFailureMessage" />
                </RunAspireCliCommand>
                <Error Condition="'$(CommandTimedOut)' != 'true'" Text="The command did not report a timeout." />
                <Error Condition="'$(CommandFailureMessage)' == ''" Text="The command did not report a timeout failure." />
                <Message Text="CommandTimedOut=$(CommandTimedOut)" Importance="High" />
                <Message Text="CommandFailureMessage=$(CommandFailureMessage)" Importance="High" />
              </Target>
            </Project>
            """);
 
        int? childProcessId = null;
        try
        {
            var result = await RunProcessWithArgumentsAsync(
                msbuildPath!,
                workspace.Path,
                [projectPath, "-nologo", "-t:Test"],
                new Dictionary<string, string>
                {
                    [HangingCommandPidEnvironmentVariable] = childPidPath
                },
                TimeSpan.FromSeconds(30));
 
            Assert.True(result.ExitCode == 0, result.Output);
            Assert.Contains("CommandTimedOut=true", result.Output, StringComparison.OrdinalIgnoreCase);
            Assert.Contains("CommandFailureMessage=The command timed out", result.Output, StringComparison.OrdinalIgnoreCase);
            Assert.True(File.Exists(childPidPath), $"The child process did not record its PID. MSBuild output:{Environment.NewLine}{result.Output}");
 
            childProcessId = int.Parse(await File.ReadAllTextAsync(childPidPath), CultureInfo.InvariantCulture);
            using var survivingChild = TryGetProcessById(childProcessId.Value);
            Assert.Null(survivingChild);
            childProcessId = null;
        }
        finally
        {
            if (childProcessId is { } processId)
            {
                using var childProcess = TryGetProcessById(processId);
                if (childProcess is not null && !childProcess.HasExited)
                {
                    childProcess.Kill(entireProcessTree: true);
                }
            }
        }
    }
 
    [Fact]
    public async Task RunAspireCliCommandReportsWhenProcessDoesNotExitAfterTermination()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var commandPath = Path.Combine(workspace.Path, OperatingSystem.IsWindows() ? "hang.cmd" : "hang");
        await File.WriteAllTextAsync(
            commandPath,
            OperatingSystem.IsWindows()
                ? "@echo off\r\nping -n 3601 127.0.0.1 > nul\r\n"
                : "#!/bin/sh\nsleep 3600\n");
        if (!OperatingSystem.IsWindows())
        {
            File.SetUnixFileMode(commandPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
        }
 
        var terminationRequested = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
        var task = new RunAspireCliCommand
        {
            FileName = commandPath,
            TimeoutMilliseconds = 1,
            TestTerminateProcess = process =>
            {
                terminationRequested.TrySetResult(process.Id);
                return true;
            }
        };
 
        int? processId = null;
        Task<bool>? executionTask = null;
        try
        {
            executionTask = Task.Run(task.Execute);
            processId = await terminationRequested.Task.WaitAsync(
                TimeSpan.FromSeconds(5),
                TestContext.Current.CancellationToken);
            var result = await executionTask.WaitAsync(
                TimeSpan.FromSeconds(10),
                TestContext.Current.CancellationToken);
 
            using var survivingProcess = TryGetProcessById(processId.Value);
            Assert.NotNull(survivingProcess);
            Assert.False(survivingProcess.HasExited);
            Assert.True(result);
            Assert.True(task.TimedOut);
            Assert.NotNull(task.FailureMessage);
            Assert.Contains($"The command '{commandPath}' timed out", task.FailureMessage, StringComparison.Ordinal);
            Assert.Contains("did not exit within", task.FailureMessage, StringComparison.Ordinal);
            Assert.Contains("after termination was requested", task.FailureMessage, StringComparison.Ordinal);
        }
        finally
        {
            if (processId is { } startedProcessId)
            {
                using var process = TryGetProcessById(startedProcessId);
                if (process is not null && !process.HasExited)
                {
                    process.Kill(entireProcessTree: true);
                    await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(5));
                }
            }
 
            if (executionTask is not null)
            {
                _ = await executionTask.WaitAsync(TimeSpan.FromSeconds(5));
            }
        }
    }
 
    [Fact]
    public async Task BuildUsesEnvironmentAspireHomeWhenMsBuildPropertyDiffers()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var project = await CreateRunHookProjectAsync(workspace.Path, aspireUseCliBundle: true, includeBundlePaths: false);
        var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "fake-cli"));
        await CreateFakeDnxAsync(fakeCliDirectory.FullName);
        var environmentAspireHome = Path.Combine(workspace.Path, "environment-aspire-home");
        var propertyAspireHome = Path.Combine(workspace.Path, "property-aspire-home");
        var environment = new Dictionary<string, string>
        {
            ["ASPIRE_HOME"] = environmentAspireHome,
            [GetPathEnvironmentVariableName()] = CreatePathWithoutAspire(fakeCliDirectory.FullName)
        };
 
        var buildResult = await RunDotNetWithArgumentsAsync(
            project.ProjectDirectory,
            ["build", "-nologo", project.ProjectFile, $"-p:ASPIRE_HOME={propertyAspireHome}"],
            environment);
 
        Assert.True(buildResult.ExitCode == 0, buildResult.Output);
        AssertCliBundleExists(environmentAspireHome, buildResult.Output);
        Assert.False(Directory.Exists(Path.Combine(propertyAspireHome, "bundle")));
    }
 
    [Fact]
    public async Task ComputeRunArgumentsFallsBackToDnxWhenPathAspireCliVersionCannotBeDetermined()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var project = await CreateRunHookProjectAsync(workspace.Path, aspireUseCliBundle: true);
        var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "fake-cli"));
        await CreateFakeAspireCliThatFailsVersionAsync(fakeCliDirectory.FullName);
        var dnxPath = await CreateFakeDnxAsync(fakeCliDirectory.FullName);
 
        var properties = await GetComputeRunArgumentsPropertiesAsync(
            project,
            ["-p:RunArguments=--custom foo"],
            CreatePathEnvironment(fakeCliDirectory.FullName));
 
        Assert.Equal("Dnx", properties["_AspireResolvedCliInvocationMode"]);
        Assert.Equal("13.5.0", properties["_AspireResolvedCliVersion"]);
        Assert.Equal("true", properties["_AspireCliVersionSupportsRunHook"]);
        Assert.Equal(GetExpectedDnxRunCommand(dnxPath), properties["RunCommand"]);
        Assert.Equal(GetExpectedDnxRunArguments(dnxPath, project, "--custom foo"), properties["RunArguments"]);
    }
 
    [Theory]
    [InlineData("13.5.0-preview.1.26319.9")]
    [InlineData("13.5.0+gabcdef")]
    [InlineData("13.5.0")]
    [InlineData("13.6.0")]
    [InlineData("14.0.0")]
    public async Task ComputeRunArgumentsUsesAspireCliWhenCliVersionIsAtOrAboveMinimum(string cliVersion)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var project = await CreateRunHookProjectAsync(workspace.Path, aspireUseCliBundle: true);
        var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "fake-cli"));
        var cliPath = Path.Combine(fakeCliDirectory.FullName, OperatingSystem.IsWindows() ? "aspire.cmd" : "aspire");
        await CreateFakeAspireCliWithVersionAsync(fakeCliDirectory.FullName, cliVersion);
 
        var properties = await GetComputeRunArgumentsPropertiesAsync(
            project,
            [$"-p:AspireCliPath={cliPath}", "-p:RunArguments=--custom foo"]);
 
        AssertUsesExplicitAspireCli(properties, project, cliPath, "--custom foo");
    }
 
    [Fact]
    public async Task ComputeRunArgumentsUsesAspireCliWhenCliVersionWritesLargeStdErr()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var project = await CreateRunHookProjectAsync(workspace.Path, aspireUseCliBundle: true);
        var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "fake-cli"));
        var cliPath = Path.Combine(fakeCliDirectory.FullName, OperatingSystem.IsWindows() ? "aspire.cmd" : "aspire");
        await CreateFakeAspireCliWithVersionAndLargeStdErrAsync(fakeCliDirectory.FullName, "13.5.0");
 
        var properties = await GetComputeRunArgumentsPropertiesAsync(
            project,
            [$"-p:AspireCliPath={cliPath}", "-p:RunArguments=--custom foo"]);
 
        AssertUsesExplicitAspireCli(properties, project, cliPath, "--custom foo");
    }
 
    [Fact]
    public async Task ComputeRunArgumentsDoesNotUseAspireCliWhenCliVersionCommandTimesOut()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var project = await CreateRunHookProjectAsync(workspace.Path, aspireUseCliBundle: true);
        var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "fake-cli"));
        var cliPath = Path.Combine(fakeCliDirectory.FullName, OperatingSystem.IsWindows() ? "aspire.cmd" : "aspire");
        await CreateFakeAspireCliThatHangsOnVersionAsync(fakeCliDirectory.FullName);
 
        var properties = await GetComputeRunArgumentsPropertiesAsync(
            project,
            [$"-p:AspireCliPath={cliPath}", "-p:RunArguments=--custom foo"]);
 
        AssertUsesDotNetRun(properties, project, "--custom foo");
    }
 
    [Fact]
    public async Task ComputeRunArgumentsDoesNotUseAspireCliWhenCliVersionOutputIsUnparseable()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var project = await CreateRunHookProjectAsync(workspace.Path, aspireUseCliBundle: true);
        var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "fake-cli"));
        var cliPath = Path.Combine(fakeCliDirectory.FullName, OperatingSystem.IsWindows() ? "aspire.cmd" : "aspire");
        await CreateFakeAspireCliWithVersionAsync(fakeCliDirectory.FullName, "aspire version 13.5.0");
 
        var properties = await GetComputeRunArgumentsPropertiesAsync(
            project,
            [$"-p:AspireCliPath={cliPath}", "-p:RunArguments=--custom foo"]);
 
        AssertUsesDotNetRun(properties, project, "--custom foo");
    }
 
    [Fact]
    public async Task ComputeRunArgumentsDoesNotUseAspireCliWhenCliVersionCannotBeDetermined()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var project = await CreateRunHookProjectAsync(workspace.Path, aspireUseCliBundle: true);
        var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "fake-cli"));
        var cliPath = Path.Combine(fakeCliDirectory.FullName, OperatingSystem.IsWindows() ? "aspire.cmd" : "aspire");
        await CreateFakeAspireCliThatFailsVersionAsync(fakeCliDirectory.FullName);
 
        var properties = await GetComputeRunArgumentsPropertiesAsync(
            project,
            [$"-p:AspireCliPath={cliPath}", "-p:RunArguments=--custom foo"]);
 
        AssertUsesDotNetRun(properties, project, "--custom foo");
    }
 
    [Fact]
    public async Task ComputeRunArgumentsDoesNotUseAspireCliWhenCliIsUnavailable()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var project = await CreateRunHookProjectAsync(workspace.Path, aspireUseCliBundle: true);
        var emptyPathDirectory = Directory.CreateDirectory(Path.Combine(workspace.Path, "empty-path"));
 
        var properties = await GetComputeRunArgumentsPropertiesAsync(
            project,
            ["-p:RunArguments=--custom foo"],
            CreatePathEnvironment(emptyPathDirectory.FullName, includeCurrentPath: false));
 
        AssertUsesDotNetRun(properties, project, "--custom foo");
    }
 
    private static async Task<string[]> RunAddReferenceToDashboardAndDcpAsync(TemporaryWorkspace workspace, string? extraProjectXml)
    {
        var repoRoot = GetRepoRoot();
 
        var projectDirectory = Path.Combine(workspace.Path, "AppHost");
        Directory.CreateDirectory(projectDirectory);
 
        var sdkTargetsPath = SecurityElement.Escape(Path.Combine(repoRoot, "src", "Aspire.AppHost.Sdk", "SDK", "Sdk.in.targets"));
        var packageReferencesPath = Path.Combine(projectDirectory, "obj", "package-references.txt");
 
        await File.WriteAllTextAsync(Path.Combine(projectDirectory, "AppHost.csproj"),
            $$"""
            <Project Sdk="Microsoft.NET.Sdk">
 
              <PropertyGroup>
                <TargetFramework>net8.0</TargetFramework>
                <SkipAspireWorkloadManifest>true</SkipAspireWorkloadManifest>
              </PropertyGroup>
 
              <ItemGroup>
                <PackageReference Include="Aspire.Hosting.AppHost" Version="13.4.0" />
              </ItemGroup>
 
              <Import Project="{{sdkTargetsPath}}" />
 
            {{extraProjectXml}}
 
              <Target Name="WritePackageReferences" DependsOnTargets="AddReferenceToDashboardAndDCP">
                <WriteLinesToFile File="$(BaseIntermediateOutputPath)package-references.txt"
                                  Lines="UseSdkPickBestRid=$(_AspireUseSdkPickBestRid);RunRidToolFallback=$(_AspireRunRidToolFallback);@(PackageReference->'%(Identity)=%(Version)')"
                                  Overwrite="true" />
              </Target>
 
            </Project>
            """);
 
        var result = await RunDotNetAsync(projectDirectory, "msbuild -nologo -t:WritePackageReferences");
 
        Assert.True(result.ExitCode == 0, result.Output);
 
        return await File.ReadAllLinesAsync(packageReferencesPath);
    }
 
    private static async Task<RunHookProject> CreateRunHookProjectAsync(
        string workspace,
        bool aspireUseCliBundle,
        string? extraProjectXml = null,
        bool includeBundlePaths = true)
    {
        var repoRoot = GetRepoRoot();
        var projectDirectory = Path.Combine(workspace, "AppHost");
        Directory.CreateDirectory(projectDirectory);
 
        var appHostTargetsPath = SecurityElement.Escape(Path.Combine(repoRoot, "src", "Aspire.Hosting.AppHost", "build", "Aspire.Hosting.AppHost.in.targets"));
        var projectFile = Path.Combine(projectDirectory, "AppHost.csproj");
        var bundlePathsXml = includeBundlePaths
            ? """
                <AspireDashboardPath>$(MSBuildProjectDirectory)/Aspire.Dashboard.dll</AspireDashboardPath>
                <DcpDir>$(MSBuildProjectDirectory)</DcpDir>
              """
            : string.Empty;
 
        await File.WriteAllTextAsync(projectFile,
            $$"""
            <Project Sdk="Microsoft.NET.Sdk">
 
              <PropertyGroup>
                <OutputType>Exe</OutputType>
                <TargetFramework>net8.0</TargetFramework>
                <IsAspireHost>true</IsAspireHost>
                <AspireHostingSDKVersion>{{AspireCliVersion}}</AspireHostingSDKVersion>
                <AspireUseCliBundle>{{aspireUseCliBundle.ToString().ToLowerInvariant()}}</AspireUseCliBundle>
            {{bundlePathsXml}}
                <_AspireTasksAssembly>{{SecurityElement.Escape(GetAspireHostingTasksAssemblyPath())}}</_AspireTasksAssembly>
                <SkipAspireWorkloadManifest>true</SkipAspireWorkloadManifest>
                <SkipValidateAspireHostProjectResources>true</SkipValidateAspireHostProjectResources>
              </PropertyGroup>
 
            {{extraProjectXml}}
 
              <Import Project="{{appHostTargetsPath}}" />
 
            </Project>
            """);
 
        await File.WriteAllTextAsync(Path.Combine(projectDirectory, "Program.cs"), """
            System.Console.WriteLine("AppHost should be launched by the Aspire CLI.");
            """);
 
        return new RunHookProject(projectDirectory, projectFile);
    }
 
    private static async Task<Dictionary<string, string>> GetComputeRunArgumentsPropertiesAsync(
        RunHookProject project,
        string[]? extraArguments = null,
        IDictionary<string, string>? environment = null)
    {
        return await GetTargetPropertiesAsync(
            project,
            "ComputeRunArguments",
            "RunCommand,RunArguments,RunWorkingDirectory,_AspireResolvedCliInvocationMode,_AspireResolvedCliPath,_AspireResolvedDnxPath,_AspireResolvedDnxHostPath,_AspireResolvedDnxHostArguments,_AspireResolvedCliVersion,_AspireCliVersionSupportsRunHook,_AspireCliVersionCommand",
            extraArguments,
            environment);
    }
 
    private static async Task<Dictionary<string, string>> GetResolveAspireCliBundlePathPropertiesAsync(
        RunHookProject project,
        IDictionary<string, string> environment,
        string[]? extraArguments = null)
    {
        return await GetTargetPropertiesAsync(
            project,
            "ResolveAspireCliBundlePaths",
            "DcpDir,AspireDashboardDir,AspireDashboardPath,_AspireResolvedCliInvocationMode,_AspireResolvedCliPath",
            extraArguments,
            environment: environment);
    }
 
    private static async Task<Dictionary<string, string>> GetTargetPropertiesAsync(
        RunHookProject project,
        string target,
        string propertyNames,
        string[]? extraArguments,
        IDictionary<string, string>? environment)
    {
        var arguments = new List<string>
        {
            "msbuild",
            "-nologo",
            "-restore",
            $"-t:{target}",
            $"-getProperty:{propertyNames}",
            project.ProjectFile
        };
 
        if (extraArguments is not null)
        {
            arguments.AddRange(extraArguments);
        }
 
        var result = await RunDotNetWithArgumentsAsync(project.ProjectDirectory, [.. arguments], environment);
        Assert.True(result.ExitCode == 0, result.Output);
 
        var jsonStart = result.StandardOutput.IndexOf('{');
        var jsonEnd = result.StandardOutput.LastIndexOf('}');
        Assert.True(jsonStart >= 0 && jsonEnd > jsonStart, result.Output);
 
        using var document = JsonDocument.Parse(result.StandardOutput[jsonStart..(jsonEnd + 1)]);
        var properties = document.RootElement.GetProperty("Properties");
 
        return properties.EnumerateObject().ToDictionary(property => property.Name, property => property.Value.GetString() ?? string.Empty);
    }
 
    private static async Task<string> CreateFakeAspireCliAsync(string fakeCliDirectory)
    {
        if (OperatingSystem.IsWindows())
        {
            return await CreateFakeAspireCommandShimAsync(fakeCliDirectory);
        }
 
        var aspirePath = Path.Combine(fakeCliDirectory, "aspire");
        await File.WriteAllTextAsync(aspirePath, """
            #!/bin/sh
            if [ "$1" = "--version" ]; then
                echo "13.5.0"
                exit 0
            fi
            printf '%s\n' "$@" > "$ASPIRE_TEST_CAPTURE_PATH"
            """);
        File.SetUnixFileMode(aspirePath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
 
        return aspirePath;
    }
 
    private static async Task<string> CreateFakeAspireCliThatSetsUpBundleAsync(string fakeCliDirectory)
    {
        var aspirePath = Path.Combine(fakeCliDirectory, OperatingSystem.IsWindows() ? "aspire.cmd" : "aspire");
        var contents = OperatingSystem.IsWindows()
            ? """
                @echo off
                if not "%~1"=="setup" exit /b 2
                mkdir "%~dp0bundle\dcp"
                mkdir "%~dp0bundle\managed"
                type nul > "%~dp0bundle\dcp\dcp.exe"
                type nul > "%~dp0bundle\managed\aspire-managed.exe"
                """
            : """
                #!/bin/sh
                if [ "$1" != "setup" ]; then
                    exit 2
                fi
                install_path="$(dirname "$0")"
                mkdir -p "$install_path/bundle/dcp" "$install_path/bundle/managed"
                : > "$install_path/bundle/dcp/dcp"
                : > "$install_path/bundle/managed/aspire-managed"
                """;
 
        await File.WriteAllTextAsync(aspirePath, contents.ReplaceLineEndings(OperatingSystem.IsWindows() ? "\r\n" : "\n"));
        if (!OperatingSystem.IsWindows())
        {
            File.SetUnixFileMode(aspirePath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
        }
 
        return aspirePath;
    }
 
    private static (string DcpDirectory, string ManagedDirectory, string ManagedPath) CreateFakeCliBundle(string layoutRoot)
    {
        var bundle = GetFakeCliBundlePaths(layoutRoot);
        Directory.CreateDirectory(bundle.DcpDirectory);
        Directory.CreateDirectory(bundle.ManagedDirectory);
        File.WriteAllText(Path.Combine(bundle.DcpDirectory, OperatingSystem.IsWindows() ? "dcp.exe" : "dcp"), "");
        File.WriteAllText(bundle.ManagedPath, "");
        return bundle;
    }
 
    private static (string DcpDirectory, string ManagedDirectory, string ManagedPath) GetFakeCliBundlePaths(string layoutRoot)
    {
        var bundleRoot = Path.Combine(layoutRoot, "bundle");
        var dcpDirectory = Path.Combine(bundleRoot, "dcp");
        var managedDirectory = Path.Combine(bundleRoot, "managed");
        var managedPath = Path.Combine(managedDirectory, OperatingSystem.IsWindows() ? "aspire-managed.exe" : "aspire-managed");
        return (dcpDirectory, managedDirectory, managedPath);
    }
 
    private static async Task<string> CreateFakeAspireCommandShimAsync(string fakeCliDirectory, string extension = ".cmd")
    {
        var aspirePath = Path.Combine(fakeCliDirectory, $"aspire{extension}");
 
        if (OperatingSystem.IsWindows())
        {
            await File.WriteAllTextAsync(aspirePath, """
                @echo off
                if "%~1"=="--version" (
                    echo 13.5.0
                    exit /b 0
                )
                type nul > "%ASPIRE_TEST_CAPTURE_PATH%"
                :loop
                if "%~1"=="" exit /b 0
                >> "%ASPIRE_TEST_CAPTURE_PATH%" echo %~1
                shift
                goto loop
                """);
 
            return aspirePath;
        }
 
        await File.WriteAllTextAsync(aspirePath, ("""
            #!/bin/sh
            if [ "$1" = "--version" ]; then
                echo "13.5.0"
                exit 0
            fi
            printf '%s\n' "$@" > "$ASPIRE_TEST_CAPTURE_PATH"
            """).ReplaceLineEndings("\n"));
        File.SetUnixFileMode(aspirePath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
 
        return aspirePath;
    }
 
    private static async Task<string> CreateFakeDnxAsync(string fakeCliDirectory)
    {
        var dnxPath = Path.Combine(fakeCliDirectory, OperatingSystem.IsWindows() ? "dnx.cmd" : "dnx");
        if (OperatingSystem.IsWindows())
        {
            CopyFakeCommandHost(fakeCliDirectory);
            var fakeSdkDirectory = Directory.CreateDirectory(Path.Combine(fakeCliDirectory, "sdk", AspireCliVersion));
            await File.WriteAllTextAsync(Path.Combine(fakeSdkDirectory.FullName, "dotnet.dll"), "");
        }
 
        await File.WriteAllTextAsync(dnxPath, OperatingSystem.IsWindows()
            ? """
                @echo off
                if "%~1"=="--yes" if "%~2"=="aspire.cli@13.5.0" if "%~3"=="--" if "%~4"=="--version" (
                    echo 13.5.0
                    exit /b 0
                )
                if "%~1"=="--yes" if "%~2"=="aspire.cli" if "%~3"=="--" if "%~4"=="--version" (
                    echo 13.5.0
                    exit /b 0
                )
                type nul > "%ASPIRE_TEST_CAPTURE_PATH%"
                :loop
                if "%~1"=="" exit /b 0
                >> "%ASPIRE_TEST_CAPTURE_PATH%" echo %~1
                shift
                goto loop
                """
            : ("""
                #!/bin/sh
                if [ "$1" = "--yes" ] && { [ "$2" = "aspire.cli@13.5.0" ] || [ "$2" = "aspire.cli" ]; } && [ "$3" = "--" ] && [ "$4" = "--version" ]; then
                    echo "13.5.0"
                    exit 0
                fi
                if [ "$1" = "--yes" ] && { [ "$2" = "aspire.cli@13.5.0" ] || [ "$2" = "aspire.cli" ]; } && [ "$3" = "--" ] && [ "$4" = "setup" ] && [ "$5" = "--install-path" ]; then
                    mkdir -p "$6/bundle/dcp" "$6/bundle/managed"
                    : > "$6/bundle/dcp/dcp"
                    : > "$6/bundle/managed/aspire-managed"
                    exit 0
                fi
                printf '%s\n' "$@" > "$ASPIRE_TEST_CAPTURE_PATH"
                """).ReplaceLineEndings("\n"));
 
        if (!OperatingSystem.IsWindows())
        {
            File.SetUnixFileMode(dnxPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
        }
 
        return dnxPath;
    }
 
    private static async Task CreateFakeDnxThatFailsVersionAsync(string fakeCliDirectory)
    {
        var dnxPath = await CreateFakeDnxAsync(fakeCliDirectory);
        if (OperatingSystem.IsWindows())
        {
            await File.WriteAllTextAsync(Path.Combine(fakeCliDirectory, "fail-version"), "");
            return;
        }
 
        await File.WriteAllTextAsync(dnxPath, ("""
            #!/bin/sh
            exit 42
            """).ReplaceLineEndings("\n"));
        File.SetUnixFileMode(dnxPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
    }
 
    private static string CreateFakeNativeAspireCli(string fakeCliDirectory)
    {
        var fakeDotNetPath = CopyFakeCommandHost(fakeCliDirectory);
        var aspirePath = Path.Combine(fakeCliDirectory, "aspire.exe");
        File.Copy(fakeDotNetPath, aspirePath, overwrite: true);
        return aspirePath;
    }
 
    private static string CopyFakeCommandHost(string destinationDirectory)
    {
        var fakeCommandHostPath = GetAssemblyMetadataPath("FakeCommandHostPath");
        var sourceDirectory = Path.GetDirectoryName(fakeCommandHostPath)!;
        foreach (var sourcePath in Directory.EnumerateFiles(sourceDirectory, "dotnet.*"))
        {
            File.Copy(sourcePath, Path.Combine(destinationDirectory, Path.GetFileName(sourcePath)), overwrite: true);
        }
 
        return Path.Combine(destinationDirectory, Path.GetFileName(fakeCommandHostPath));
    }
 
    private static async Task<string> CreateFailingWorkingDirectoryShadowAsync(string workingDirectory, string command)
    {
        var captureFile = Path.Combine(workingDirectory, $"{command}-shadow-invoked.txt");
        if (OperatingSystem.IsWindows())
        {
            await File.WriteAllTextAsync(
                Path.Combine(workingDirectory, $"{command}.cmd"),
                $$"""
                @echo off
                type nul > "{{captureFile}}"
                exit /b 1
                """);
        }
 
        return captureFile;
    }
 
    private static async Task CreateFakeAspireCliWithVersionAsync(string fakeCliDirectory, string version)
    {
        if (OperatingSystem.IsWindows())
        {
            await File.WriteAllTextAsync(Path.Combine(fakeCliDirectory, "aspire.cmd"), $$"""
                @echo off
                if "%~1"=="--version" (
                    echo {{version}}
                    exit /b 0
                )
                exit /b 42
                """);
 
            return;
        }
 
        var aspirePath = Path.Combine(fakeCliDirectory, "aspire");
        await File.WriteAllTextAsync(aspirePath, ($$"""
            #!/bin/sh
            if [ "$1" = "--version" ]; then
                echo "{{version}}"
                exit 0
            fi
            exit 42
            """).ReplaceLineEndings("\n"));
        File.SetUnixFileMode(aspirePath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
    }
 
    private static async Task CreateFakeAspireCliWithVersionAndLargeStdErrAsync(string fakeCliDirectory, string version)
    {
        if (OperatingSystem.IsWindows())
        {
            await File.WriteAllTextAsync(Path.Combine(fakeCliDirectory, "aspire.cmd"), $$"""
                @echo off
                if "%~1"=="--version" (
                    for /L %%i in (1,1,4096) do echo stderr-padding-abcdefghijklmnopqrstuvwxyz-0123456789 1>&2
                    echo {{version}}
                    exit /b 0
                )
                exit /b 0
                """);
 
            return;
        }
 
        var aspirePath = Path.Combine(fakeCliDirectory, "aspire");
        await File.WriteAllTextAsync(aspirePath, ($$"""
            #!/bin/sh
            if [ "$1" = "--version" ]; then
                i=0
                while [ "$i" -lt 4096 ]; do
                    echo "stderr-padding-abcdefghijklmnopqrstuvwxyz-0123456789" >&2
                    i=$((i + 1))
                done
                echo "{{version}}"
                exit 0
            fi
            exit 0
            """).ReplaceLineEndings("\n"));
        File.SetUnixFileMode(aspirePath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
    }
 
    private static async Task CreateFakeAspireCliThatHangsOnVersionAsync(string fakeCliDirectory)
    {
        if (OperatingSystem.IsWindows())
        {
            await File.WriteAllTextAsync(Path.Combine(fakeCliDirectory, "aspire.cmd"), """
                @echo off
                if "%~1"=="--version" (
                    :loop
                    ping -n 2 127.0.0.1 > nul
                    goto loop
                )
                exit /b 0
                """);
 
            return;
        }
 
        var aspirePath = Path.Combine(fakeCliDirectory, "aspire");
        await File.WriteAllTextAsync(aspirePath, ("""
            #!/bin/sh
            if [ "$1" = "--version" ]; then
                while true; do
                    sleep 1
                done
            fi
            exit 0
            """).ReplaceLineEndings("\n"));
        File.SetUnixFileMode(aspirePath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
    }
 
    private static async Task CreateFakeAspireCliThatHangsOnSetupAsync(string fakeCliDirectory)
    {
        if (OperatingSystem.IsWindows())
        {
            await File.WriteAllTextAsync(Path.Combine(fakeCliDirectory, "aspire.cmd"), """
                @echo off
                if "%~1"=="--version" (
                    echo 13.5.0
                    exit /b 0
                )
                if "%~1"=="setup" (
                    :loop
                    ping -n 2 127.0.0.1 > nul
                    goto loop
                )
                exit /b 0
                """);
 
            return;
        }
 
        var aspirePath = Path.Combine(fakeCliDirectory, "aspire");
        await File.WriteAllTextAsync(aspirePath, ("""
            #!/bin/sh
            if [ "$1" = "--version" ]; then
                echo "13.5.0"
                exit 0
            fi
            if [ "$1" = "setup" ]; then
                while true; do
                    sleep 1
                done
            fi
            exit 0
            """).ReplaceLineEndings("\n"));
        File.SetUnixFileMode(aspirePath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
    }
 
    private static async Task CreateFakeAspireCliThatFailsVersionAsync(string fakeCliDirectory)
    {
        if (OperatingSystem.IsWindows())
        {
            await File.WriteAllTextAsync(Path.Combine(fakeCliDirectory, "aspire.cmd"), """
                @echo off
                if "%~1"=="--version" (
                    exit /b 1
                )
                type nul > "%ASPIRE_TEST_CAPTURE_PATH%"
                :loop
                if "%~1"=="" exit /b 0
                >> "%ASPIRE_TEST_CAPTURE_PATH%" echo %~1
                shift
                goto loop
                """);
 
            return;
        }
 
        var aspirePath = Path.Combine(fakeCliDirectory, "aspire");
        await File.WriteAllTextAsync(aspirePath, ("""
            #!/bin/sh
            if [ "$1" = "--version" ]; then
                exit 1
            fi
            printf '%s\n' "$@" > "$ASPIRE_TEST_CAPTURE_PATH"
            """).ReplaceLineEndings("\n"));
        File.SetUnixFileMode(aspirePath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
    }
 
    private static void AssertDashboardAndOrchestrationReferences(string[] packageReferences)
    {
        var dashboardReference = Assert.Single(packageReferences, static packageReference => packageReference.StartsWith("Aspire.Dashboard.Sdk.", StringComparison.Ordinal));
        var orchestrationReference = Assert.Single(packageReferences, static packageReference => packageReference.StartsWith("Aspire.Hosting.Orchestration.", StringComparison.Ordinal));
 
        var dashboardRid = GetPackageRid(dashboardReference, "Aspire.Dashboard.Sdk.");
        var orchestrationRid = GetPackageRid(orchestrationReference, "Aspire.Hosting.Orchestration.");
 
        Assert.Equal(dashboardRid, orchestrationRid);
        Assert.Contains(dashboardRid, s_supportedRids);
        Assert.Equal($"Aspire.Dashboard.Sdk.{dashboardRid}=13.4.0", dashboardReference);
        Assert.Equal($"Aspire.Hosting.Orchestration.{dashboardRid}=13.4.0", orchestrationReference);
    }
 
    private static string GetAspireRuntimeIdentifierToolPath()
    {
        // The path to the locally-built RID tool is baked into the test assembly via AssemblyMetadata
        // so the test can locate it regardless of the configuration the test was built with.
        var assembly = typeof(AppHostSdkTargetsTests).Assembly;
        var toolPath = assembly
            .GetCustomAttributes<AssemblyMetadataAttribute>()
            .Single(a => string.Equals(a.Key, "AspireRuntimeIdentifierToolPath", StringComparison.Ordinal))
            .Value;
        Assert.False(string.IsNullOrEmpty(toolPath), "AspireRuntimeIdentifierToolPath assembly metadata is not set.");
        Assert.True(File.Exists(toolPath), $"Aspire.RuntimeIdentifier.Tool was not built at '{toolPath}'. Build the test project to produce it.");
        return toolPath!;
    }
 
    private static string GetAspireHostingTasksAssemblyPath()
    {
        var assembly = typeof(AppHostSdkTargetsTests).Assembly;
        var taskAssemblyPath = assembly
            .GetCustomAttributes<AssemblyMetadataAttribute>()
            .Single(a => string.Equals(a.Key, "AspireHostingTasksAssemblyPath", StringComparison.Ordinal))
            .Value;
        Assert.False(string.IsNullOrEmpty(taskAssemblyPath), "AspireHostingTasksAssemblyPath assembly metadata is not set.");
        Assert.True(File.Exists(taskAssemblyPath), $"Aspire.Hosting.Tasks was not built at '{taskAssemblyPath}'. Build the test project to produce it.");
        return taskAssemblyPath!;
    }
 
    private static async Task<string?> FindFullFrameworkMSBuildAsync()
    {
        var vswherePath = Path.Combine(
            Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86),
            "Microsoft Visual Studio",
            "Installer",
            "vswhere.exe");
        if (!File.Exists(vswherePath))
        {
            return null;
        }
 
        var result = await RunProcessWithArgumentsAsync(
            vswherePath,
            Path.GetDirectoryName(vswherePath)!,
            ["-latest", "-products", "*", "-requires", "Microsoft.Component.MSBuild", "-find", @"MSBuild\**\Bin\MSBuild.exe"],
            environment: null,
            TimeSpan.FromSeconds(30));
        if (result.ExitCode != 0)
        {
            return null;
        }
 
        return result.StandardOutput
            .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
            .FirstOrDefault(File.Exists);
    }
 
    private static string GetAssemblyMetadataPath(string metadataName)
    {
        var path = typeof(AppHostSdkTargetsTests).Assembly
            .GetCustomAttributes<AssemblyMetadataAttribute>()
            .Single(a => string.Equals(a.Key, metadataName, StringComparison.Ordinal))
            .Value;
        Assert.False(string.IsNullOrEmpty(path), $"{metadataName} assembly metadata is not set.");
        Assert.True(File.Exists(path), $"The file specified by {metadataName} was not built at '{path}'.");
        return path!;
    }
 
    private static string GetPackageRid(string packageReference, string prefix)
    {
        var equalsIndex = packageReference.IndexOf('=');
        Assert.True(equalsIndex > prefix.Length, $"Package reference '{packageReference}' did not contain a RID.");
 
        return packageReference[prefix.Length..equalsIndex];
    }
 
    private static string GetExpectedAspireRunCommand(string aspireCliPath)
        => OperatingSystem.IsWindows() && IsWindowsCommandShim(aspireCliPath) ? "cmd" : aspireCliPath;
 
    private static string GetExpectedDnxRunCommand(string dnxPath)
        => OperatingSystem.IsWindows() ? Path.Combine(Path.GetDirectoryName(dnxPath)!, "dotnet.exe") : dnxPath;
 
    private static string GetExpectedAspireRunArguments(string aspireCliPath, RunHookProject project, string? extraArguments = null)
        => GetExpectedAspireRunArguments(aspireCliPath, project.ProjectFile, extraArguments);
 
    private static string GetExpectedAspireRunArguments(string aspireCliPath, string projectPath, string? extraArguments = null)
    {
        var prefix = OperatingSystem.IsWindows() && IsWindowsCommandShim(aspireCliPath)
            ? $"/D /V:OFF /C {Regex.Replace(aspireCliPath, """[ \t()[\]{}!^`<>&|;,+="~@]""", "^$0")} "
            : string.Empty;
        var arguments = $"{prefix}run --project \"{projectPath}\" --no-build --";
 
        return string.IsNullOrEmpty(extraArguments) ? arguments : $"{arguments} {extraArguments}";
    }
 
    private static string GetExpectedExplicitAspireRunArguments(RunHookProject project, string? extraArguments = null)
    {
        var arguments = $"run --project \"{project.ProjectFile}\" --no-build --";
 
        return string.IsNullOrEmpty(extraArguments) ? arguments : $"{arguments} {extraArguments}";
    }
 
    private static string GetExpectedDnxRunArguments(string dnxPath, RunHookProject project, string? extraArguments = null, bool pinned = true)
    {
        var prefix = OperatingSystem.IsWindows()
            ? $"exec \"{Path.Combine(Path.GetDirectoryName(dnxPath)!, "sdk", AspireCliVersion, "dotnet.dll")}\" dnx "
            : string.Empty;
        var packageReference = pinned ? $"aspire.cli@{AspireCliVersion}" : "aspire.cli";
        var arguments = $"{prefix}--yes {packageReference} -- run --project \"{project.ProjectFile}\" --no-build --";
 
        return string.IsNullOrEmpty(extraArguments) ? arguments : $"{arguments} {extraArguments}";
    }
 
    private static bool IsWindowsCommandShim(string path)
        => path.EndsWith(".cmd", StringComparison.OrdinalIgnoreCase)
            || path.EndsWith(".bat", StringComparison.OrdinalIgnoreCase);
 
    private static string GetExpectedWindowsCommandShimRunArguments(RunHookProject project, string cliPath, string? extraArguments = null)
    {
        var escapedCliPath = Regex.Replace(cliPath, """[ \t()[\]{}!^`<>&|;,+="~@]""", "^$0");
        var arguments = $"/D /V:OFF /C {escapedCliPath} run --project \"{project.ProjectFile}\" --no-build --";
 
        return string.IsNullOrEmpty(extraArguments) ? arguments : $"{arguments} {extraArguments}";
    }
 
    private static void AssertUsesExplicitAspireCli(
        Dictionary<string, string> properties,
        RunHookProject project,
        string cliPath,
        string? expectedArguments = null)
    {
        if (OperatingSystem.IsWindows()
            && (cliPath.EndsWith(".cmd", StringComparison.OrdinalIgnoreCase)
                || cliPath.EndsWith(".bat", StringComparison.OrdinalIgnoreCase)))
        {
            Assert.Equal("cmd", properties["RunCommand"]);
            Assert.Equal(GetExpectedWindowsCommandShimRunArguments(project, cliPath, expectedArguments), properties["RunArguments"]);
            return;
        }
 
        Assert.Equal(cliPath, properties["RunCommand"]);
        Assert.Equal(GetExpectedExplicitAspireRunArguments(project, expectedArguments), properties["RunArguments"]);
    }
 
    private static void AssertUsesDotNetRun(Dictionary<string, string> properties, RunHookProject project, string expectedArguments = "")
    {
        Assert.Equal(GetExpectedDotNetRunCommand(project), properties["RunCommand"]);
        Assert.Equal(expectedArguments, properties["RunArguments"]);
        Assert.Equal(project.ProjectDirectory, properties["RunWorkingDirectory"]);
    }
 
    private static void AssertCliBundleExists(string aspireHome, string output)
    {
        Assert.True(
            File.Exists(Path.Combine(aspireHome, "bundle", "dcp", OperatingSystem.IsWindows() ? "dcp.exe" : "dcp")),
            output);
        Assert.True(
            File.Exists(Path.Combine(aspireHome, "bundle", "managed", OperatingSystem.IsWindows() ? "aspire-managed.exe" : "aspire-managed")),
            output);
    }
 
    private static string GetExpectedDotNetRunCommand(RunHookProject project)
    {
        var executableName = OperatingSystem.IsWindows() ? "AppHost.exe" : "AppHost";
 
        return Path.Combine(project.ProjectDirectory, "bin", "Debug", "net8.0", executableName);
    }
 
    private static string GetPathEnvironmentVariableName() => OperatingSystem.IsWindows() ? "Path" : "PATH";
 
    private static Dictionary<string, string> CreatePathEnvironment(string directory, bool includeCurrentPath = true)
    {
        var pathEnvironmentVariable = GetPathEnvironmentVariableName();
        var path = includeCurrentPath
            ? $"{directory}{Path.PathSeparator}{Environment.GetEnvironmentVariable(pathEnvironmentVariable)}"
            : directory;
 
        return new Dictionary<string, string>
        {
            [pathEnvironmentVariable] = path
        };
    }
 
    private static string CreatePathWithoutAspire(params string[] firstDirectories)
        => CreatePathWithoutCommand(firstDirectories, "aspire");
 
    private static string CreatePathWithoutDnx(params string[] firstDirectories)
        => CreatePathWithoutCommand(firstDirectories, "dnx");
 
    private static string CreatePathWithoutCommand(string[] firstDirectories, string commandName)
    {
        var pathEnvironmentVariable = GetPathEnvironmentVariableName();
        var currentPath = Environment.GetEnvironmentVariable(pathEnvironmentVariable) ?? string.Empty;
        var pathDirectories = currentPath
            .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)
            .Where(directory => !ContainsCommand(directory, commandName));
 
        return string.Join(Path.PathSeparator, firstDirectories.Concat(pathDirectories));
    }
 
    private static bool ContainsCommand(string directory, string commandName)
    {
        var executableNames = OperatingSystem.IsWindows()
            ? new[] { $"{commandName}.exe", $"{commandName}.cmd", $"{commandName}.bat", commandName }
            : [commandName];
 
        return executableNames.Any(executableName => File.Exists(Path.Combine(directory.Trim().Trim('"'), executableName)));
    }
 
    private static async Task<(int ExitCode, string Output)> RunDotNetAsync(string workingDirectory, string arguments)
    {
        using var process = Process.Start(new ProcessStartInfo("dotnet", arguments)
        {
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            UseShellExecute = false,
            CreateNoWindow = true,
            WorkingDirectory = workingDirectory
        });
 
        Assert.NotNull(process);
 
        var outputTask = process.StandardOutput.ReadToEndAsync();
        var errorTask = process.StandardError.ReadToEndAsync();
 
        using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(3));
 
        try
        {
            await process.WaitForExitAsync(cts.Token);
        }
        catch (OperationCanceledException)
        {
            process.Kill(entireProcessTree: true);
            throw new TimeoutException($"dotnet {arguments} timed out after 3 minutes.");
        }
 
        var output = await outputTask;
        var error = await errorTask;
 
        return (process.ExitCode, output + error);
    }
 
    private static Task<DotNetResult> RunDotNetWithArgumentsAsync(string workingDirectory, string[] arguments, IDictionary<string, string>? environment = null)
        => RunProcessWithArgumentsAsync("dotnet", workingDirectory, arguments, environment, TimeSpan.FromMinutes(3));
 
    private static async Task<DotNetResult> RunProcessWithArgumentsAsync(
        string fileName,
        string workingDirectory,
        string[] arguments,
        IDictionary<string, string>? environment,
        TimeSpan timeout)
    {
        var startInfo = new ProcessStartInfo(fileName)
        {
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            UseShellExecute = false,
            CreateNoWindow = true,
            WorkingDirectory = workingDirectory
        };
 
        startInfo.Environment["MSBUILDTERMINALLOGGER"] = "false";
 
        foreach (var argument in arguments)
        {
            startInfo.ArgumentList.Add(argument);
        }
 
        if (environment is not null)
        {
            foreach (var (key, value) in environment)
            {
                startInfo.Environment[key] = value;
            }
        }
 
        using var process = Process.Start(startInfo);
 
        Assert.NotNull(process);
 
        var outputTask = process.StandardOutput.ReadToEndAsync();
        var errorTask = process.StandardError.ReadToEndAsync();
 
        using var cts = new CancellationTokenSource(timeout);
 
        try
        {
            await process.WaitForExitAsync(cts.Token);
        }
        catch (OperationCanceledException)
        {
            process.Kill(entireProcessTree: true);
            throw new TimeoutException($"{fileName} {string.Join(' ', arguments)} timed out after {timeout}.");
        }
 
        return new DotNetResult(process.ExitCode, await outputTask, await errorTask);
    }
 
    private static Process? TryGetProcessById(int processId)
    {
        try
        {
            return Process.GetProcessById(processId);
        }
        catch (ArgumentException)
        {
            return null;
        }
    }
 
    private static string GetRepoRoot()
    {
        var directory = AppContext.BaseDirectory;
 
        while (directory is not null && !Directory.Exists(Path.Combine(directory, ".git")) && !File.Exists(Path.Combine(directory, ".git")))
        {
            directory = Directory.GetParent(directory)?.FullName;
        }
 
        return directory ?? throw new InvalidOperationException("Could not find repository root.");
    }
 
    private sealed record RunHookProject(string ProjectDirectory, string ProjectFile);
 
    private sealed record DotNetResult(int ExitCode, string StandardOutput, string StandardError)
    {
        public string Output => StandardOutput + StandardError;
    }
}