File: DotNet\DotNetCliRunnerTests.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.Collections.Concurrent;
using System.Diagnostics;
using System.Text.Json;
using Aspire.Cli.Backchannel;
using Aspire.Cli.DotNet;
using Aspire.Cli.Interaction;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Aspire.Hosting;
using Aspire.Tests;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using System.Net.Sockets;
 
namespace Aspire.Cli.Tests.DotNet;
 
public class DotNetCliRunnerTests(ITestOutputHelper outputHelper)
{
    private static Aspire.Cli.CliExecutionContext CreateExecutionContext(DirectoryInfo workingDirectory)
    {
        return TestExecutionContextHelper.CreateExecutionContext(workingDirectory);
    }
 
    private static string GetDotNetExecutablePath()
    {
        var dotnetRoot = Environment.GetEnvironmentVariable("DOTNET_ROOT");
        if (!string.IsNullOrWhiteSpace(dotnetRoot))
        {
            return Path.Combine(dotnetRoot, OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet");
        }
 
        return "dotnet";
    }
 
    [Fact]
    public async Task DotNetCliCorrectlyAppliesNoLaunchProfileArgumentWhenSpecifiedInOptions()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file.");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var options = new ProcessInvocationOptions()
        {
            NoLaunchProfile = true,
            LaunchProfile = "E2E"
        };
 
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (args, _, _, _) =>
            {
                Assert.Equal(
                    ["run", "--no-launch-profile", "--project", projectFile.FullName, "--", "--operation", "inspect"],
                    args);
            },
            42);
 
        // This is what we are really testing here - that RunAsync reads
        // the NoLaunchProfile property from the invocation options and
        // correctly applies it to the command-line arguments for the
        // dotnet run command.
        var exitCode = await runner.RunAsync(
            projectFile: projectFile,
            watch: false,
            noBuild: false,
            noRestore: false,
            args: ["--operation", "inspect"],
            env: new Dictionary<string, string>(),
            null,
            options,
            CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(42, exitCode);
    }
 
    [Theory]
    [InlineData("AppHost.csproj", false)]
    [InlineData("AppHost.csproj", true)]
    [InlineData("apphost.cs", false)]
    public async Task RunAsyncAppliesSelectedLaunchProfile(string appHostFileName, bool watch)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, appHostFileName));
        await File.WriteAllTextAsync(appHostFile.FullName, "Not a real AppHost.");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (args, _, _, _) =>
            {
                string[] expectedArgs = appHostFile.Extension == ".cs"
                    ? ["run", "--launch-profile=--no-build", $"/p:{KnownConfigNames.SuppressCliRunHook}=true", "--file", appHostFile.FullName, "--"]
                    : watch
                        ? ["watch", "--non-interactive", "--launch-profile=--no-build", "--project", appHostFile.FullName, "--"]
                        : ["run", "--launch-profile=--no-build", "--project", appHostFile.FullName, "--"];
                Assert.Equal(expectedArgs, args);
            },
            42);
 
        var exitCode = await runner.RunAsync(
            projectFile: appHostFile,
            watch,
            noBuild: false,
            noRestore: false,
            args: [],
            env: null,
            backchannelCompletionSource: null,
            new ProcessInvocationOptions { LaunchProfile = "--no-build" },
            CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(42, exitCode);
    }
 
    [Fact]
    public async Task RunAsyncPreservesApplicationArgumentBoundaries()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file.");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var appHostArguments = new[] { "--custom", "value with spaces", "", "literal \"quote\"", @"C:\tools\backslash\path" };
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (args, _, _, _) => Assert.Equal(
                ["run", "--project", projectFile.FullName, "--", .. appHostArguments],
                args),
            42);
 
        var exitCode = await runner.RunAsync(
            projectFile,
            watch: false,
            noBuild: false,
            noRestore: false,
            appHostArguments,
            env: null,
            backchannelCompletionSource: null,
            new ProcessInvocationOptions(),
            CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(42, exitCode);
    }
 
    [Fact]
    public async Task RunAppHostCommandAsyncUsesNativeCommandWithoutRunDelimiter()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        var appHostCommand = Path.Combine(workspace.WorkspaceRoot.FullName, "bin", "Debug", "net10.0", "AppHost");
        var runWorkingDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "run-cwd"));
        await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file.");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (fileName, args, env, workingDirectory, _) =>
            {
                Assert.Equal(appHostCommand, fileName);
                Assert.Collection(args,
                    arg => Assert.Equal("--operation", arg),
                    arg => Assert.Equal("inspect", arg));
                Assert.DoesNotContain("--", args);
                Assert.Equal(runWorkingDirectory.FullName, workingDirectory.FullName);
                Assert.NotNull(env);
                Assert.False(env.ContainsKey("DOTNET_CLI_USE_MSBUILD_SERVER"));
            },
            42);
 
        var exitCode = await runner.RunAppHostCommandAsync(
            projectFile,
            appHostCommand,
            runWorkingDirectory,
            ["--operation", "inspect"],
            new Dictionary<string, string>(),
            null,
            new ProcessInvocationOptions(),
            CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(42, exitCode);
    }
 
    [Fact]
    public async Task RunAppHostCommandAsyncResolvesDotnetMuxerCommand()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        var appHostAssembly = Path.Combine(workspace.WorkspaceRoot.FullName, "bin", "Debug", "net10.0", "AppHost.dll");
        var runWorkingDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "run-cwd"));
        await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file.");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (fileName, args, _, workingDirectory, _) =>
            {
                Assert.Equal("dotnet", Path.GetFileNameWithoutExtension(fileName));
                Assert.Collection(args,
                    arg => Assert.Equal("exec", arg),
                    arg => Assert.Equal(appHostAssembly, arg),
                    arg => Assert.Equal("--operation", arg),
                    arg => Assert.Equal("inspect", arg));
                Assert.Equal(runWorkingDirectory.FullName, workingDirectory.FullName);
            },
            42);
 
        var exitCode = await runner.RunAppHostCommandAsync(
            projectFile,
            "dotnet.exe",
            runWorkingDirectory,
            ["exec", appHostAssembly, "--operation", "inspect"],
            new Dictionary<string, string>(),
            null,
            new ProcessInvocationOptions(),
            CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(42, exitCode);
    }
 
    [Fact]
    public async Task BuildAsyncDoesNotInjectDotnetCliUseMsBuildServerEnvironmentVariable()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file.");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var options = new ProcessInvocationOptions();
 
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (args, env, _, _) =>
            {
                Assert.NotNull(env);
                Assert.False(env.ContainsKey("DOTNET_CLI_USE_MSBUILD_SERVER"));
            },
            0);
 
        var exitCode = await runner.BuildAsync(projectFile, noRestore: false, options, CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task BuildAsyncPassesCurrentAspireCliPathToMSBuild()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file.");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (_, env, _, _) =>
            {
                Assert.NotNull(env);
                if (Environment.ProcessPath is { } processPath && DotNetCliRunner.ShouldForwardProcessPathAsAspireCliPath(processPath))
                {
                    Assert.Equal(processPath, env["AspireCliPath"]);
                }
                else
                {
                    Assert.False(env.ContainsKey("AspireCliPath"));
                }
            },
            0);
 
        var exitCode = await runner.BuildAsync(projectFile, noRestore: false, new ProcessInvocationOptions(), CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public void ShouldForwardProcessPathAsAspireCliPathRejectsUnbundledFrameworkDependentCliBuild()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var cliDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "artifacts", "bin", "Aspire.Cli", "Debug", "net10.0"));
        var cliPath = Path.Combine(cliDirectory.FullName, OperatingSystem.IsWindows() ? "aspire.exe" : "aspire");
        File.WriteAllText(cliPath, string.Empty);
        File.WriteAllText(Path.Combine(cliDirectory.FullName, "aspire.dll"), string.Empty);
 
        Assert.False(DotNetCliRunner.ShouldForwardProcessPathAsAspireCliPath(cliPath));
    }
 
    [Fact]
    [SkipOnPlatform(TestPlatforms.Windows, "Symlink resolution test only runs on Linux/macOS where unprivileged symlink creation is reliable.")]
    public void ShouldForwardProcessPathAsAspireCliPathRejectsSymlinkToUnbundledFrameworkDependentCliBuild()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var cliDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "artifacts", "bin", "Aspire.Cli", "Debug", "net10.0"));
        var cliPath = Path.Combine(cliDirectory.FullName, "aspire");
        File.WriteAllText(cliPath, string.Empty);
        File.WriteAllText(Path.Combine(cliDirectory.FullName, "aspire.dll"), string.Empty);
 
        var linkDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "bin"));
        var linkPath = Path.Combine(linkDirectory.FullName, "aspire");
        File.CreateSymbolicLink(linkPath, cliPath);
 
        Assert.False(DotNetCliRunner.ShouldForwardProcessPathAsAspireCliPath(linkPath));
    }
 
    [Fact]
    public void ShouldForwardProcessPathAsAspireCliPathRejectsDotNetMuxerPath()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var dotnetDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "dotnet"));
        var dotnetPath = Path.Combine(dotnetDirectory.FullName, OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet");
        File.WriteAllText(dotnetPath, string.Empty);
 
        Assert.False(DotNetCliRunner.ShouldForwardProcessPathAsAspireCliPath(dotnetPath));
    }
 
    [Fact]
    public void ShouldForwardProcessPathAsAspireCliPathAllowsFrameworkDependentCliWithInstallSidecar()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var cliDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "bin"));
        var cliPath = Path.Combine(cliDirectory.FullName, OperatingSystem.IsWindows() ? "aspire.exe" : "aspire");
        File.WriteAllText(cliPath, string.Empty);
        File.WriteAllText(Path.Combine(cliDirectory.FullName, "aspire.dll"), string.Empty);
        File.WriteAllText(Path.Combine(cliDirectory.FullName, ".aspire-install.json"), """{"source":"script"}""");
 
        Assert.True(DotNetCliRunner.ShouldForwardProcessPathAsAspireCliPath(cliPath));
    }
 
    [Fact]
    public void ShouldForwardProcessPathAsAspireCliPathAllowsAdjacentBundleLayout()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var cliDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "bin"));
        var cliPath = Path.Combine(cliDirectory.FullName, OperatingSystem.IsWindows() ? "aspire.exe" : "aspire");
        File.WriteAllText(cliPath, string.Empty);
        File.WriteAllText(Path.Combine(cliDirectory.FullName, "aspire.dll"), string.Empty);
        Directory.CreateDirectory(Path.Combine(cliDirectory.FullName, "dcp"));
        Directory.CreateDirectory(Path.Combine(cliDirectory.FullName, "managed"));
        File.WriteAllText(Path.Combine(cliDirectory.FullName, "dcp", OperatingSystem.IsWindows() ? "dcp.exe" : "dcp"), string.Empty);
        File.WriteAllText(Path.Combine(cliDirectory.FullName, "managed", OperatingSystem.IsWindows() ? "aspire-managed.exe" : "aspire-managed"), string.Empty);
 
        Assert.True(DotNetCliRunner.ShouldForwardProcessPathAsAspireCliPath(cliPath));
    }
 
    [Fact]
    public async Task RestoreAsyncRunsDotnetRestoreCommand()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file.");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var options = new ProcessInvocationOptions();
 
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (args, _, _, _) =>
            {
                Assert.Equal("restore", args[0]);
                Assert.Equal(projectFile.FullName, args[1]);
            },
            0);
 
        var exitCode = await runner.RestoreAsync(projectFile, options, CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task BuildAsyncDoesNotInjectConfiguredDotnetCliUseMsBuildServer()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file.");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        services.AddSingleton<IConfiguration>(sp =>
        {
            var configBuilder = new ConfigurationBuilder();
            configBuilder.AddInMemoryCollection(new Dictionary<string, string?>
            {
                ["DOTNET_CLI_USE_MSBUILD_SERVER"] = "false"
            });
            return configBuilder.Build();
        });
        using var provider = services.BuildServiceProvider();
 
        var options = new ProcessInvocationOptions();
 
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (args, env, _, _) =>
            {
                Assert.NotNull(env);
                Assert.False(env.ContainsKey("DOTNET_CLI_USE_MSBUILD_SERVER"));
            },
            0);
 
        var exitCode = await runner.BuildAsync(projectFile, noRestore: false, options, CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task BuildAsyncIncludesNoRestoreFlagWhenNoRestoreIsTrue()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file.");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var options = new ProcessInvocationOptions();
 
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (args, env, _, _) =>
            {
                // Verify that --no-restore is included when noRestore is true
                Assert.Contains("build", args);
                Assert.Contains("--no-restore", args);
            },
            0);
 
        var exitCode = await runner.BuildAsync(projectFile, noRestore: true, options, CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task BuildAsyncDoesNotIncludeNoRestoreFlagWhenNoRestoreIsFalse()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file.");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var options = new ProcessInvocationOptions();
 
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (args, env, _, _) =>
            {
                // Verify that --no-restore is NOT included when noRestore is false
                Assert.Contains("build", args);
                Assert.DoesNotContain("--no-restore", args);
            },
            0);
 
        var exitCode = await runner.BuildAsync(projectFile, noRestore: false, options, CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task BuildAsyncAddsBinlogWhenBinlogDirectoryIsConfigured()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file.");
 
        var binlogDirectory = workspace.WorkspaceRoot.CreateSubdirectory("binlogs");
        var configuration = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?>
            {
                [KnownConfigNames.CliDotnetBinlogDirectory] = binlogDirectory.FullName
            })
            .Build();
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var options = new ProcessInvocationOptions();
 
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (args, _, _, _) =>
            {
                var binlogArgument = Assert.Single(args, arg => arg.StartsWith("/bl:", StringComparison.Ordinal));
                var binlogPath = binlogArgument["/bl:".Length..];
                Assert.Equal(binlogDirectory.FullName, Path.GetDirectoryName(binlogPath));
                Assert.EndsWith(".binlog", binlogPath);
                Assert.Contains("build", Path.GetFileName(binlogPath));
                Assert.Contains("AppHost", Path.GetFileName(binlogPath));
            },
            0,
            configuration: configuration);
 
        var exitCode = await runner.BuildAsync(projectFile, noRestore: false, options, CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task NewProjectAsyncDoesNotAddBinlogWhenBinlogDirectoryIsConfigured()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var configuration = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?>
            {
                [KnownConfigNames.CliDotnetBinlogDirectory] = workspace.WorkspaceRoot.CreateSubdirectory("binlogs").FullName
            })
            .Build();
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var options = new ProcessInvocationOptions();
 
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (args, _, _, _) =>
            {
                Assert.DoesNotContain(args, arg => arg.StartsWith("/bl:", StringComparison.Ordinal));
            },
            0,
            configuration: configuration);
 
        var exitCode = await runner.NewProjectAsync("aspire", "TestProject", "/tmp/test", [], options, CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task RunAsyncDoesNotInjectDotnetCliUseMsBuildServerWhenNoBuildIsFalse()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file.");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var options = new ProcessInvocationOptions();
 
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (args, env, _, _) =>
            {
                Assert.NotNull(env);
                Assert.False(env.ContainsKey("DOTNET_CLI_USE_MSBUILD_SERVER"));
            },
            0);
 
        var exitCode = await runner.RunAsync(
            projectFile: projectFile,
            watch: false,
            noBuild: false,
            noRestore: false,
            args: ["--operation", "inspect"],
            env: new Dictionary<string, string>(),
            null,
            options,
            CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task RunAsyncDoesNotInjectDotnetCliUseMsBuildServerWhenNoBuildIsTrue()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file.");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var options = new ProcessInvocationOptions();
 
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (args, env, _, _) =>
            {
                // When noBuild is true, the original env should be passed through unchanged
                // or should be null if no env was provided
                if (env != null)
                {
                    Assert.False(env.ContainsKey("DOTNET_CLI_USE_MSBUILD_SERVER"));
                }
            },
            0);
 
        var exitCode = await runner.RunAsync(
            projectFile: projectFile,
            watch: false,
            noBuild: true, // This should NOT inject the environment variable
            noRestore: false,
            args: ["--operation", "inspect"],
            env: new Dictionary<string, string>(),
            null,
            options,
            CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task RunAsyncPreservesExistingEnvironmentVariables()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file.");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var options = new ProcessInvocationOptions();
 
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (args, env, _, _) =>
            {
                Assert.NotNull(env);
                Assert.False(env.ContainsKey("DOTNET_CLI_USE_MSBUILD_SERVER"));
                // Verify existing environment variable is preserved
                Assert.True(env.ContainsKey("EXISTING_VAR"));
                Assert.Equal("existing_value", env["EXISTING_VAR"]);
            },
            0);
 
        var existingEnv = new Dictionary<string, string>
        {
            ["EXISTING_VAR"] = "existing_value"
        };
 
        var exitCode = await runner.RunAsync(
            projectFile: projectFile,
            watch: false,
            noBuild: false,
            noRestore: false,
            args: ["--operation", "inspect"],
            env: existingEnv,
            null,
            options,
            CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task RunAsyncPropagatesProcessProfilingContextToChildEnvironment()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file.");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ConfigurationCallback += config =>
            {
                config[ProfilingTelemetry.EnvironmentVariables.Enabled] = "true";
                config[ProfilingTelemetry.EnvironmentVariables.SessionId] = "session-1";
            };
        });
        using var provider = services.BuildServiceProvider();
        var profilingTelemetry = provider.GetRequiredService<ProfilingTelemetry>();
        using var listener = ActivityListenerHelper.Create(profilingTelemetry.ActivitySource);
 
        var options = new ProcessInvocationOptions();
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (_, env, _, _) =>
            {
                var currentActivity = Activity.Current;
                Assert.NotNull(currentActivity);
                Assert.Equal(ProfilingTelemetry.ActivitySourceName, currentActivity.Source.Name);
                Assert.NotNull(env);
                Assert.Equal("true", env[ProfilingTelemetry.EnvironmentVariables.Enabled]);
                Assert.Equal("true", env[KnownConfigNames.Legacy.StartupProfilingEnabled]);
                Assert.Equal("session-1", env[ProfilingTelemetry.EnvironmentVariables.SessionId]);
                Assert.Equal("session-1", env[KnownConfigNames.Legacy.StartupOperationId]);
                Assert.Equal(currentActivity.Id, env[ProfilingTelemetry.EnvironmentVariables.TraceParent]);
                Assert.Equal(currentActivity.Id, env[KnownConfigNames.Legacy.StartupTraceParent]);
            },
            0);
 
        var exitCode = await runner.RunAsync(
            projectFile: projectFile,
            watch: false,
            noBuild: false,
            noRestore: false,
            args: ["--operation", "inspect"],
            env: new Dictionary<string, string>(),
            null,
            options,
            CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task RunAsyncParentsBackchannelConnectToRunOperationInsteadOfDotnetProcess()
    {
        const string sessionId = "backchannel-parent-session";
        var startedActivities = new ConcurrentQueue<Activity>();
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file.");
 
        var backchannel = new TestAppHostBackchannel
        {
            ConnectAsyncCalled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)
        };
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ConfigurationCallback += config =>
            {
                config[ProfilingTelemetry.EnvironmentVariables.Enabled] = "true";
                config[ProfilingTelemetry.EnvironmentVariables.SessionId] = sessionId;
            };
            options.AppHostBackchannelFactory = _ => backchannel;
        });
        using var provider = services.BuildServiceProvider();
 
        var profilingTelemetry = provider.GetRequiredService<ProfilingTelemetry>();
        using var listener = ActivityListenerHelper.Create(profilingTelemetry.ActivitySource, onActivityStarted: startedActivities.Enqueue);
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (_, _, _, _) => { },
            0);
 
        var backchannelCompletionSource = new TaskCompletionSource<IAppHostCliBackchannel>(TaskCreationOptions.RunContinuationsAsynchronously);
        ActivitySpanId runSpanId;
        using (profilingTelemetry.StartRunCommand())
        {
            runSpanId = Activity.Current!.SpanId;
 
            var exitCode = await runner.RunAsync(
                projectFile: projectFile,
                watch: false,
                noBuild: false,
                noRestore: false,
                args: ["--operation", "inspect"],
                env: new Dictionary<string, string>
                {
                    [KnownConfigNames.UnixSocketPath] = "cli.sock"
                },
                backchannelCompletionSource,
                new ProcessInvocationOptions(),
                CancellationToken.None).DefaultTimeout();
 
            Assert.Equal(0, exitCode);
            await backchannel.ConnectAsyncCalled.Task.DefaultTimeout();
            Assert.Same(backchannel, await backchannelCompletionSource.Task.DefaultTimeout());
        }
 
        var sessionActivities = startedActivities
            .Where(activity => activity.Tags.Any(tag => tag.Key == ProfilingTelemetry.Tags.ProfilingSessionId && tag.Value == sessionId))
            .ToArray();
        var processActivity = Assert.Single(sessionActivities, activity => activity.OperationName == ProfilingTelemetry.Activities.Process);
        var backchannelActivity = Assert.Single(sessionActivities, activity => activity.OperationName == ProfilingTelemetry.Activities.BackchannelConnect);
        Assert.Equal(runSpanId, backchannelActivity.ParentSpanId);
        Assert.NotEqual(processActivity.SpanId, backchannelActivity.ParentSpanId);
    }
 
    [Fact]
    public async Task NewProjectAsyncReturnsExitCode73WhenProjectAlreadyExists()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var options = new ProcessInvocationOptions();
 
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (args, env, _, _) =>
            {
                // Verify the arguments are correct for dotnet new
                Assert.Contains("new", args);
                Assert.Contains("aspire", args);
                Assert.Contains("--name", args);
                Assert.Contains("TestProject", args);
                Assert.Contains("--output", args);
                Assert.Contains("/tmp/test", args);
            },
            73); // Return exit code 73 to simulate project already exists
 
        // Act
        var exitCode = await runner.NewProjectAsync("aspire", "TestProject", "/tmp/test", [], options, CancellationToken.None).DefaultTimeout();
 
        // Assert
        Assert.Equal(73, exitCode);
    }
 
    [Fact]
    public async Task RunAsyncSetsVersionCheckDisabledWhenUpdateNotificationsFeatureIsDisabled()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file.");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.DisabledFeatures = [KnownFeatures.UpdateNotificationsEnabled];
        });
        using var provider = services.BuildServiceProvider();
 
        var options = new ProcessInvocationOptions();
 
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            provider.GetRequiredService<CliExecutionContext>(),
            (args, env, _, _) =>
            {
                Assert.NotNull(env);
                Assert.True(env.ContainsKey("ASPIRE_VERSION_CHECK_DISABLED"));
                Assert.Equal("true", env["ASPIRE_VERSION_CHECK_DISABLED"]);
            },
            0);
 
        var exitCode = await runner.RunAsync(
            projectFile: projectFile,
            watch: false,
            noBuild: false,
            noRestore: false,
            args: ["--operation", "inspect"],
            env: new Dictionary<string, string>(),
            null,
            options,
            CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task RunAsyncDoesNotSetVersionCheckDisabledWhenUpdateNotificationsFeatureIsEnabled()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file.");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.EnabledFeatures = [KnownFeatures.UpdateNotificationsEnabled];
        });
        using var provider = services.BuildServiceProvider();
 
        var options = new ProcessInvocationOptions();
 
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            provider.GetRequiredService<CliExecutionContext>(),
            (args, env, _, _) =>
            {
                // When the feature is enabled (default), the version check env var should NOT be set
                if (env != null)
                {
                    Assert.False(env.ContainsKey("ASPIRE_VERSION_CHECK_DISABLED"));
                }
            },
            0);
 
        var exitCode = await runner.RunAsync(
            projectFile: projectFile,
            watch: false,
            noBuild: false,
            noRestore: false,
            args: ["--operation", "inspect"],
            env: new Dictionary<string, string>(),
            null,
            options,
            CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task RunAsyncDoesNotOverrideUserProvidedVersionCheckDisabledValue()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file.");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.DisabledFeatures = [KnownFeatures.UpdateNotificationsEnabled];
        });
        using var provider = services.BuildServiceProvider();
 
        var options = new ProcessInvocationOptions();
 
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            provider.GetRequiredService<CliExecutionContext>(),
            (args, env, _, _) =>
            {
                Assert.NotNull(env);
                Assert.True(env.ContainsKey("ASPIRE_VERSION_CHECK_DISABLED"));
                // Should preserve user's value, not override with "true"
                Assert.Equal("false", env["ASPIRE_VERSION_CHECK_DISABLED"]);
            },
            0);
 
        // User explicitly sets the environment variable to false
        var userEnv = new Dictionary<string, string>
        {
            ["ASPIRE_VERSION_CHECK_DISABLED"] = "false"
        };
 
        var exitCode = await runner.RunAsync(
            projectFile: projectFile,
            watch: false,
            noBuild: false,
            noRestore: false,
            args: ["--operation", "inspect"],
            env: userEnv,
            null,
            options,
            CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task RunAsyncKeepsExtensionLaunchedAppHostAliveUntilBackchannelDisconnects()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file.");
 
        var launchAppHostCalledTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var backchannel = new TestAppHostBackchannel
        {
            ConnectAsyncCalled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)
        };
        TestExtensionInteractionService? testExtensionInteractionService = null;
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = sp =>
            {
                testExtensionInteractionService = new TestExtensionInteractionService(sp)
                {
                    LaunchAppHostCallback = () => launchAppHostCalledTcs.SetResult(),
                };
                return testExtensionInteractionService;
            };
            options.ConfigurationCallback = configBuilder =>
            {
                configBuilder["ASPIRE_EXTENSION_TOKEN"] = "extension-token";
            };
            options.ExtensionBackchannelFactory = _ => new TestExtensionBackchannel
            {
                HasCapabilityAsyncCallback = (c, _) => Task.FromResult(c is "devkit" or "project"),
            };
            options.AppHostBackchannelFactory = _ => backchannel;
        });
 
        using var provider = services.BuildServiceProvider();
        var runner = provider.GetRequiredService<IDotNetCliRunner>();
        var backchannelCompletionSource = new TaskCompletionSource<IAppHostCliBackchannel>(TaskCreationOptions.RunContinuationsAsynchronously);
 
        var runTask = runner.RunAsync(
            projectFile: projectFile,
            watch: false,
            noBuild: false,
            noRestore: false,
            args: [],
            env: new Dictionary<string, string>
            {
                [KnownConfigNames.UnixSocketPath] = Path.Combine(workspace.WorkspaceRoot.FullName, "cli.sock")
            },
            backchannelCompletionSource,
            options: new ProcessInvocationOptions(),
            cancellationToken: CancellationToken.None);
 
        await launchAppHostCalledTcs.Task.DefaultTimeout();
        await backchannel.ConnectAsyncCalled.Task.DefaultTimeout();
        Assert.Same(backchannel, await backchannelCompletionSource.Task.DefaultTimeout());
 
        var completedTask = await Task.WhenAny(runTask, Task.Delay(TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken));
        Assert.NotSame(runTask, completedTask);
 
        backchannel.DisconnectCompletionSource.SetResult();
        Assert.Equal(CliExitCodes.Success, await runTask.DefaultTimeout());
    }
 
    [Fact]
    public async Task RunAsyncFailsBackchannelWhenExtensionLaunchedAppHostDoesNotConnectBeforeTimeout()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file.");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = sp => new TestExtensionInteractionService(sp);
            options.ConfigurationCallback = configBuilder =>
            {
                configBuilder["ASPIRE_EXTENSION_TOKEN"] = "extension-token";
                configBuilder[KnownConfigNames.CliBackchannelConnectTimeoutSeconds] = "0";
            };
            options.ExtensionBackchannelFactory = _ => new TestExtensionBackchannel
            {
                HasCapabilityAsyncCallback = (c, _) => Task.FromResult(c is "devkit" or "project"),
            };
            options.AppHostBackchannelFactory = _ => new TestAppHostBackchannel
            {
                ConnectAsyncCallback = (_, _) => throw new SocketException((int)SocketError.ConnectionRefused)
            };
        });
 
        using var provider = services.BuildServiceProvider();
        var runner = provider.GetRequiredService<IDotNetCliRunner>();
        var backchannelCompletionSource = new TaskCompletionSource<IAppHostCliBackchannel>(TaskCreationOptions.RunContinuationsAsynchronously);
 
        var exception = await Assert.ThrowsAsync<TimeoutException>(
            () => runner.RunAsync(
                projectFile: projectFile,
                watch: false,
                noBuild: false,
                noRestore: false,
                args: [],
                env: new Dictionary<string, string>
                {
                    [KnownConfigNames.UnixSocketPath] = Path.Combine(workspace.WorkspaceRoot.FullName, "cli.sock")
                },
                backchannelCompletionSource,
                new ProcessInvocationOptions(),
                CancellationToken.None).DefaultTimeout());
        Assert.Contains("Timed out waiting for AppHost backchannel", exception.Message);
    }
 
    [Fact]
    public async Task RunAsyncFailsBackchannelWhenAppHostExitsWithSuccessBeforeBackchannelConnects()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file.");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.DotNetCliExecutionFactoryFactory = _ => new TestProcessExecutionFactory
            {
                CreateExecutionCallback = (args, env, _, _) => new ExitedProcessExecution(args, env, exitCode: 0)
            };
            options.AppHostBackchannelFactory = _ => new TestAppHostBackchannel
            {
                ConnectAsyncCallback = (_, _) => throw new SocketException((int)SocketError.ConnectionRefused)
            };
        });
        using var provider = services.BuildServiceProvider();
        var runner = provider.GetRequiredService<IDotNetCliRunner>();
        var backchannelCompletionSource = new TaskCompletionSource<IAppHostCliBackchannel>(TaskCreationOptions.RunContinuationsAsynchronously);
 
        var exitCode = await runner.RunAsync(
            projectFile: projectFile,
            watch: false,
            noBuild: false,
            noRestore: false,
            args: [],
            env: new Dictionary<string, string>
            {
                [KnownConfigNames.UnixSocketPath] = Path.Combine(workspace.WorkspaceRoot.FullName, "cli.sock")
            },
            backchannelCompletionSource,
            new ProcessInvocationOptions(),
            CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        var exception = await Assert.ThrowsAsync<FailedToConnectBackchannelConnection>(
            () => backchannelCompletionSource.Task).DefaultTimeout();
        Assert.Equal("The AppHost process exited", exception.Message);
    }
 
    [Fact]
    public async Task RunAsyncFailsBackchannelWithExitCodeWhenAppHostExitsUnexpectedlyBeforeBackchannelConnects()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file.");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.DotNetCliExecutionFactoryFactory = _ => new TestProcessExecutionFactory
            {
                CreateExecutionCallback = (args, env, _, _) => new ExitedProcessExecution(args, env, exitCode: 42)
            };
            options.AppHostBackchannelFactory = _ => new TestAppHostBackchannel
            {
                ConnectAsyncCallback = (_, _) => throw new SocketException((int)SocketError.ConnectionRefused)
            };
        });
        using var provider = services.BuildServiceProvider();
        var runner = provider.GetRequiredService<IDotNetCliRunner>();
        var backchannelCompletionSource = new TaskCompletionSource<IAppHostCliBackchannel>(TaskCreationOptions.RunContinuationsAsynchronously);
 
        var exitCode = await runner.RunAsync(
            projectFile: projectFile,
            watch: false,
            noBuild: false,
            noRestore: false,
            args: [],
            env: new Dictionary<string, string>
            {
                [KnownConfigNames.UnixSocketPath] = Path.Combine(workspace.WorkspaceRoot.FullName, "cli.sock")
            },
            backchannelCompletionSource,
            new ProcessInvocationOptions(),
            CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(42, exitCode);
        var exception = await Assert.ThrowsAsync<FailedToConnectBackchannelConnection>(
            () => backchannelCompletionSource.Task).DefaultTimeout();
        Assert.Equal("The AppHost process exited unexpectedly with exit code 42", exception.Message);
    }
 
    [Fact]
    public async Task AddPackageAsyncUseFilesSwitchForSingleFileAppHost()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs"));
        await File.WriteAllTextAsync(appHostFile.FullName, "// Single-file AppHost");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var options = new ProcessInvocationOptions();
 
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (args, _, _, _) =>
            {
                // Verify arguments are correct for single-file AppHost
                Assert.Contains("add", args);
                Assert.Contains("package", args);
                Assert.Contains("--file", args);
                Assert.Contains(appHostFile.FullName, args);
                Assert.Contains("Aspire.Hosting.Redis@9.2.0", args);
                Assert.Contains("--no-restore", args);
 
                // Verify the order: package add PackageName --file FilePath --version Version --no-restore
                var packageIndex = Array.IndexOf(args, "package");
                var addIndex = Array.IndexOf(args, "add");
                var fileIndex = Array.IndexOf(args, "--file");
                var filePathIndex = Array.IndexOf(args, appHostFile.FullName);
                var packageNameIndex = Array.IndexOf(args, "Aspire.Hosting.Redis@9.2.0");
 
                Assert.True(packageIndex < addIndex);
                Assert.True(packageIndex < fileIndex);
                Assert.True(fileIndex < filePathIndex);
                Assert.True(filePathIndex < packageNameIndex);
            },
            0);
 
        var exitCode = await runner.AddPackageAsync(
            appHostFile,
            "Aspire.Hosting.Redis",
            "9.2.0",
            nugetSource: null,
            noRestore: true, // should use --no-restore
            options,
            CancellationToken.None
            );
 
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task AddPackageAsyncUsesPositionalArgumentForCsprojFile()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(projectFile.FullName, "<Project></Project>");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var options = new ProcessInvocationOptions();
 
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (args, _, _, _) =>
            {
                // Verify arguments are correct for .csproj file (new behavior with --version)
                Assert.Contains("add", args);
                Assert.Contains("package", args);
                Assert.Contains(projectFile.FullName, args);
                Assert.Contains("Aspire.Hosting.Redis", args);
                Assert.Contains("--version", args);
                Assert.Contains("9.2.0", args);
                Assert.Contains("--source", args);
                Assert.Contains("https://api.nuget.org/v3/index.json", args);
 
                // Verify the order: package add PackageName --version Version --source Source --project ProjectFile 
                var packageIndex = Array.IndexOf(args, "package");
                var addIndex = Array.IndexOf(args, "add");
                var projectFlagIndex = Array.IndexOf(args, "--project");
                var projectValueIndex = Array.IndexOf(args, projectFile.FullName);
                var packageNameIndex = Array.IndexOf(args, "Aspire.Hosting.Redis");
                var versionFlagIndex = Array.IndexOf(args, "--version");
                var versionValueIndex = Array.IndexOf(args, "9.2.0");
                var sourceFlagIndex = Array.IndexOf(args, "--source");
                var sourceValueIndex = Array.IndexOf(args, "https://api.nuget.org/v3/index.json");
 
                Assert.True(packageIndex < addIndex);
                Assert.True(addIndex < packageNameIndex);
                Assert.True(packageNameIndex < versionFlagIndex);
                Assert.True(versionFlagIndex < versionValueIndex);
                Assert.True(packageNameIndex < projectFlagIndex);
                Assert.True(projectFlagIndex < projectValueIndex);
                Assert.True(packageNameIndex < sourceFlagIndex);
                Assert.True(sourceFlagIndex < sourceValueIndex);
 
                // Should NOT contain --file or the @version format
                Assert.DoesNotContain("--file", args);
                Assert.DoesNotContain("Aspire.Hosting.Redis@9.2.0", args);
            },
            0);
 
        var exitCode = await runner.AddPackageAsync(
            projectFile,
            "Aspire.Hosting.Redis",
            "9.2.0",
            "https://api.nuget.org/v3/index.json", // provide source, should use --source
            noRestore: false,
            options,
            CancellationToken.None
            );
 
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task AddPackageAsyncUsesPositionalArgumentForCsprojFileWithNoRestore()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(projectFile.FullName, "<Project></Project>");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var options = new ProcessInvocationOptions();
 
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (args, _, _, _) =>
            {
                // Verify arguments are correct for .csproj file with --no-restore (no source provided)
                Assert.Contains("package", args);
                Assert.Contains("add", args);
                Assert.Contains("Aspire.Hosting.Redis", args);
                Assert.Contains("--version", args);
                Assert.Contains("9.2.0", args);
                Assert.Contains("--project", args);
                Assert.Contains(projectFile.FullName, args);
                Assert.Contains("--no-restore", args);
 
                // Verify the order: package add PackageName --version Version --project ProjectFile --no-restore
                var packageIndex = Array.IndexOf(args, "package");
                var addIndex = Array.IndexOf(args, "add");
                var packageNameIndex = Array.IndexOf(args, "Aspire.Hosting.Redis");
                var versionFlagIndex = Array.IndexOf(args, "--version");
                var versionValueIndex = Array.IndexOf(args, "9.2.0");
                var projectFlagIndex = Array.IndexOf(args, "--project");
                var projectValueIndex = Array.IndexOf(args, projectFile.FullName);
                var noRestoreIndex = Array.IndexOf(args, "--no-restore");
 
                Assert.True(packageIndex < addIndex);
                Assert.True(addIndex < packageNameIndex);
                Assert.True(packageNameIndex < versionFlagIndex);
                Assert.True(versionFlagIndex < versionValueIndex);
                Assert.True(packageNameIndex < projectFlagIndex);
                Assert.True(projectFlagIndex < projectValueIndex);
                Assert.True(versionValueIndex < noRestoreIndex);
 
                // Should NOT contain --file, --source, or the @version format
                Assert.DoesNotContain("--file", args);
                Assert.DoesNotContain("--source", args);
                Assert.DoesNotContain("Aspire.Hosting.Redis@9.2.0", args);
            },
            0);
 
        var exitCode = await runner.AddPackageAsync(
            projectFile,
            "Aspire.Hosting.Redis",
            "9.2.0",
            nugetSource: null,
            noRestore: true,
            options,
            CancellationToken.None
            );
 
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task AddPackageAsyncWithSourceAndNoRestoreHasArgumentSourceAndNoRestore()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(projectFile.FullName, "<Project></Project>");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var options = new ProcessInvocationOptions();
 
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (args, _, _, _) =>
            {
                // Verify arguments are correct for .csproj file with --source and --no-restore
                Assert.Contains("package", args);
                Assert.Contains("add", args);
                Assert.Contains("Aspire.Hosting.Redis", args);
                Assert.Contains("--version", args);
                Assert.Contains("9.2.0", args);
                Assert.Contains("--project", args);
                Assert.Contains(projectFile.FullName, args);
                Assert.Contains("--source", args);
                Assert.Contains("https://api.nuget.org/v3/index.json", args);
                Assert.Contains("--no-restore", args);
 
                // Verify the order: package add PackageName --version Version --project ProjectFile --no-restore --source Source
                var packageIndex = Array.IndexOf(args, "package");
                var addIndex = Array.IndexOf(args, "add");
                var packageNameIndex = Array.IndexOf(args, "Aspire.Hosting.Redis");
                var versionFlagIndex = Array.IndexOf(args, "--version");
                var versionValueIndex = Array.IndexOf(args, "9.2.0");
                var projectFlagIndex = Array.IndexOf(args, "--project");
                var projectValueIndex = Array.IndexOf(args, projectFile.FullName);
                var sourceFlagIndex = Array.IndexOf(args, "--source");
                var sourceValueIndex = Array.IndexOf(args, "https://api.nuget.org/v3/index.json");
                var noRestoreIndex = Array.IndexOf(args, "--no-restore");
 
                Assert.True(packageIndex < addIndex);
                Assert.True(addIndex < packageNameIndex);
                Assert.True(packageNameIndex < versionFlagIndex);
                Assert.True(versionFlagIndex < versionValueIndex);
                Assert.True(packageNameIndex < projectFlagIndex);
                Assert.True(projectFlagIndex < projectValueIndex);
                Assert.True(packageNameIndex < noRestoreIndex);
                Assert.True(packageNameIndex < sourceFlagIndex);
                Assert.True(sourceFlagIndex < sourceValueIndex);
 
                // Should NOT contain --file or the @version format
                Assert.DoesNotContain("--file", args);
                Assert.DoesNotContain("Aspire.Hosting.Redis@9.2.0", args);
            },
            0);
 
        var exitCode = await runner.AddPackageAsync(
            projectFile,
            "Aspire.Hosting.Redis",
            "9.2.0",
            "https://api.nuget.org/v3/index.json", // provide source, should use --source
            noRestore: true,
            options,
            CancellationToken.None
            );
 
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task GetSolutionProjectsAsync_ParsesOutputCorrectly()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        // Create a fake solution file
        var solutionFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Test.sln"));
        await File.WriteAllTextAsync(solutionFile.FullName, "Not a real solution file.");
 
        // Create project files
        var project1Dir = workspace.WorkspaceRoot.CreateSubdirectory("Project1");
        var project1File = new FileInfo(Path.Combine(project1Dir.FullName, "Project1.csproj"));
        await File.WriteAllTextAsync(project1File.FullName, "Not a real project file.");
 
        var project2Dir = workspace.WorkspaceRoot.CreateSubdirectory("Project2");
        var project2File = new FileInfo(Path.Combine(project2Dir.FullName, "Project2.csproj"));
        await File.WriteAllTextAsync(project2File.FullName, "Not a real project file.");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var options = new ProcessInvocationOptions
        {
            StandardOutputCallback = (line) => outputHelper.WriteLine($"stdout: {line}")
        };
 
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (args, _, _, invocationOptions) =>
            {
                // Simulate dotnet sln list output
                invocationOptions.StandardOutputCallback?.Invoke("Project(s)");
                invocationOptions.StandardOutputCallback?.Invoke("----------");
                invocationOptions.StandardOutputCallback?.Invoke($"Project1{Path.DirectorySeparatorChar}Project1.csproj");
                invocationOptions.StandardOutputCallback?.Invoke($"Project2{Path.DirectorySeparatorChar}Project2.csproj");
            },
            0);
 
        var (exitCode, projects) = await runner.GetSolutionProjectsAsync(solutionFile, options, CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(0, exitCode);
        Assert.Equal(2, projects.Count);
        Assert.Contains(projects, p => p.Name == "Project1.csproj");
        Assert.Contains(projects, p => p.Name == "Project2.csproj");
    }
 
    [Fact]
    public async Task AddProjectReferenceAsync_ExecutesCorrectCommand()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file.");
 
        var referencedProject = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Service.csproj"));
        await File.WriteAllTextAsync(referencedProject.FullName, "Not a real project file.");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var options = new ProcessInvocationOptions();
 
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (args, _, _, _) =>
            {
                Assert.Contains("add", args);
                Assert.Contains(projectFile.FullName, args);
                Assert.Contains("reference", args);
                Assert.Contains(referencedProject.FullName, args);
            },
            0);
 
        var exitCode = await runner.AddProjectReferenceAsync(projectFile, referencedProject, options, CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task RunAsyncAppliesNoLaunchProfileForSingleFileAppHost()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs"));
        await File.WriteAllTextAsync(appHostFile.FullName, "// Single-file AppHost");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var options = new ProcessInvocationOptions()
        {
            NoLaunchProfile = true
        };
 
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (args, _, _, _) =>
            {
                // For single-file .cs files, should include --no-launch-profile
                Assert.Collection(args,
                    arg => Assert.Equal("run", arg),
                    arg => Assert.Equal("--no-launch-profile", arg),
                    arg => Assert.Equal($"/p:{KnownConfigNames.SuppressCliRunHook}=true", arg),
                    arg => Assert.Equal("--file", arg),
                    arg => Assert.Equal(appHostFile.FullName, arg),
                    arg => Assert.Equal("--", arg)
                );
            },
            0);
 
        var exitCode = await runner.RunAsync(
            projectFile: appHostFile,
            watch: false,
            noBuild: false,
            noRestore: false,
            args: [],
            env: new Dictionary<string, string>(),
            null,
            options,
            CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task RunAsyncDoesNotIncludeNoLaunchProfileForSingleFileAppHostWhenNotSpecified()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs"));
        await File.WriteAllTextAsync(appHostFile.FullName, "// Single-file AppHost");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var options = new ProcessInvocationOptions()
        {
            NoLaunchProfile = false
        };
 
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (args, _, _, _) =>
            {
                // For single-file .cs files, should NOT include --no-launch-profile when false
                Assert.Collection(args,
                    arg => Assert.Equal("run", arg),
                    arg => Assert.Equal($"/p:{KnownConfigNames.SuppressCliRunHook}=true", arg),
                    arg => Assert.Equal("--file", arg),
                    arg => Assert.Equal(appHostFile.FullName, arg),
                    arg => Assert.Equal("--", arg)
                );
            },
            0);
 
        var exitCode = await runner.RunAsync(
            projectFile: appHostFile,
            watch: false,
            noBuild: false,
            noRestore: false,
            args: [],
            env: new Dictionary<string, string>(),
            null,
            options,
            CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task RunAsyncSuppressesCliRunHookForProjectAppHost()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file.");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        Dictionary<string, string>? capturedEnv = null;
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (_, env, _, _) =>
            {
                capturedEnv = env?.ToDictionary();
            },
            0);
 
        var exitCode = await runner.RunAsync(
            projectFile: projectFile,
            watch: false,
            noBuild: false,
            noRestore: false,
            args: [],
            env: new Dictionary<string, string>(),
            null,
            new ProcessInvocationOptions(),
            CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(0, exitCode);
        Assert.NotNull(capturedEnv);
        Assert.Equal("true", capturedEnv[KnownConfigNames.SuppressCliRunHook]);
    }
 
    [Fact]
    public async Task RunAsyncSuppressesCliRunHookForSingleFileAppHost()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs"));
        await File.WriteAllTextAsync(appHostFile.FullName, "// Single-file AppHost");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        Dictionary<string, string>? capturedEnv = null;
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (_, env, _, _) =>
            {
                capturedEnv = env?.ToDictionary();
            },
            0);
 
        var exitCode = await runner.RunAsync(
            projectFile: appHostFile,
            watch: false,
            noBuild: false,
            noRestore: false,
            args: [],
            env: new Dictionary<string, string>(),
            null,
            new ProcessInvocationOptions(),
            CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(0, exitCode);
        Assert.NotNull(capturedEnv);
        Assert.Equal("true", capturedEnv[KnownConfigNames.SuppressCliRunHook]);
    }
 
    [Fact]
    public async Task RunAsyncDoesNotIncludeNoBuildFlagForSingleFileAppHostWhenNoBuildIsTrue()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs"));
        await File.WriteAllTextAsync(appHostFile.FullName, "// Single-file AppHost");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (args, _, _, _) =>
            {
                Assert.Collection(args,
                    arg => Assert.Equal("run", arg),
                    arg => Assert.Equal($"/p:{KnownConfigNames.SuppressCliRunHook}=true", arg),
                    arg => Assert.Equal("--file", arg),
                    arg => Assert.Equal(appHostFile.FullName, arg),
                    arg => Assert.Equal("--", arg)
                );
                Assert.DoesNotContain("--no-build", args);
            },
            0);
 
        var exitCode = await runner.RunAsync(
            projectFile: appHostFile,
            watch: false,
            noBuild: true,
            noRestore: false,
            args: [],
            env: new Dictionary<string, string>(),
            null,
            new ProcessInvocationOptions(),
            CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task RunAsyncFiltersOutEmptyAndWhitespaceArguments()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file.");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        // Use watch=true and NoLaunchProfile=false to ensure some empty strings are generated
        var options = new ProcessInvocationOptions()
        {
            NoLaunchProfile = false,
            Debug = false
        };
 
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (args, _, _, _) =>
            {
                // Verify no empty or whitespace-only arguments exist
                foreach (var arg in args)
                {
                    Assert.False(string.IsNullOrWhiteSpace(arg), $"Found empty or whitespace argument in args: [{string.Join(", ", args)}]");
                }
            },
            0);
 
        var exitCode = await runner.RunAsync(
            projectFile: projectFile,
            watch: true, // This will generate empty strings for verboseSwitch when Debug=false
            noBuild: false,
            noRestore: false,
            args: [],
            env: new Dictionary<string, string>(),
            null,
            options,
            CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task RunAsyncFiltersOutEmptyArgumentsForSingleFileAppHost()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs"));
        await File.WriteAllTextAsync(appHostFile.FullName, "// Single-file AppHost");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var options = new ProcessInvocationOptions()
        {
            NoLaunchProfile = false // This will generate an empty string for noProfileSwitch
        };
 
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (args, _, _, _) =>
            {
                // Verify no empty or whitespace-only arguments exist in single-file AppHost scenario
                foreach (var arg in args)
                {
                    Assert.False(string.IsNullOrWhiteSpace(arg), $"Found empty or whitespace argument in args: [{string.Join(", ", args)}]");
                }
 
                // Ensure the correct arguments are present
                Assert.Collection(args,
                    arg => Assert.Equal("run", arg),
                    arg => Assert.Equal($"/p:{KnownConfigNames.SuppressCliRunHook}=true", arg),
                    arg => Assert.Equal("--file", arg),
                    arg => Assert.Equal(appHostFile.FullName, arg),
                    arg => Assert.Equal("--", arg)
                );
            },
            0);
 
        var exitCode = await runner.RunAsync(
            projectFile: appHostFile,
            watch: false,
            noBuild: false,
            noRestore: false,
            args: [],
            env: new Dictionary<string, string>(),
            null,
            options,
            CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task RunAsyncIncludesAllNonEmptyFlagsWhenEnabled()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file.");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var options = new ProcessInvocationOptions()
        {
            NoLaunchProfile = true,
            Debug = true
        };
 
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (args, _, _, _) =>
            {
                // With watch=true and Debug=true, should include --verbose
                Assert.Collection(args,
                    arg => Assert.Equal("watch", arg),
                    arg => Assert.Equal("--non-interactive", arg),
                    arg => Assert.Equal("--verbose", arg),
                    arg => Assert.Equal("--no-launch-profile", arg),
                    arg => Assert.Equal("--project", arg),
                    arg => Assert.Equal(projectFile.FullName, arg),
                    arg => Assert.Equal("--", arg)
                );
            },
            0);
 
        var exitCode = await runner.RunAsync(
            projectFile: projectFile,
            watch: true,
            noBuild: false,
            noRestore: false,
            args: [],
            env: new Dictionary<string, string>(),
            null,
            options,
            CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task RunAsyncCorrectlyHandlesWatchWithoutDebug()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file.");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var options = new ProcessInvocationOptions()
        {
            NoLaunchProfile = true,
            Debug = false // No debug, so no --verbose
        };
 
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (args, _, _, _) =>
            {
                // With watch=true but Debug=false, should NOT include --verbose
                Assert.Collection(args,
                    arg => Assert.Equal("watch", arg),
                    arg => Assert.Equal("--non-interactive", arg),
                    arg => Assert.Equal("--no-launch-profile", arg),
                    arg => Assert.Equal("--project", arg),
                    arg => Assert.Equal(projectFile.FullName, arg),
                    arg => Assert.Equal("--", arg)
                );
            },
            0);
 
        var exitCode = await runner.RunAsync(
            projectFile: projectFile,
            watch: true,
            noBuild: false,
            noRestore: false,
            args: [],
            env: new Dictionary<string, string>(),
            null,
            options,
            CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task GetProjectItemsAndPropertiesAsync_UsesBuild_ForSingleFileAppHost()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs"));
        await File.WriteAllTextAsync(appHostFile.FullName, "// Single-file AppHost");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var options = new ProcessInvocationOptions();
 
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (args, _, _, invocationOptions) =>
            {
                // Verify that "build" command is used for single-file app host
                Assert.Contains("build", args);
                Assert.DoesNotContain("msbuild", args);
 
                // Provide valid JSON output
                invocationOptions.StandardOutputCallback?.Invoke("{\"Properties\":{\"MSBuildVersion\":\"17.0.0\",\"AspireHostingSDKVersion\":\"9.0.0\"},\"Items\":{\"PackageReference\":[]}}");
            },
            0);
 
        await runner.GetProjectItemsAndPropertiesAsync(
            appHostFile,
            ["PackageReference"],
            ["AspireHostingSDKVersion"],
            targets: [],
            options,
            CancellationToken.None
        );
    }
 
    [Fact]
    public async Task GetProjectItemsAndPropertiesAsyncSuppressesCliRunHook()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file.");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        Dictionary<string, string>? capturedEnv = null;
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (_, env, _, invocationOptions) =>
            {
                capturedEnv = env?.ToDictionary();
                invocationOptions.StandardOutputCallback?.Invoke("{\"Properties\":{\"MSBuildVersion\":\"17.0.0\",\"AspireHostingSDKVersion\":\"9.0.0\"},\"Items\":{\"PackageReference\":[]}}");
            },
            0);
 
        await runner.GetProjectItemsAndPropertiesAsync(
            projectFile,
            ["PackageReference"],
            ["AspireHostingSDKVersion"],
            targets: [],
            new ProcessInvocationOptions(),
            CancellationToken.None
        );
 
        Assert.NotNull(capturedEnv);
        Assert.Equal("true", capturedEnv[KnownConfigNames.SuppressCliRunHook]);
    }
 
    [Fact]
    public async Task GetAppHostInformationAsync_UsesAspireHostingAppHostPackageVersion()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file.");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (args, _, _, invocationOptions) =>
            {
                Assert.Contains("-getItem:PackageReference,AspireProjectOrPackageReference,PackageVersion", args);
                invocationOptions.StandardOutputCallback?.Invoke("""
                    {
                      "Properties": {
                        "MSBuildVersion": "17.0.0",
                        "IsAspireHost": "true"
                      },
                      "Items": {
                        "PackageReference": [
                          {
                            "Identity": "Aspire.Hosting.AppHost",
                            "Version": "13.0.0"
                          }
                        ]
                      }
                    }
                    """);
            },
            0);
 
        var result = await runner.GetAppHostInformationAsync(projectFile, new ProcessInvocationOptions(), CancellationToken.None);
 
        Assert.Equal(0, result.ExitCode);
        Assert.True(result.IsAspireHost);
        Assert.Equal("13.0.0", result.AspireHostingVersion);
    }
 
    [Fact]
    public async Task GetAppHostInformationAsync_UsesAspireHostingAppHostCentralPackageVersion()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file.");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (_, _, _, invocationOptions) =>
            {
                invocationOptions.StandardOutputCallback?.Invoke("""
                    {
                      "Properties": {
                        "MSBuildVersion": "17.0.0",
                        "IsAspireHost": "true"
                      },
                      "Items": {
                        "PackageVersion": [
                          {
                            "Identity": "Aspire.Hosting.AppHost",
                            "Version": "13.0.1"
                          }
                        ]
                      }
                    }
                    """);
            },
            0);
 
        var result = await runner.GetAppHostInformationAsync(projectFile, new ProcessInvocationOptions(), CancellationToken.None);
 
        Assert.Equal(0, result.ExitCode);
        Assert.True(result.IsAspireHost);
        Assert.Equal("13.0.1", result.AspireHostingVersion);
    }
 
    [Fact]
    public async Task GetProjectItemsAndPropertiesAsync_UsesMsBuild_ForCsProjFile()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file.");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var options = new ProcessInvocationOptions();
 
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (args, env, _, invocationOptions) =>
            {
                // Verify that "msbuild" command is used for .csproj files
                Assert.Contains("msbuild", args);
                Assert.DoesNotContain("build", args);
                Assert.NotNull(env);
                Assert.Equal("1", env["DOTNET_CLI_TELEMETRY_OPTOUT"]);
                Assert.Equal("1", env["DOTNET_CLI_WORKLOAD_UPDATE_NOTIFY_DISABLE"]);
 
                // Provide valid JSON output
                invocationOptions.StandardOutputCallback?.Invoke("{\"Properties\":{\"MSBuildVersion\":\"17.0.0\",\"AspireHostingSDKVersion\":\"9.0.0\"},\"Items\":{\"PackageReference\":[]}}");
            },
            0);
 
        await runner.GetProjectItemsAndPropertiesAsync(
            projectFile,
            ["PackageReference"],
            ["AspireHostingSDKVersion"],
            targets: [],
            options,
            CancellationToken.None
        );
    }
 
    [Fact]
    public async Task GetProjectItemsAndPropertiesAsync_EmitsTargetSwitch_WhenTargetsRequested()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file.");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var options = new ProcessInvocationOptions();
 
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (args, _, _, invocationOptions) =>
            {
                // The Run* properties need ComputeRunArguments to have executed before
                // -getProperty reads them, so the runner must add -t:ComputeRunArguments.
                Assert.Contains("-t:ComputeRunArguments;Build", args);
 
                invocationOptions.StandardOutputCallback?.Invoke("{\"Properties\":{\"MSBuildVersion\":\"17.0.0\",\"RunCommand\":\"/bin/AppHost\"},\"Items\":{}}");
            },
            0);
 
        var (exitCode, output) = await runner.GetProjectItemsAndPropertiesAsync(
            projectFile,
            items: [],
            properties: ["RunCommand"],
            targets: ["ComputeRunArguments", "Build"],
            options,
            CancellationToken.None
        );
 
        Assert.Equal(0, exitCode);
        Assert.NotNull(output);
    }
 
    [Fact]
    public async Task ComputeRunArgumentsPopulatesRunPropertiesForSdkProject()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "RunPropertiesApp"));
        var projectFile = new FileInfo(Path.Combine(projectDirectory.FullName, "RunPropertiesApp.csproj"));
        await File.WriteAllTextAsync(projectFile.FullName, """
            <Project Sdk="Microsoft.NET.Sdk">
              <PropertyGroup>
                <OutputType>Exe</OutputType>
                <TargetFramework>net10.0</TargetFramework>
                <StartArguments>--from-msbuild</StartArguments>
                <RunWorkingDirectory>relative-working-directory</RunWorkingDirectory>
              </PropertyGroup>
            </Project>
            """);
        await File.WriteAllTextAsync(Path.Combine(projectDirectory.FullName, "Program.cs"), "Console.WriteLine(\"Hello\");");
 
        var processStartInfo = new ProcessStartInfo(GetDotNetExecutablePath())
        {
            WorkingDirectory = projectDirectory.FullName,
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            UseShellExecute = false
        };
        processStartInfo.ArgumentList.Add("msbuild");
        processStartInfo.ArgumentList.Add(projectFile.FullName);
        processStartInfo.ArgumentList.Add("-restore");
        processStartInfo.ArgumentList.Add("-t:ComputeRunArguments");
        processStartInfo.ArgumentList.Add("-getProperty:RunCommand,RunArguments,RunWorkingDirectory");
        processStartInfo.ArgumentList.Add("-nologo");
 
        using var process = Process.Start(processStartInfo) ?? throw new InvalidOperationException("Failed to start dotnet msbuild.");
        var stdout = await process.StandardOutput.ReadToEndAsync(TestContext.Current.CancellationToken);
        var stderr = await process.StandardError.ReadToEndAsync(TestContext.Current.CancellationToken);
        await process.WaitForExitAsync(TestContext.Current.CancellationToken);
 
        Assert.Equal(0, process.ExitCode);
        using var jsonDocument = JsonDocument.Parse(stdout);
        var properties = jsonDocument.RootElement.GetProperty("Properties");
        Assert.False(string.IsNullOrWhiteSpace(properties.GetProperty("RunCommand").GetString()));
        Assert.Equal("--from-msbuild", properties.GetProperty("RunArguments").GetString());
        Assert.Equal(Path.Combine(projectDirectory.FullName, "relative-working-directory"), properties.GetProperty("RunWorkingDirectory").GetString());
        Assert.True(string.IsNullOrWhiteSpace(stderr));
    }
 
    [Fact]
    public async Task GetProjectItemsAndPropertiesAsync_OmitsTargetSwitch_WhenNoTargetsRequested()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file.");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var options = new ProcessInvocationOptions();
 
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (args, _, _, invocationOptions) =>
            {
                // No targets requested means no -t: switch should be added so MSBuild only
                // evaluates the project (the existing fast property/item probe behavior).
                Assert.DoesNotContain(args, a => a.StartsWith("-t:", StringComparison.Ordinal));
 
                invocationOptions.StandardOutputCallback?.Invoke("{\"Properties\":{\"MSBuildVersion\":\"17.0.0\",\"AspireHostingSDKVersion\":\"9.0.0\"},\"Items\":{\"PackageReference\":[]}}");
            },
            0);
 
        await runner.GetProjectItemsAndPropertiesAsync(
            projectFile,
            ["PackageReference"],
            ["AspireHostingSDKVersion"],
            targets: [],
            options,
            CancellationToken.None
        );
    }
 
    [Fact]
    public async Task SearchPackagesAsyncRetriesOnFailureAndSucceedsOnSecondAttempt()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
        var logger = provider.GetRequiredService<ILogger<DotNetCliRunner>>();
        var interactionService = provider.GetRequiredService<IInteractionService>();
 
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var (runner, executor) = DotNetCliRunnerTestHelper.CreateWithRetry(
            provider,
            executionContext,
            (attempt, options) =>
            {
                // First attempt fails, second succeeds
                if (attempt == 1)
                {
                    return (1, null);
                }
 
                return (0, "{\"version\":1,\"searchResult\":[{\"sourceName\":\"nuget.org\",\"packages\":[{\"id\":\"Aspire.Hosting.Redis\",\"latestVersion\":\"9.0.0\"}]}]}");
            },
            logger: logger
        );
 
        var options = new ProcessInvocationOptions { SuppressLogging = true };
 
        var result = await runner.SearchPackagesAsync(
            workspace.WorkspaceRoot,
            "Aspire.Hosting",
            exactMatch: false,
            prerelease: false,
            take: 100,
            skip: 0,
            nugetConfigFile: null,
            useCache: false,
            options,
            CancellationToken.None
        );
 
        Assert.Equal(0, result.ExitCode);
        Assert.NotNull(result.Packages);
        Assert.Equal(2, executor.AttemptCount);
    }
 
    [Fact]
    public async Task SearchPackagesAsyncRetriesMaxTimesAndReturnsFailure()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
        var logger = provider.GetRequiredService<ILogger<DotNetCliRunner>>();
        var interactionService = provider.GetRequiredService<IInteractionService>();
 
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var (runner, executor) = DotNetCliRunnerTestHelper.CreateWithRetry(
            provider,
            executionContext,
            (attempt, options) =>
            {
                // Always fail
                return (1, null);
            },
            logger: logger
        );
 
        var options = new ProcessInvocationOptions { SuppressLogging = true };
 
        var result = await runner.SearchPackagesAsync(
            workspace.WorkspaceRoot,
            "Aspire.Hosting",
            exactMatch: false,
            prerelease: false,
            take: 100,
            skip: 0,
            nugetConfigFile: null,
            useCache: false,
            options,
            CancellationToken.None
        );
 
        Assert.Equal(1, result.ExitCode);
        Assert.Null(result.Packages);
        Assert.Equal(3, executor.AttemptCount); // Should have attempted 3 times
    }
 
    [Fact]
    public async Task SearchPackagesAsyncSucceedsOnFirstAttemptWithoutRetry()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
        var logger = provider.GetRequiredService<ILogger<DotNetCliRunner>>();
        var interactionService = provider.GetRequiredService<IInteractionService>();
 
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var (runner, executor) = DotNetCliRunnerTestHelper.CreateWithRetry(
            provider,
            executionContext,
            (attempt, options) =>
            {
                // Always succeed
                return (0, "{\"version\":1,\"searchResult\":[{\"sourceName\":\"nuget.org\",\"packages\":[{\"id\":\"Aspire.Hosting.Redis\",\"latestVersion\":\"9.0.0\"}]}]}");
            },
            logger: logger
        );
 
        var options = new ProcessInvocationOptions { SuppressLogging = true };
 
        var result = await runner.SearchPackagesAsync(
            workspace.WorkspaceRoot,
            "Aspire.Hosting",
            exactMatch: false,
            prerelease: false,
            take: 100,
            skip: 0,
            nugetConfigFile: null,
            useCache: false,
            options,
            CancellationToken.None
        );
 
        Assert.Equal(0, result.ExitCode);
        Assert.NotNull(result.Packages);
        Assert.Equal(1, executor.AttemptCount); // Should have attempted only once
    }
 
    [Fact]
    public async Task SearchPackagesAsync_WithExactMatch_UsesExactMatchFlagWithoutPaginationArguments()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var options = new ProcessInvocationOptions { SuppressLogging = true };
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (args, _, _, invocationOptions) =>
            {
                Assert.Contains("package", args);
                Assert.Contains("search", args);
                Assert.Contains("Aspire.Hosting.Redis", args);
                Assert.Contains("--exact-match", args);
                Assert.DoesNotContain("--take", args);
                Assert.DoesNotContain("--skip", args);
 
                invocationOptions.StandardOutputCallback?.Invoke(
                    """
                    {"version":1,"searchResult":[{"sourceName":"nuget.org","packages":[{"id":"Aspire.Hosting.Redis","latestVersion":"13.3.0"}]}]}
                    """);
            },
            0);
 
        var result = await runner.SearchPackagesAsync(
            workspace.WorkspaceRoot,
            "Aspire.Hosting.Redis",
            exactMatch: true,
            prerelease: false,
            take: 100,
            skip: 200,
            nugetConfigFile: null,
            useCache: false,
            options,
            CancellationToken.None);
 
        Assert.Equal(0, result.ExitCode);
        Assert.Collection(
            result.Packages!,
            package =>
            {
                Assert.Equal("Aspire.Hosting.Redis", package.Id);
                Assert.Equal("13.3.0", package.Version);
            });
    }
 
    [Fact]
    public async Task InstallTemplateAsync_UsesLocalPackagePathForLocalFolderSource()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var packageVersion = "13.3.0-local.1";
        var packagesDirectory = workspace.WorkspaceRoot.CreateSubdirectory("packages");
        var packagePath = Path.Combine(packagesDirectory.FullName, $"Aspire.ProjectTemplates.{packageVersion}.nupkg");
        await File.WriteAllTextAsync(packagePath, string.Empty);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        var provider = services.BuildServiceProvider();
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var invocationCount = 0;
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (args, _, workingDirectory, _) =>
            {
                invocationCount++;
                Assert.Equal(workspace.WorkspaceRoot.FullName, workingDirectory.FullName);
                Assert.Equal("new", args[0]);
 
                switch (invocationCount)
                {
                    case 1:
                        Assert.Equal("uninstall", args[1]);
                        Assert.Equal("Aspire.ProjectTemplates", args[2]);
                        Assert.DoesNotContain("--force", args);
                        Assert.DoesNotContain("--nuget-source", args);
                        break;
                    case 2:
                        Assert.Equal("install", args[1]);
                        Assert.Equal(packagePath, args[2]);
                        Assert.DoesNotContain("--force", args);
                        Assert.DoesNotContain("--nuget-source", args);
                        break;
                    default:
                        Assert.Fail($"Unexpected dotnet invocation {invocationCount}.");
                        break;
                }
            },
            0);
 
        var result = await runner.InstallTemplateAsync(
            "Aspire.ProjectTemplates",
            packageVersion,
            nugetConfigFile: null,
            nugetSource: "packages",
            force: true,
            new ProcessInvocationOptions(),
            CancellationToken.None);
 
        Assert.Equal(0, result.ExitCode);
        Assert.Equal(packageVersion, result.TemplateVersion);
        Assert.Equal(2, invocationCount);
    }
 
    [Theory]
    [InlineData("Success: Aspire.ProjectTemplates@13.2.0-preview.1.26101.12 installed the following templates:", true, "13.2.0-preview.1.26101.12")] // New .NET 10.0 SDK format with @ separator
    [InlineData("Success: Aspire.ProjectTemplates::13.2.0-preview.1.26101.12 installed the following templates:", true, "13.2.0-preview.1.26101.12")] // Old SDK format with :: separator
    [InlineData("Some other output", false, null)] // Missing success line
    [InlineData("Success: Aspire.ProjectTemplates installed the following templates:", false, null)] // Invalid format without version separator
    public void TryParsePackageVersionFromStdout_ParsesCorrectly(string stdout, bool expectedResult, string? expectedVersion)
    {
        // Act
        var result = DotNetCliRunner.TryParsePackageVersionFromStdout(stdout, out var version);
 
        // Assert
        Assert.Equal(expectedResult, result);
        Assert.Equal(expectedVersion, version);
    }
 
    [Fact]
    public async Task RunAsyncIncludesNoBuildFlagWhenNoBuildIsTrue()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file.");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var options = new ProcessInvocationOptions();
 
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (args, env, _, _) =>
            {
                // Verify that --no-build is included when noBuild is true
                Assert.Contains("run", args);
                Assert.Contains("--no-build", args);
            },
            0);
 
        var exitCode = await runner.RunAsync(
            projectFile: projectFile,
            watch: false,
            noBuild: true, // This should add --no-build
            noRestore: false,
            args: ["--operation", "inspect"],
            env: new Dictionary<string, string>(),
            null,
            options,
            CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task RunAsyncDoesNotIncludeNoBuildFlagWhenNoBuildIsFalse()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file.");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var options = new ProcessInvocationOptions();
 
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (args, env, _, _) =>
            {
                // Verify that --no-build is NOT included when noBuild is false
                Assert.Contains("run", args);
                Assert.DoesNotContain("--no-build", args);
            },
            0);
 
        var exitCode = await runner.RunAsync(
            projectFile: projectFile,
            watch: false,
            noBuild: false, // This should NOT add --no-build
            noRestore: false,
            args: ["--operation", "inspect"],
            env: new Dictionary<string, string>(),
            null,
            options,
            CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task RunAsyncIncludesNoRestoreFlagWhenNoRestoreIsTrueAndNoBuildIsFalse()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file.");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var options = new ProcessInvocationOptions();
 
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (args, env, _, _) =>
            {
                // Verify that --no-restore is included when noRestore is true and noBuild is false
                Assert.Contains("run", args);
                Assert.Contains("--no-restore", args);
                Assert.DoesNotContain("--no-build", args);
            },
            0);
 
        var exitCode = await runner.RunAsync(
            projectFile: projectFile,
            watch: false,
            noBuild: false,
            noRestore: true, // This should add --no-restore
            args: ["--operation", "inspect"],
            env: new Dictionary<string, string>(),
            null,
            options,
            CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task RunAsyncDoesNotIncludeNoRestoreFlagWhenNoBuildIsTrue()
    {
        // --no-build implies --no-restore, so we should not include --no-restore when --no-build is specified
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file.");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var options = new ProcessInvocationOptions();
 
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (args, env, _, _) =>
            {
                // Verify that --no-restore is NOT included when noBuild is true (because --no-build implies --no-restore)
                Assert.Contains("run", args);
                Assert.Contains("--no-build", args);
                Assert.DoesNotContain("--no-restore", args);
            },
            0);
 
        var exitCode = await runner.RunAsync(
            projectFile: projectFile,
            watch: false,
            noBuild: true, // --no-build implies --no-restore
            noRestore: true, // This should be ignored because noBuild is true
            args: ["--operation", "inspect"],
            env: new Dictionary<string, string>(),
            null,
            options,
            CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task RunAsyncSetsCliLogFilePathEnvironmentVariable()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(projectFile.FullName, "Not a real project file.");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var options = new ProcessInvocationOptions();
 
        Dictionary<string, string>? capturedEnv = null;
        var executionContext = CreateExecutionContext(workspace.WorkspaceRoot);
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (args, env, _, _) =>
            {
                capturedEnv = env?.ToDictionary();
            },
            0);
 
        var exitCode = await runner.RunAsync(
            projectFile: projectFile,
            watch: false,
            noBuild: true,
            noRestore: true,
            args: ["--operation", "inspect"],
            env: new Dictionary<string, string>(),
            null,
            options,
            CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(0, exitCode);
        Assert.NotNull(capturedEnv);
        Assert.Equal(executionContext.LogFilePath, capturedEnv[KnownConfigNames.CliLogFilePath]);
    }
 
    private sealed class ExitedProcessExecution(
        IReadOnlyList<string> arguments,
        IDictionary<string, string>? environmentVariables,
        int exitCode) : IProcessExecution
    {
        private bool _started;
 
        public string FileName => "dotnet";
 
        public IReadOnlyList<string> Arguments { get; } = arguments;
 
        public IReadOnlyDictionary<string, string?> EnvironmentVariables { get; } = environmentVariables?.ToDictionary(kvp => kvp.Key, kvp => (string?)kvp.Value)
            ?? new Dictionary<string, string?>();
 
        public int ProcessId => Environment.ProcessId;
 
        public DateTimeOffset? StartTime => DateTimeOffset.UtcNow;
 
        public bool HasExited => _started;
 
        public int ExitCode => exitCode;
 
        public Task<bool> StartAsync(CancellationToken cancellationToken)
        {
            cancellationToken.ThrowIfCancellationRequested();
            _started = true;
            return Task.FromResult(true);
        }
 
        public Task<int> WaitForExitAsync(CancellationToken cancellationToken) => Task.FromResult(exitCode);
 
        public void Kill(bool entireProcessTree)
        {
        }
 
        public ValueTask DisposeAsync() => ValueTask.CompletedTask;
    }
}