File: Commands\RunCommandTests.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.Diagnostics;
using System.Globalization;
using System.Net.Sockets;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Commands;
using Aspire.Cli.Configuration;
using Aspire.Cli.Diagnostics;
using Aspire.Cli.DotNet;
using Aspire.Cli.Interaction;
using Aspire.Cli.NuGet;
using Aspire.Cli.Profiling;
using Aspire.Cli.Projects;
using Aspire.Cli.Resources;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Aspire.Cli.Utils;
using Aspire.Hosting;
using Aspire.Hosting.Utils;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Tests.Telemetry;
using Aspire.Shared.UserSecrets;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using Spectre.Console;
using StreamJsonRpc;
 
namespace Aspire.Cli.Tests.Commands;
 
public class RunCommandTests(ITestOutputHelper outputHelper)
{
    [Fact]
    public async Task RunCommandWithHelpArgumentReturnsZero()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("run --help");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(0, exitCode);
    }
 
    [Theory]
    [InlineData("--launch-profile")]
    [InlineData("-lp")]
    public void RunCommand_ParsesLaunchProfileOption(string optionName)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse(["run", optionName, "E2E"]);
 
        Assert.Empty(result.Errors);
        Assert.Equal("E2E", result.GetValue(AppHostLauncher.s_launchProfileOption));
        Assert.Empty(result.UnmatchedTokens);
    }
 
    [Fact]
    public async Task RunCommand_RejectsLaunchProfileForUnsupportedAppHostType()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = CreateAppHostFile(workspace);
        var interactionService = new TestInteractionService();
        var projectLocator = new TestProjectLocator
        {
            UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) =>
                Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile]))
        };
        var projectFactory = new TestAppHostProjectFactory
        {
            DisplayName = "TypeScript (Node.js)",
            SupportsLaunchProfiles = false
        };
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => interactionService;
            options.ProjectLocatorFactory = _ => projectLocator;
            options.AppHostProjectFactory = _ => projectFactory;
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse(["run", "--apphost", appHostFile.FullName, "--launch-profile", "E2E"]);
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
        Assert.Contains(
            string.Format(CultureInfo.CurrentCulture, SharedCommandStrings.LaunchProfileNotSupported, projectFactory.DisplayName),
            interactionService.DisplayedErrors);
    }
 
    [Fact]
    public async Task RunCommand_DetachedRejectsLaunchProfileForUnsupportedAppHostBeforeStoppingOrLaunching()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = CreateAppHostFile(workspace);
        var interactionService = new TestInteractionService();
        var projectLocator = new TestProjectLocator
        {
            UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) =>
                Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile]))
        };
        var projectFactory = new TestAppHostProjectFactory
        {
            DisplayName = "TypeScript (Node.js)",
            SupportsLaunchProfiles = false
        };
        var processFactory = new TestProcessExecutionFactory
        {
            DefaultExitCode = CliExitCodes.FailedToDotnetRunAppHost
        };
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => interactionService;
            options.ProjectLocatorFactory = _ => projectLocator;
            options.AppHostProjectFactory = _ => projectFactory;
        });
        services.Replace(ServiceDescriptor.Singleton<IProcessExecutionFactory>(processFactory));
        using var provider = services.BuildServiceProvider();
        var executionContext = provider.GetRequiredService<CliExecutionContext>();
        var socketPath = CreateMatchingSocketFile(appHostFile, executionContext.HomeDirectory);
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse(["run", "--detach", "--apphost", appHostFile.FullName, "--launch-profile", "E2E"]);
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
        Assert.True(File.Exists(socketPath), "Validation should happen before existing AppHost instances are stopped.");
        Assert.Equal(0, processFactory.AttemptCount);
        Assert.Contains(
            string.Format(CultureInfo.CurrentCulture, SharedCommandStrings.LaunchProfileNotSupported, projectFactory.DisplayName),
            interactionService.DisplayedErrors);
    }
 
    [Fact]
    public async Task RunCommand_MissingExplicitLaunchProfileFallsBackToDotNet()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = CreateAppHostFile(workspace);
        var propertiesDirectory = Directory.CreateDirectory(Path.Combine(appHostFile.DirectoryName!, "Properties"));
        File.WriteAllText(Path.Combine(propertiesDirectory.FullName, "launchSettings.json"), """
            {
              "profiles": {
                "playwright": {
                  "commandName": "Project"
                }
              }
            }
            """);
        var interactionService = new TestInteractionService();
        var buildCalled = false;
        var runner = new TestDotNetCliRunner
        {
            BuildAsyncCallback = (_, _, _, _) =>
            {
                buildCalled = true;
                return 1;
            }
        };
        var projectLocator = new TestProjectLocator
        {
            UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) =>
                Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile]))
        };
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => interactionService;
            options.ProjectLocatorFactory = _ => projectLocator;
            options.DotNetCliRunnerFactory = _ => runner;
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse(["run", "--apphost", appHostFile.FullName, "--launch-profile", "missing"]);
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.FailedToBuildArtifacts, exitCode);
        Assert.True(buildCalled);
    }
 
    [Theory]
    [InlineData("""{ "commandName": "Executable", "executablePath": "custom-tool" }""")]
    [InlineData("true")]
    public async Task RunCommand_ExistingUnsupportedOrMalformedLaunchProfileFallsBackToDotNet(string profile)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = CreateAppHostFile(workspace);
        var propertiesDirectory = Directory.CreateDirectory(Path.Combine(appHostFile.DirectoryName!, "Properties"));
        File.WriteAllText(Path.Combine(propertiesDirectory.FullName, "launchSettings.json"), $$"""
            {
              "profiles": {
                "container": {{profile}}
              }
            }
            """);
        var buildCalled = false;
        var runner = new TestDotNetCliRunner
        {
            BuildAsyncCallback = (_, _, _, _) =>
            {
                buildCalled = true;
                return 1;
            }
        };
        var projectLocator = new TestProjectLocator
        {
            UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) =>
                Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile]))
        };
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = _ => projectLocator;
            options.DotNetCliRunnerFactory = _ => runner;
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse(["run", "--apphost", appHostFile.FullName, "--launch-profile", "container"]);
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.FailedToBuildArtifacts, exitCode);
        Assert.True(buildCalled);
    }
 
    [Fact]
    public async Task RunCommand_PassesSelectedLaunchProfileToProjectContext()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = CreateAppHostFile(workspace);
        var projectLocator = new TestProjectLocator
        {
            UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) =>
                Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile]))
        };
        string? launchProfile = null;
        var projectFactory = new TestAppHostProjectFactory
        {
            RunAsyncCallback = (context, _) =>
            {
                launchProfile = context.LaunchProfile;
                context.BuildCompletionSource?.TrySetResult(false);
                return Task.FromResult(42);
            }
        };
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = _ => projectLocator;
            options.AppHostProjectFactory = _ => projectFactory;
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse(["run", "--apphost", appHostFile.FullName, "--launch-profile", "E2E"]);
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(42, exitCode);
        Assert.Equal("E2E", launchProfile);
    }
 
    [Fact]
    public async Task RunCommand_RejectsInvalidStartupTimeoutEnvironmentVariable()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var interactionService = new TestInteractionService();
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => interactionService;
            options.ConfigurationCallback += config =>
            {
                config[CliConfigNames.AppHostStartupTimeout] = "0";
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("run");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
        Assert.Equal(
            string.Format(CultureInfo.CurrentCulture, RunCommandStrings.InvalidAppHostStartupTimeoutEnvironmentVariable, CliConfigNames.AppHostStartupTimeout),
            Assert.Single(interactionService.DisplayedErrors));
    }
 
    [Fact]
    public async Task RunCommand_WhenAppHostStartupTimesOut_DisplaysTimeoutGuidance()
    {
        var interactionService = new TestInteractionService();
        var runCancellationObserved = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
 
        var runnerFactory = (IServiceProvider sp) =>
        {
            var runner = new TestDotNetCliRunner();
            runner.BuildAsyncCallback = (projectFile, noRestore, options, ct) => 0;
            runner.GetAppHostInformationAsyncCallback = (projectFile, options, ct) => (0, true, VersionHelper.GetDefaultTemplateVersion());
            runner.RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, ct) =>
            {
                try
                {
                    await Task.Delay(Timeout.InfiniteTimeSpan, ct);
                }
                catch (OperationCanceledException) when (ct.IsCancellationRequested)
                {
                    await Task.Delay(50, CancellationToken.None);
                    runCancellationObserved.SetResult();
                    throw;
                }
 
                return 0;
            };
 
            return runner;
        };
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => interactionService;
            options.ProjectLocatorFactory = _ => new TestProjectLocator();
            options.DotNetCliRunnerFactory = runnerFactory;
            options.ConfigurationCallback += config =>
            {
                config[CliConfigNames.AppHostStartupTimeout] = "1";
            };
        });
 
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("run");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.FailedToDotnetRunAppHost, exitCode);
        Assert.Contains(
            string.Format(CultureInfo.CurrentCulture, RunCommandStrings.TimeoutWaitingForAppHost, 1, CliConfigNames.AppHostStartupTimeout),
            interactionService.DisplayedErrors);
        Assert.True(runCancellationObserved.Task.IsCompletedSuccessfully);
    }
 
    [Fact]
    public async Task RunCommand_WhenCancelledDuringStartupTimeout_ExitsWithoutWaitingForFullTimeout()
    {
        // Verifies that when Ctrl+C fires (cancellationToken) during startup, the command exits
        // promptly rather than blocking for the 5-second CancelAppHostStartupAsync timeout.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        using var cts = new CancellationTokenSource();
        var interactionService = new TestInteractionService();
        var buildCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
 
        var appHostDir = workspace.WorkspaceRoot.CreateSubdirectory("AppHost");
        var appHostFile = new FileInfo(Path.Combine(appHostDir.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(appHostFile.FullName, "<Project />");
 
        var projectLocator = new TestProjectLocator
        {
            UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) =>
                Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile]))
        };
 
        var projectFactory = new TestAppHostProjectFactory
        {
            RunAsyncCallback = async (context, _) =>
            {
                context.BuildCompletionSource?.TrySetResult(true);
                buildCompleted.SetResult();
 
                // Never signal BackchannelCompletionSource and ignore cancellation to
                // simulate a hung AppHost process.
                await Task.Delay(TimeSpan.FromSeconds(30), CancellationToken.None);
                return 0;
            }
        };
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => interactionService;
            options.ProjectLocatorFactory = _ => projectLocator;
            options.AppHostProjectFactory = _ => projectFactory;
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse($"run --apphost {appHostFile.FullName}");
 
        var pendingRun = result.InvokeAsync(cancellationToken: cts.Token);
 
        // Cancel after build completes to simulate Ctrl+C during startup.
        await buildCompleted.Task.DefaultTimeout();
        cts.Cancel();
 
        var stopwatch = Stopwatch.StartNew();
        var exitCode = await pendingRun.DefaultTimeout();
        stopwatch.Stop();
 
        // Without the cancellationToken plumbing, this would block for the full 5-second
        // CancelAppHostStartupAsync timeout. With the fix, it exits promptly.
        Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(3), $"Expected prompt exit after Ctrl+C, but took {stopwatch.Elapsed}.");
        Assert.Equal(CliExitCodes.Success, exitCode);
    }
 
    [Fact]
    public async Task RunCommand_DetachedChild_WhenLauncherDiesBeforeReadiness_CancelsRun()
    {
        // End-to-end coverage for the detached launcher-death wiring: a detached child CLI
        // (ASPIRE_CLI_RUN_DETACHED) must watch the foreground launcher (ASPIRE_LAUNCHER_PID/STARTED) and,
        // if the launcher dies before the AppHost reaches readiness, cancel the run so the AppHost tree is
        // torn down instead of leaking. This exercises IsDetachedStartChild() ->
        // LauncherLivenessMonitor.StartIfConfigured -> run cancellation that the isolated monitor unit
        // tests do not cover.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var runCancellationObserved = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var buildCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
 
        var appHostDir = workspace.WorkspaceRoot.CreateSubdirectory("AppHost");
        var appHostFile = new FileInfo(Path.Combine(appHostDir.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(appHostFile.FullName, "<Project />", TestContext.Current.CancellationToken);
 
        var projectLocator = new TestProjectLocator
        {
            UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) =>
                Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile]))
        };
 
        var projectFactory = new TestAppHostProjectFactory
        {
            RunAsyncCallback = async (context, ct) =>
            {
                context.BuildCompletionSource?.TrySetResult(true);
                buildCompleted.SetResult();
 
                try
                {
                    // Block during startup without ever signaling the backchannel, so the only thing that
                    // can end the run is the launcher-death watchdog cancelling the run token.
                    await Task.Delay(Timeout.InfiniteTimeSpan, ct);
                }
                catch (OperationCanceledException) when (ct.IsCancellationRequested)
                {
                    runCancellationObserved.SetResult();
                    throw;
                }
 
                return 0;
            }
        };
 
        // A real, short-lived process stands in for the foreground launcher that spawned this detached
        // child. The child watches it via ASPIRE_LAUNCHER_PID/STARTED and must react to its death.
        using var launcher = TestProcesses.StartLongRunning();
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = _ => projectLocator;
            options.AppHostProjectFactory = _ => projectFactory;
            options.ConfigurationCallback += config =>
            {
                config[KnownConfigNames.CliRunDetached] = "true";
                config[KnownConfigNames.CliLauncherProcessId] = launcher.Id.ToString(CultureInfo.InvariantCulture);
                config[KnownConfigNames.CliLauncherProcessStarted] = GetProcessStartTimeUnixMilliseconds(launcher).ToString(CultureInfo.InvariantCulture);
            };
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse($"run --apphost {appHostFile.FullName}");
 
        var pendingRun = result.InvokeAsync(cancellationToken: TestContext.Current.CancellationToken);
 
        // Once build completes the child is in the startup window with the launcher watchdog armed.
        // Killing the launcher now must cancel the run rather than leak the AppHost.
        await buildCompleted.Task.DefaultTimeout();
        launcher.Kill(entireProcessTree: true);
        launcher.WaitForExit();
 
        // The monitor polls roughly once a second; allow generous margin for CI contention.
        await runCancellationObserved.Task.WaitAsync(TimeSpan.FromSeconds(30));
        var exitCode = await pendingRun.DefaultTimeout();
 
        Assert.True(runCancellationObserved.Task.IsCompletedSuccessfully);
        Assert.Equal(CliExitCodes.FailedToDotnetRunAppHost, exitCode);
    }
 
    [Fact]
    public async Task RunCommand_DetachedChild_WhenLauncherDiesAfterBackchannelEstablished_DoesNotCancelRun()
    {
        // Regression test for the detached-start happy-path race (PR #18566 scenario 2): once the
        // child<->AppHost backchannel is established the launcher watchdog must be disarmed, so the
        // foreground launcher exiting normally (right after it observes readiness) does NOT tear the
        // detached AppHost down. Before the fix the monitor was only disarmed after full readiness, so a
        // launcher exit during GetDashboardUrlsAsync / the early-exit observation window cancelled a
        // healthy run.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var interactionService = new TestInteractionService();
 
        // Drive the launcher watchdog off a fake clock so the "monitor stays disarmed" guarantee can be
        // observed deterministically. After the backchannel disarms the monitor we advance the clock past
        // several poll intervals: a still-armed monitor would fire and cancel the run, but a correctly
        // disarmed one has no live timer, so nothing happens. This replaces a real-time sleep that raced
        // the 5s teardown budget and made the regression test flaky under CI load.
        var timeProvider = new FakeTimeProvider();
 
        var appHostDir = workspace.WorkspaceRoot.CreateSubdirectory("AppHost");
        var appHostFile = new FileInfo(Path.Combine(appHostDir.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(appHostFile.FullName, "<Project />", TestContext.Current.CancellationToken);
 
        var projectLocator = new TestProjectLocator
        {
            UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) =>
                Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile]))
        };
 
        var dashboardRequested = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var dashboardCanReturn = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var appHostReady = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var appHostCanExit = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
 
        var projectFactory = new TestAppHostProjectFactory
        {
            RunAsyncCallback = async (context, cancellationToken) =>
            {
                context.BuildCompletionSource?.TrySetResult(true);
                context.BackchannelCompletionSource?.TrySetResult(new TestAppHostBackchannel
                {
                    NotifyAppHostReadyAsyncCalled = appHostReady,
                    GetDashboardUrlsAsyncCallback = async ct =>
                    {
                        // The backchannel is established and the launcher watchdog has already been
                        // disarmed by the time GetDashboardUrlsAsync runs. Signal the test so it can kill
                        // the launcher, then wait for the test to confirm the run survived before returning
                        // healthy dashboard URLs so startup can proceed to readiness.
                        dashboardRequested.TrySetResult();
                        await dashboardCanReturn.Task.WaitAsync(ct);
                        return new DashboardUrlsState { DashboardHealthy = true };
                    }
                });
 
                // Keep the AppHost "running" until the test allows it to exit, so the run stays alive after
                // readiness instead of completing during the early-exit observation window.
                await appHostCanExit.Task.WaitAsync(cancellationToken);
                return CliExitCodes.Success;
            }
        };
 
        // A real, short-lived process stands in for the foreground launcher. The child watches it via
        // ASPIRE_LAUNCHER_PID/STARTED and must stop reacting to its death once the backchannel is up.
        using var launcher = TestProcesses.StartLongRunning();
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => interactionService;
            options.ProjectLocatorFactory = _ => projectLocator;
            options.AppHostProjectFactory = _ => projectFactory;
            options.ConfigurationCallback += config =>
            {
                config[KnownConfigNames.CliRunDetached] = "true";
                config[KnownConfigNames.CliLauncherProcessId] = launcher.Id.ToString(CultureInfo.InvariantCulture);
                config[KnownConfigNames.CliLauncherProcessStarted] = GetProcessStartTimeUnixMilliseconds(launcher).ToString(CultureInfo.InvariantCulture);
            };
        });
 
        services.RemoveAll<TimeProvider>();
        services.AddSingleton<TimeProvider>(timeProvider);
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse($"run --apphost {appHostFile.FullName}");
 
        var pendingCommand = result.InvokeAsync(cancellationToken: TestContext.Current.CancellationToken);
 
        // Wait until startup has established the backchannel and is fetching dashboard URLs; the monitor
        // is disarmed before this point.
        await dashboardRequested.Task.WaitAsync(TimeSpan.FromSeconds(30));
 
        // Kill the launcher, then advance the fake clock well past the monitor's 1s poll interval. If the
        // monitor were still armed it would tick, observe the dead launcher, and cancel the run; because it
        // was disarmed when the backchannel came up, there is no live timer and advancing is a no-op. The
        // run must survive and reach readiness.
        launcher.Kill(entireProcessTree: true);
        launcher.WaitForExit();
        timeProvider.Advance(TimeSpan.FromSeconds(5));
 
        dashboardCanReturn.SetResult();
        await appHostReady.Task.WaitAsync(TimeSpan.FromSeconds(30));
 
        // Tear down cleanly and confirm the run completed successfully rather than being cancelled by the
        // dead launcher.
        appHostCanExit.SetResult();
        var exitCode = await pendingCommand.DefaultTimeout();
 
        Assert.True(appHostReady.Task.IsCompletedSuccessfully);
        Assert.Equal(CliExitCodes.Success, exitCode);
    }
 
    [Fact]
    public async Task RunCommand_DetachedChild_WhenSignaledBeforeReadiness_AwaitsAppHostTeardownBeforeExit()
    {
        // Regression test for the detached-start teardown leak (PR #18566 scenario 3): when a termination
        // signal cancels the run before the AppHost backchannel is established, the detached child must wait
        // for the AppHost (dotnet run) shutdown to finish before the CLI process exits. Before the fix the
        // child returned immediately, abandoning the in-flight teardown and orphaning a dotnet run process
        // that aspire ps could not even see (its backchannel never came up).
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        using var cts = new CancellationTokenSource();
        var interactionService = new TestInteractionService();
        var timeProvider = new FakeTimeProvider();
 
        var appHostDir = workspace.WorkspaceRoot.CreateSubdirectory("AppHost");
        var appHostFile = new FileInfo(Path.Combine(appHostDir.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(appHostFile.FullName, "<Project />", TestContext.Current.CancellationToken);
 
        var projectLocator = new TestProjectLocator
        {
            UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) =>
                Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile]))
        };
 
        var startupReached = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var teardownStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var teardownCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        // Test-controlled gate that simulates the AppHost shutdown ladder finishing. Using a TCS
        // instead of Task.Delay(timeProvider) avoids potential reentrancy issues where FakeTimeProvider's
        // Advance() fires a timer whose continuation chain disposes another timer on the same provider,
        // which can deadlock intermittently under thread pool contention.
        var teardownCanFinish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
 
        var projectFactory = new TestAppHostProjectFactory
        {
            RunAsyncCallback = async (context, cancellationToken) =>
            {
                // Report a successful build but never establish the backchannel, mirroring an AppHost that
                // is still in a long startup delay when the termination signal arrives.
                context.BuildCompletionSource?.TrySetResult(true);
                startupReached.TrySetResult();
 
                try
                {
                    await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
                }
                catch (OperationCanceledException)
                {
                    // Stand in for the dotnet run shutdown ladder, which takes real time to kill the process
                    // tree. The CLI must not exit until this has finished. CancellationToken.None is
                    // deliberate: this represents teardown work that the same signal cannot itself cancel.
                    teardownStarted.TrySetResult();
                    await teardownCanFinish.Task;
                    teardownCompleted.TrySetResult();
                }
 
                return CliExitCodes.Cancelled;
            }
        };
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => interactionService;
            options.ProjectLocatorFactory = _ => projectLocator;
            options.AppHostProjectFactory = _ => projectFactory;
            options.TimeProvider = timeProvider;
            options.ConfigurationCallback += config => config[KnownConfigNames.CliRunDetached] = "true";
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse($"run --apphost {appHostFile.FullName}");
 
        var pendingCommand = result.InvokeAsync(cancellationToken: cts.Token);
 
        // Once the run is in the startup window (no backchannel yet), deliver the termination signal.
        await startupReached.Task.WaitAsync(TimeSpan.FromSeconds(30));
        cts.Cancel();
        await teardownStarted.Task.WaitAsync(TimeSpan.FromSeconds(30));
 
        // The detached child must not exit while the AppHost teardown is in progress. The callback
        // is blocked on teardownCanFinish and fake time is never advanced, so pendingCommand cannot
        // complete until the gate is released below.
        Assert.False(pendingCommand.IsCompleted, "Detached child exited before the AppHost teardown completed.");
 
        // Allow the teardown to finish. The RunCommand's finally block awaits runTask (via WaitAsync),
        // so once the callback returns, the command pipeline unwinds and pendingCommand completes.
        teardownCanFinish.SetResult();
 
        var exitCode = await pendingCommand.DefaultTimeout();
 
        // The command must not have returned until the AppHost teardown finished; otherwise the real
        // dotnet run process would be orphaned below the backchannel-ready point.
        Assert.True(teardownCompleted.Task.IsCompletedSuccessfully, "Detached child exited before the AppHost teardown completed.");
        Assert.Equal(CliExitCodes.Success, exitCode);
    }
 
    [Fact]
    public async Task RunCommand_StartupTimeoutBudgetIncludesBuildAndBackchannelWaits()
    {
        var interactionService = new TestInteractionService();
        var timeProvider = new FakeTimeProvider();
 
        var runnerFactory = (IServiceProvider sp) =>
        {
            var runner = new TestDotNetCliRunner();
            runner.BuildAsyncCallback = (projectFile, noRestore, options, ct) =>
            {
                timeProvider.Advance(TimeSpan.FromSeconds(2));
                return 0;
            };
            runner.GetAppHostInformationAsyncCallback = (projectFile, options, ct) => (0, true, VersionHelper.GetDefaultTemplateVersion());
            runner.RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, ct) =>
            {
                await Task.Delay(Timeout.InfiniteTimeSpan, ct);
                return 0;
            };
 
            return runner;
        };
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => interactionService;
            options.ProjectLocatorFactory = _ => new TestProjectLocator();
            options.DotNetCliRunnerFactory = runnerFactory;
            options.ConfigurationCallback += config =>
            {
                config[CliConfigNames.AppHostStartupTimeout] = "2";
            };
        });
        services.RemoveAll<TimeProvider>();
        services.AddSingleton<TimeProvider>(timeProvider);
 
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("run");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.FailedToDotnetRunAppHost, exitCode);
        Assert.Contains(
            string.Format(CultureInfo.CurrentCulture, RunCommandStrings.TimeoutWaitingForAppHost, 2, CliConfigNames.AppHostStartupTimeout),
            interactionService.DisplayedErrors);
    }
 
    [Fact]
    public async Task RunCommand_WhenNoProjectFileFound_ReturnsNonZeroExitCode()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = _ => new NoProjectFileProjectLocator();
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("run");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(CliExitCodes.FailedToFindProject, exitCode);
    }
 
    [Fact]
    public async Task RunCommand_WhenMultipleProjectFilesFound_NonInteractive_ReturnsFailedToFindProject()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        // Create two real apphost project files in the workspace
        var appHost1Dir = workspace.WorkspaceRoot.CreateSubdirectory("AppHost1");
        await File.WriteAllTextAsync(Path.Combine(appHost1Dir.FullName, "AppHost1.csproj"), "fake");
 
        var appHost2Dir = workspace.WorkspaceRoot.CreateSubdirectory("AppHost2");
        await File.WriteAllTextAsync(Path.Combine(appHost2Dir.FullName, "AppHost2.csproj"), "fake");
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            // Use the real ProjectLocator (default) so it discovers both apphosts
            options.CliHostEnvironmentFactory = sp =>
            {
                var configuration = sp.GetRequiredService<IConfiguration>();
                return new CliHostEnvironment(configuration, nonInteractive: true);
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("run");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.FailedToFindProject, exitCode);
    }
 
    [Fact]
    public async Task RunCommand_WhenMultipleProjectFilesFound_ReturnsNonZeroExitCode()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = _ => new MultipleProjectFilesProjectLocator();
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("run");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(CliExitCodes.FailedToFindProject, exitCode);
    }
 
    [Fact]
    public async Task RunCommand_WhenProjectFileDoesNotExist_ReturnsNonZeroExitCode()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = _ => new ProjectFileDoesNotExistLocator();
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("run --apphost /tmp/doesnotexist.csproj");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.FailedToFindProject, exitCode);
    }
 
    // https://github.com/microsoft/aspire/issues/19035. An AppHost that MSBuild cannot even evaluate
    // (missing Aspire.AppHost.Sdk, malformed project XML) must reach the command's real build path so the
    // original MSBuild diagnostics are printed. Replacing them with a bare "The project could not be
    // built." tells the user nothing about what to fix.
    private const string MissingSdkBuildError = "AppHost.csproj : error MSB4236: The SDK 'Aspire.AppHost.Sdk/0.0.0-does-not-exist' specified could not be found.";
 
    private static void WriteUnbuildableAppHostProject(FileInfo projectFile)
    {
        Directory.CreateDirectory(projectFile.DirectoryName!);
        File.WriteAllText(projectFile.FullName, """
            <Project Sdk="Microsoft.NET.Sdk">
              <Sdk Name="Aspire.AppHost.Sdk" Version="0.0.0-does-not-exist" />
              <PropertyGroup>
                <OutputType>Exe</OutputType>
                <TargetFramework>net10.0</TargetFramework>
              </PropertyGroup>
            </Project>
            """);
    }
 
    /// <summary>
    /// Configures a runner whose MSBuild evaluation always fails (so the AppHost can only ever be
    /// classified as possibly unbuildable) and whose build emits <see cref="MissingSdkBuildError"/>.
    /// </summary>
    private static TestDotNetCliRunner CreateUnevaluatableAppHostRunner()
    {
        var runner = new TestDotNetCliRunner();
 
        // A non-zero exit with no JSON payload is what DotNetCliRunner returns when MSBuild cannot
        // evaluate the project at all, which is what produces the possibly-unbuildable classification.
        runner.GetProjectItemsAndPropertiesAsyncCallbackWithTargets = (_, _, _, _, _, _) => (1, null);
        runner.BuildAsyncCallback = (_, _, buildOptions, _) =>
        {
            buildOptions.StandardErrorCallback?.Invoke(MissingSdkBuildError);
            return 1;
        };
 
        return runner;
    }
 
    [Fact]
    public async Task RunCommand_WhenExplicitAppHostCannotBeEvaluated_SurfacesMSBuildDiagnosticsAndBuildFailureExitCode()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var appHostProjectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost", "AppHost.csproj"));
        WriteUnbuildableAppHostProject(appHostProjectFile);
 
        var interactionService = new TestInteractionService();
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => interactionService;
            options.DotNetCliRunnerFactory = _ => CreateUnevaluatableAppHostRunner();
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse($"run --apphost {appHostProjectFile.FullName}");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.FailedToBuildArtifacts, exitCode);
        Assert.Contains(interactionService.DisplayedLines, line => line.Line == MissingSdkBuildError);
        Assert.Contains(InteractionServiceStrings.ProjectCouldNotBeBuilt, interactionService.DisplayedErrors);
    }
 
    [Fact]
    public async Task RunCommand_WhenConfiguredAppHostCannotBeEvaluated_SurfacesMSBuildDiagnosticsAndBuildFailureExitCode()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var appHostProjectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost", "AppHost.csproj"));
        WriteUnbuildableAppHostProject(appHostProjectFile);
 
        await File.WriteAllTextAsync(
            Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName),
            JsonSerializer.Serialize(new { appHost = new { path = "AppHost/AppHost.csproj" } }));
 
        var interactionService = new TestInteractionService();
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => interactionService;
            options.DotNetCliRunnerFactory = _ => CreateUnevaluatableAppHostRunner();
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("run");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.FailedToBuildArtifacts, exitCode);
        Assert.Contains(interactionService.DisplayedLines, line => line.Line == MissingSdkBuildError);
        Assert.Contains(InteractionServiceStrings.ProjectCouldNotBeBuilt, interactionService.DisplayedErrors);
    }
 
    [Fact]
    public async Task RunCommand_WhenAmbientDiscoveryOnlyFindsUnbuildableAppHosts_ReportsProjectResolutionFailure()
    {
        // Nothing was named by the user here, so this stays a project-resolution failure: the CLI never
        // built anything and must keep the pre-existing message, exit code, and telemetry tag.
        using var fixture = new TelemetryFixture();
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var appHostProjectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost", "AppHost.csproj"));
        WriteUnbuildableAppHostProject(appHostProjectFile);
 
        var interactionService = new TestInteractionService();
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => interactionService;
            options.DotNetCliRunnerFactory = _ => CreateUnevaluatableAppHostRunner();
            options.TelemetryFactory = _ => fixture.Telemetry;
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("run");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.FailedToFindProject, exitCode);
        Assert.Contains(InteractionServiceStrings.UnbuildableAppHostsDetected, interactionService.DisplayedErrors);
 
        Assert.NotNull(fixture.CapturedActivity);
        var tags = fixture.CapturedActivity.TagObjects.ToDictionary(t => t.Key, t => t.Value);
        Assert.Equal("project_not_found", tags[TelemetryConstants.Tags.ErrorType]);
    }
 
    [Fact]
    public async Task RunCommand_WhenExplicitAppHostCannotBeEvaluated_TagsRunActivityAsBuildFailure()
    {
        using var fixture = new TelemetryFixture();
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var appHostProjectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost", "AppHost.csproj"));
        WriteUnbuildableAppHostProject(appHostProjectFile);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.DotNetCliRunnerFactory = _ => CreateUnevaluatableAppHostRunner();
            options.TelemetryFactory = _ => fixture.Telemetry;
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse($"run --apphost {appHostProjectFile.FullName}");
 
        await result.InvokeAsync().DefaultTimeout();
 
        Assert.NotNull(fixture.CapturedActivity);
        var tags = fixture.CapturedActivity.TagObjects.ToDictionary(t => t.Key, t => t.Value);
        Assert.Equal("build_failed", tags[TelemetryConstants.Tags.ErrorType]);
    }
 
    [Fact]
    public async Task RunCommand_WhenUnverifiedAppHostBuildsButIsNotAnAppHost_FailsInsteadOfWaitingForBackchannel()
    {
        // A candidate kept only because MSBuild could not evaluate it has never been confirmed to be an
        // AppHost. Once the build repairs evaluation and proves it is an ordinary project, the existing
        // compatibility gate must reject it; launching it would hang until the startup timeout waiting
        // for a backchannel that never arrives.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var appHostProjectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost", "AppHost.csproj"));
        WriteUnbuildableAppHostProject(appHostProjectFile);
 
        var evaluationCount = 0;
        var interactionService = new TestInteractionService();
        var backchannelWaited = false;
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => interactionService;
            options.DotNetCliRunnerFactory = _ =>
            {
                var runner = new TestDotNetCliRunner();
                runner.GetProjectItemsAndPropertiesAsyncCallbackWithTargets = (_, _, _, _, _, _) =>
                {
                    // First evaluation (project resolution) fails, so the project is only ever a
                    // possibly-unbuildable candidate. Later evaluations succeed and prove the project is
                    // an ordinary library, not an AppHost.
                    if (Interlocked.Increment(ref evaluationCount) == 1)
                    {
                        return (1, null);
                    }
 
                    return (0, JsonDocument.Parse("""
                        {
                          "Properties": { "IsAspireHost": "false", "AspireHostingSDKVersion": null },
                          "Items": {}
                        }
                        """));
                };
                runner.BuildAsyncCallback = (_, _, _, _) => 0;
                runner.RunAsyncCallback = async (_, _, _, _, _, _, _, _, ct) =>
                {
                    backchannelWaited = true;
                    await Task.Delay(Timeout.InfiniteTimeSpan, ct);
                    return 0;
                };
                return runner;
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse($"run --apphost {appHostProjectFile.FullName}");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.FailedToDotnetRunAppHost, exitCode);
        Assert.Contains(ErrorStrings.ProjectIsNotAppHost, interactionService.DisplayedErrors);
        Assert.False(backchannelWaited, "The run must fail before the AppHost process is launched.");
    }
 
    [Fact]
    public async Task RunCommand_WithDetachFlag_DoesNotShowUpdateNotification()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var testNotifier = new TestCliUpdateNotifier();
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = _ => new NoProjectFileProjectLocator();
            options.CliUpdateNotifierFactory = _ => testNotifier;
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("run --detach");
 
        await result.InvokeAsync().DefaultTimeout();
 
        Assert.False(testNotifier.NotifyWasCalled, "Update notification should not be shown when --detach is used");
    }
 
    [Fact]
    public async Task RunCommand_DetachedChild_DoesNotStartCliMetadataPrefetching()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("AppHost");
        var appHostFile = new FileInfo(Path.Combine(appHostDirectory.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(appHostFile.FullName, "<Project />", TestContext.Current.CancellationToken);
        var projectLocator = new TestProjectLocator
        {
            UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) =>
                Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile]))
        };
        var processFactory = new TestProcessExecutionFactory();
 
        var parentServices = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = _ => projectLocator;
        });
        parentServices.Replace(ServiceDescriptor.Singleton<IProcessExecutionFactory>(processFactory));
 
        using (var parentProvider = parentServices.BuildServiceProvider())
        {
            var parentCommand = parentProvider.GetRequiredService<RootCommand>();
            var parentResult = parentCommand.Parse($"run --detach --apphost {appHostFile.FullName}");
 
            await parentResult.InvokeAsync().DefaultTimeout();
        }
 
        var childEnvironment = Assert.IsAssignableFrom<IDictionary<string, string>>(processFactory.LastEnvironmentVariables);
        var childArguments = Assert.IsAssignableFrom<IEnumerable<string>>(processFactory.LastArguments);
        Assert.Contains("run", childArguments);
        Assert.DoesNotContain("--detach", childArguments);
        Assert.Equal("true", childEnvironment[KnownConfigNames.CliRunDetached]);
 
        var cliPrefetchStarted = false;
        var testNotifier = new TestCliUpdateNotifier
        {
            CheckForCliUpdatesAsyncCallback = (_, _) =>
            {
                cliPrefetchStarted = true;
                return Task.CompletedTask;
            }
        };
 
        var childServices = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = _ => new NoProjectFileProjectLocator();
            options.CliUpdateNotifierFactory = _ => testNotifier;
            options.ConfigurationCallback += config =>
            {
                foreach (var (name, value) in childEnvironment)
                {
                    config[name] = value;
                }
            };
        });
        using var childProvider = childServices.BuildServiceProvider();
 
        var prefetcher = childProvider.GetRequiredService<NuGetPackagePrefetcher>();
        await prefetcher.StartAsync(CancellationToken.None).DefaultTimeout();
 
        var childCommand = childProvider.GetRequiredService<RootCommand>();
        var childResult = childCommand.Parse("run");
 
        await childResult.InvokeAsync().DefaultTimeout();
        await prefetcher.ExecuteTask!.DefaultTimeout();
        await prefetcher.StopAsync(CancellationToken.None).DefaultTimeout();
 
        Assert.False(cliPrefetchStarted);
    }
 
    [Theory]
    [InlineData("--launch-profile")]
    [InlineData("-lp")]
    public async Task RunCommand_DetachedChild_PreservesOptionShapedLaunchProfileAndAppHostArguments(string launchProfileOption)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = CreateAppHostFile(workspace);
        var propertiesDirectory = Directory.CreateDirectory(Path.Combine(appHostFile.DirectoryName!, "Properties"));
        File.WriteAllText(Path.Combine(propertiesDirectory.FullName, "launchSettings.json"), """
            {
              "profiles": {
                "--no-build": {
                  "commandName": "Project"
                }
              }
            }
            """);
        var expectedAppHostArguments = new[] { "true", "false", string.Empty, "--detach", "--option-shaped" };
        var projectLocator = new TestProjectLocator
        {
            UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) =>
                Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile]))
        };
        var processFactory = new TestProcessExecutionFactory
        {
            DefaultExitCode = CliExitCodes.FailedToDotnetRunAppHost
        };
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = _ => projectLocator;
        });
        services.Replace(ServiceDescriptor.Singleton<IProcessExecutionFactory>(processFactory));
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse(
        [
            "run",
            "--detach",
            "--apphost", appHostFile.FullName,
            "--no-build",
            $"{launchProfileOption}=--no-build",
            "--",
            .. expectedAppHostArguments
        ]);
 
        Assert.Empty(result.Errors);
        Assert.Equal(CliExitCodes.FailedToDotnetRunAppHost, await result.InvokeAsync().DefaultTimeout());
 
        AssertDetachedChildArguments(command, processFactory.LastArguments, "--no-build", expectedAppHostArguments);
    }
 
    [Fact]
    public async Task RunCommand_WithoutDetachFlag_ShowsUpdateNotification()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var testNotifier = new TestCliUpdateNotifier();
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = _ => new NoProjectFileProjectLocator();
            options.CliUpdateNotifierFactory = _ => testNotifier;
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("run");
 
        await result.InvokeAsync().DefaultTimeout();
 
        Assert.True(testNotifier.NotifyWasCalled, "Update notification should be shown when --detach is not used");
    }
 
    [Fact]
    public void GetDetachedFailureMessage_ReturnsBuildSpecificMessage_ForBuildFailureExitCode()
    {
        var message = AppHostLauncher.GetDetachedFailureMessage(CliExitCodes.FailedToBuildArtifacts);
 
        Assert.Equal(RunCommandStrings.AppHostFailedToBuild, message);
    }
 
    [Fact]
    public void GetDetachedFailureMessage_ReturnsExitCodeMessage_ForUnknownExitCode()
    {
        var message = AppHostLauncher.GetDetachedFailureMessage(123);
 
        Assert.Contains("123", message, StringComparison.Ordinal);
    }
 
    private sealed class ProjectFileDoesNotExistLocator : Aspire.Cli.Projects.IProjectLocator
    {
        public Task<List<AppHostProjectCandidate>> FindAppHostProjectsAsync(DirectoryInfo searchDirectory, AppHostDiscoveryScope scope, CancellationToken cancellationToken)
        {
            throw new Aspire.Cli.Projects.ProjectLocatorException("Project file does not exist.", Aspire.Cli.Projects.ProjectLocatorFailureReason.ProjectFileDoesntExist);
        }
 
        public Task<List<FileInfo>> FindAppHostProjectFilesAsync(DirectoryInfo searchDirectory, AppHostDiscoveryScope scope, CancellationToken cancellationToken)
        {
            throw new Aspire.Cli.Projects.ProjectLocatorException("Project file does not exist.", Aspire.Cli.Projects.ProjectLocatorFailureReason.ProjectFileDoesntExist);
        }
 
        public Task<AppHostProjectSearchResult> UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, MultipleAppHostProjectsFoundBehavior multipleAppHostProjectsFoundBehavior, bool createSettingsFile, CancellationToken cancellationToken)
        {
            throw new Aspire.Cli.Projects.ProjectLocatorException("Project file does not exist.", Aspire.Cli.Projects.ProjectLocatorFailureReason.ProjectFileDoesntExist);
        }
 
        public Task<FileInfo?> UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, bool createSettingsFile, CancellationToken cancellationToken)
        {
            throw new Aspire.Cli.Projects.ProjectLocatorException("Project file does not exist.", Aspire.Cli.Projects.ProjectLocatorFailureReason.ProjectFileDoesntExist);
        }
 
        public Task<FileInfo?> GetAppHostFromSettingsAsync(CancellationToken cancellationToken = default) => Task.FromResult<FileInfo?>(null);
    }
 
    [Fact]
    public async Task RunCommand_WhenBackchannelDisconnectsDuringStartup_WaitsForAppHostExitAndSurfacesWrappedError()
    {
        // Covers the catastrophic-disconnect path: the backchannel itself died (e.g. AppHost
        // crashed mid-startup) so the GetDashboardUrlsAsync call surfaces a ConnectionLostException.
        // We want to give the dying AppHost a chance to write a final error to its own captured
        // output before we surface the CLI-side wrapper, so the CLI waits on pendingRun (with a
        // Ctrl+C-aware status) and then reports both the AppHost narrative and the wrapped fault.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var interactionService = new TestInteractionService();
 
        var appHostDir = workspace.WorkspaceRoot.CreateSubdirectory("AppHost");
        var appHostFile = new FileInfo(Path.Combine(appHostDir.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(appHostFile.FullName, "<Project />");
 
        var projectLocator = new TestProjectLocator
        {
            UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) =>
                Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile]))
        };
 
        var dashboardRequested = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var appHostCanExit = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var projectFactory = new TestAppHostProjectFactory
        {
            RunAsyncCallback = async (context, cancellationToken) =>
            {
                context.BuildCompletionSource?.TrySetResult(true);
                context.BackchannelCompletionSource?.TrySetResult(new TestAppHostBackchannel
                {
                    GetDashboardUrlsAsyncCallback = _ =>
                    {
                        dashboardRequested.TrySetResult();
                        throw new ConnectionLostException("Backchannel dropped while fetching dashboard URLs.");
                    }
                });
 
                await dashboardRequested.Task.WaitAsync(cancellationToken);
                interactionService.DisplayLines(
                [
                    (OutputLineStream.StdErr, "Endpoint 'http' must specify a port when isProxied is false.")
                ]);
 
                await appHostCanExit.Task.WaitAsync(cancellationToken);
 
                return CliExitCodes.FailedToDotnetRunAppHost;
            }
        };
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => interactionService;
            options.ProjectLocatorFactory = _ => projectLocator;
            options.AppHostProjectFactory = _ => projectFactory;
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse($"run --apphost {appHostFile.FullName}");
 
        var pendingCommand = result.InvokeAsync();
 
        // The backchannel connection died, so the CLI is waiting on pendingRun for the AppHost to
        // surface its real exit code/output. The command must not complete until the AppHost does.
        await dashboardRequested.Task.DefaultTimeout();
        appHostCanExit.SetResult();
 
        var exitCode = await pendingCommand.DefaultTimeout();
 
        Assert.Equal(CliExitCodes.FailedToDotnetRunAppHost, exitCode);
        Assert.Contains(RunCommandStrings.AppHostConnectionLostWaitingForExit, interactionService.ShownStatuses);
        Assert.Contains(interactionService.DisplayedLines, line => line.Line == "Endpoint 'http' must specify a port when isProxied is false.");
        Assert.Contains(interactionService.DisplayedErrors, error => error.Contains("An unexpected error occurred", StringComparison.Ordinal));
        Assert.Contains(interactionService.DisplayedErrors, error => error.Contains("Backchannel dropped", StringComparison.Ordinal));
    }
 
    [Fact]
    public async Task RunCommand_WhenDashboardRpcHandlerFaultsButConnectionStaysAlive_SurfacesImmediatelyWithoutWaiting()
    {
        // Covers the server-side-handler-fault path: the RPC channel is alive and the AppHost is
        // still running, but GetDashboardUrlsAsync's server-side handler threw. There is no
        // catastrophic exit to wait for - the RPC payload is already the real cause, so the CLI
        // must surface the wrapped error immediately rather than hanging on pendingRun. This
        // preserves pre-PR behavior for this failure shape.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var interactionService = new TestInteractionService();
 
        var appHostDir = workspace.WorkspaceRoot.CreateSubdirectory("AppHost");
        var appHostFile = new FileInfo(Path.Combine(appHostDir.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(appHostFile.FullName, "<Project />");
 
        var projectLocator = new TestProjectLocator
        {
            UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) =>
                Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile]))
        };
 
        var dashboardRequested = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var appHostCanExit = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
 
        var projectFactory = new TestAppHostProjectFactory
        {
            RunAsyncCallback = async (context, cancellationToken) =>
            {
                context.BuildCompletionSource?.TrySetResult(true);
                context.BackchannelCompletionSource?.TrySetResult(new TestAppHostBackchannel
                {
                    GetDashboardUrlsAsyncCallback = _ =>
                    {
                        dashboardRequested.TrySetResult();
                        throw new IOException("Dashboard URL handler threw.");
                    }
                });
 
                await appHostCanExit.Task.WaitAsync(cancellationToken);
                return CliExitCodes.FailedToDotnetRunAppHost;
            }
        };
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => interactionService;
            options.ProjectLocatorFactory = _ => projectLocator;
            options.AppHostProjectFactory = _ => projectFactory;
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse($"run --apphost {appHostFile.FullName}");
 
        var pendingCommand = result.InvokeAsync();
 
        // The command must surface the wrapped error and return without waiting for the AppHost
        // to exit, since the channel is still alive and the AppHost is not necessarily exiting.
        var exitCode = await pendingCommand.DefaultTimeout();
 
        Assert.Equal(CliExitCodes.FailedToDotnetRunAppHost, exitCode);
        Assert.DoesNotContain(RunCommandStrings.AppHostConnectionLostWaitingForExit, interactionService.ShownStatuses);
        Assert.Contains(interactionService.DisplayedErrors, error => error.Contains("An unexpected error occurred", StringComparison.Ordinal));
        Assert.Contains(interactionService.DisplayedErrors, error => error.Contains("Dashboard URL handler threw", StringComparison.Ordinal));
 
        // Release the stub project task so the background callback can complete and not leak.
        appHostCanExit.SetResult();
    }
 
    [Fact]
    public async Task RunCommand_WhenAppHostRunFaultsDuringStartup_ReturnsFailureExitCode()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var interactionService = new TestInteractionService();
 
        var appHostDir = workspace.WorkspaceRoot.CreateSubdirectory("AppHost");
        var appHostFile = new FileInfo(Path.Combine(appHostDir.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(appHostFile.FullName, "<Project />");
 
        var projectLocator = new TestProjectLocator
        {
            UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) =>
                Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile]))
        };
 
        var dashboardRequested = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var projectFactory = new TestAppHostProjectFactory
        {
            RunAsyncCallback = async (context, cancellationToken) =>
            {
                var outputCollector = new OutputCollector();
                context.OutputCollector = outputCollector;
                outputCollector.AppendError("MSB3277: Found conflicts between different versions of a dependency.");
                context.BuildCompletionSource?.TrySetResult(true);
                context.BackchannelCompletionSource?.TrySetResult(new TestAppHostBackchannel
                {
                    GetDashboardUrlsAsyncCallback = async ct =>
                    {
                        dashboardRequested.TrySetResult();
                        await Task.Delay(Timeout.InfiniteTimeSpan, ct);
                        return new DashboardUrlsState { DashboardHealthy = true };
                    }
                });
 
                await dashboardRequested.Task.WaitAsync(cancellationToken);
                outputCollector.AppendError("System.InvalidOperationException: AppHost failed before returning an exit code.");
 
                throw new InvalidOperationException("RunAsync failed before returning an exit code.");
            }
        };
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => interactionService;
            options.ProjectLocatorFactory = _ => projectLocator;
            options.AppHostProjectFactory = _ => projectFactory;
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse($"run --apphost {appHostFile.FullName}");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.FailedToDotnetRunAppHost, exitCode);
        Assert.Contains(interactionService.DisplayedLines, line => line.Line.Contains("AppHost failed before returning an exit code", StringComparison.Ordinal));
        Assert.DoesNotContain(interactionService.DisplayedLines, line => line.Line.Contains("MSB3277", StringComparison.Ordinal));
        Assert.Contains(interactionService.DisplayedErrors, error => error.Contains("An unexpected error occurred", StringComparison.Ordinal));
        Assert.Contains(interactionService.DisplayedErrors, error => error.Contains("RunAsync failed before returning an exit code", StringComparison.Ordinal));
    }
 
    [Fact]
    public async Task RunCommand_DetachedEarlyExit_PropagatesExitCodeWithoutUnexpectedErrorWrapper()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var interactionService = new TestInteractionService();
 
        var appHostDir = workspace.WorkspaceRoot.CreateSubdirectory("AppHost");
        var appHostFile = new FileInfo(Path.Combine(appHostDir.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(appHostFile.FullName, "<Project />");
 
        var projectLocator = new TestProjectLocator
        {
            UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) =>
                Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile]))
        };
 
        const int detachedExitCode = 42;
        var projectFactory = new TestAppHostProjectFactory
        {
            RunAsyncCallback = async (context, cancellationToken) =>
            {
                var outputCollector = new OutputCollector();
                context.OutputCollector = outputCollector;
                context.BuildCompletionSource?.TrySetResult(true);
                context.BackchannelCompletionSource?.TrySetResult(new TestAppHostBackchannel
                {
                    GetDashboardUrlsAsyncCallback = _ => Task.FromResult(new DashboardUrlsState { DashboardHealthy = true })
                });
 
                // Simulate a detached-start child AppHost that exits cleanly during the
                // early-exit observation window after the backchannel handshake completes.
                await Task.Delay(50, cancellationToken);
                return detachedExitCode;
            }
        };
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => interactionService;
            options.ProjectLocatorFactory = _ => projectLocator;
            options.AppHostProjectFactory = _ => projectFactory;
            options.ConfigurationCallback += config => config[KnownConfigNames.CliRunDetached] = "true";
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse($"run --apphost {appHostFile.FullName}");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(detachedExitCode, exitCode);
        // The detached early-exit case is an expected outcome (the AppHost intentionally
        // exited after handshake) so it must not be wrapped with the generic
        // "An unexpected error occurred" template - the exit code carries the narrative.
        Assert.DoesNotContain(interactionService.DisplayedErrors, error => error.Contains("An unexpected error occurred", StringComparison.Ordinal));
    }
 
    [Fact]
    public async Task RunCommand_WhenCancelledDuringStartupRpc_CompletesSuccessfully()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        using var cts = new CancellationTokenSource();
        var runCanExit = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
 
        var appHostDir = workspace.WorkspaceRoot.CreateSubdirectory("AppHost");
        var appHostFile = new FileInfo(Path.Combine(appHostDir.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(appHostFile.FullName, "<Project />");
 
        var projectLocator = new TestProjectLocator
        {
            UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) =>
                Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile]))
        };
 
        var interactionService = new TestInteractionService();
        var projectFactory = new TestAppHostProjectFactory
        {
            RunAsyncCallback = async (context, _) =>
            {
                context.BuildCompletionSource?.TrySetResult(true);
                context.BackchannelCompletionSource?.TrySetResult(new TestAppHostBackchannel
                {
                    GetDashboardUrlsAsyncCallback = ct =>
                    {
                        cts.Cancel();
                        return Task.FromCanceled<DashboardUrlsState>(ct);
                    }
                });
 
                await runCanExit.Task;
                return CliExitCodes.Success;
            }
        };
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => interactionService;
            options.ProjectLocatorFactory = _ => projectLocator;
            options.AppHostProjectFactory = _ => projectFactory;
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse($"run --apphost {appHostFile.FullName}");
 
        try
        {
            var exitCode = await result.InvokeAsync(cancellationToken: cts.Token).DefaultTimeout();
 
            Assert.Equal(CliExitCodes.Success, exitCode);
            Assert.Empty(interactionService.DisplayedErrors);
        }
        finally
        {
            runCanExit.TrySetResult();
        }
    }
 
    [Fact]
    public async Task RunCommand_WhenStartupRpcThrowsUnrelatedCancellationAfterUserCancellation_DoesNotTreatRunAsSuccessful()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        using var cts = new CancellationTokenSource();
        using var unrelatedCts = new CancellationTokenSource();
        var runCanExit = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
 
        var appHostDir = workspace.WorkspaceRoot.CreateSubdirectory("AppHost");
        var appHostFile = new FileInfo(Path.Combine(appHostDir.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(appHostFile.FullName, "<Project />");
 
        var projectLocator = new TestProjectLocator
        {
            UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) =>
                Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile]))
        };
 
        var interactionService = new TestInteractionService();
        var projectFactory = new TestAppHostProjectFactory
        {
            RunAsyncCallback = async (context, _) =>
            {
                context.BuildCompletionSource?.TrySetResult(true);
                context.BackchannelCompletionSource?.TrySetResult(new TestAppHostBackchannel
                {
                    GetDashboardUrlsAsyncCallback = _ =>
                    {
                        cts.Cancel();
                        unrelatedCts.Cancel();
                        return Task.FromCanceled<DashboardUrlsState>(unrelatedCts.Token);
                    }
                });
 
                await runCanExit.Task;
                return CliExitCodes.Success;
            }
        };
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => interactionService;
            options.ProjectLocatorFactory = _ => projectLocator;
            options.AppHostProjectFactory = _ => projectFactory;
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse($"run --apphost {appHostFile.FullName}");
 
        try
        {
            var exitCode = await result.InvokeAsync(cancellationToken: cts.Token).DefaultTimeout();
 
            Assert.Equal(CliExitCodes.FailedToDotnetRunAppHost, exitCode);
            Assert.Contains(interactionService.DisplayedErrors, error => error.Contains("unexpected error", StringComparison.OrdinalIgnoreCase));
        }
        finally
        {
            runCanExit.TrySetResult();
        }
    }
 
    [Fact]
    public async Task RunCommand_WhenAppHostExitsDuringStartup_DisplaysCapturedAppHostOutput()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var interactionService = new TestInteractionService();
 
        var appHostDir = workspace.WorkspaceRoot.CreateSubdirectory("AppHost");
        var appHostFile = new FileInfo(Path.Combine(appHostDir.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(appHostFile.FullName, "<Project />");
 
        var projectLocator = new TestProjectLocator
        {
            UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) =>
                Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile]))
        };
 
        var dashboardRequested = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var projectFactory = new TestAppHostProjectFactory
        {
            RunAsyncCallback = async (context, cancellationToken) =>
            {
                var outputCollector = new OutputCollector();
                context.OutputCollector = outputCollector;
                outputCollector.AppendError("MSB3277: Found conflicts between different versions of a dependency.");
                context.BuildCompletionSource?.TrySetResult(true);
                context.BackchannelCompletionSource?.TrySetResult(new TestAppHostBackchannel
                {
                    GetDashboardUrlsAsyncCallback = async ct =>
                    {
                        dashboardRequested.TrySetResult();
                        await Task.Delay(Timeout.InfiniteTimeSpan, ct);
                        return new DashboardUrlsState { DashboardHealthy = true };
                    }
                });
 
                await dashboardRequested.Task.WaitAsync(cancellationToken);
                outputCollector.AppendOutput("Build succeeded.");
                outputCollector.AppendError("System.InvalidOperationException: Service 'frontend' needs to specify a port for endpoint 'http' since it isn't using a proxy.");
 
                return CliExitCodes.FailedToDotnetRunAppHost;
            }
        };
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => interactionService;
            options.ProjectLocatorFactory = _ => projectLocator;
            options.AppHostProjectFactory = _ => projectFactory;
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse($"run --apphost {appHostFile.FullName}");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.FailedToDotnetRunAppHost, exitCode);
        Assert.Contains(interactionService.DisplayedMessages, message => message.Message == $"{RunCommandStrings.RecentAppHostStartupOutput}:");
        Assert.Contains(interactionService.DisplayedLines, line => line.Line.Contains("Service 'frontend' needs to specify a port", StringComparison.Ordinal));
        Assert.DoesNotContain(interactionService.DisplayedLines, line => line.Line.Contains("Build succeeded", StringComparison.Ordinal));
        Assert.DoesNotContain(interactionService.DisplayedLines, line => line.Line.Contains("MSB3277", StringComparison.Ordinal));
    }
 
    [Fact]
    public async Task RunCommand_WhenAppHostExitsBeforeBackchannelConnects_DisplaysCapturedAppHostOutput()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var interactionService = new TestInteractionService();
 
        var appHostDir = workspace.WorkspaceRoot.CreateSubdirectory("AppHost");
        var appHostFile = new FileInfo(Path.Combine(appHostDir.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(appHostFile.FullName, "<Project />");
 
        var connectingToAppHost = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        interactionService.ShowStatusCallback = status =>
        {
            if (status == RunCommandStrings.ConnectingToAppHost)
            {
                connectingToAppHost.TrySetResult();
            }
        };
 
        var projectLocator = new TestProjectLocator
        {
            UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) =>
                Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile]))
        };
 
        var projectFactory = new TestAppHostProjectFactory
        {
            RunAsyncCallback = async (context, cancellationToken) =>
            {
                var outputCollector = new OutputCollector();
                context.OutputCollector = outputCollector;
                outputCollector.AppendError("MSB3277: Found conflicts between different versions of a dependency.");
                context.BuildCompletionSource?.TrySetResult(true);
 
                await connectingToAppHost.Task.WaitAsync(cancellationToken);
                outputCollector.AppendError("System.InvalidOperationException: Service 'frontend' needs to specify a port for endpoint 'http' since it isn't using a proxy.");
 
                return CliExitCodes.FailedToDotnetRunAppHost;
            }
        };
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => interactionService;
            options.ProjectLocatorFactory = _ => projectLocator;
            options.AppHostProjectFactory = _ => projectFactory;
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse($"run --apphost {appHostFile.FullName}");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.FailedToDotnetRunAppHost, exitCode);
        Assert.Contains(interactionService.DisplayedMessages, message => message.Message == $"{RunCommandStrings.RecentAppHostStartupOutput}:");
        Assert.Contains(interactionService.DisplayedLines, line => line.Line.Contains("Service 'frontend' needs to specify a port", StringComparison.Ordinal));
        Assert.DoesNotContain(interactionService.DisplayedLines, line => line.Line.Contains("MSB3277", StringComparison.Ordinal));
        Assert.DoesNotContain(interactionService.DisplayedErrors, error => error.Contains("Timed out waiting for AppHost server", StringComparison.Ordinal));
    }
 
    [Fact]
    public async Task RunCommand_WhenBackchannelFailsBeforeConnection_ReportsUnknownExitWithoutSentinel()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var interactionService = new TestInteractionService();
 
        var appHostDir = workspace.WorkspaceRoot.CreateSubdirectory("AppHost");
        var appHostFile = new FileInfo(Path.Combine(appHostDir.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(appHostFile.FullName, "<Project />");
 
        var projectLocator = new TestProjectLocator
        {
            UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) =>
                Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile]))
        };
 
        var runCanExit = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var projectFactory = new TestAppHostProjectFactory
        {
            RunAsyncCallback = async (context, cancellationToken) =>
            {
                context.BuildCompletionSource?.TrySetResult(true);
                context.BackchannelCompletionSource?.TrySetException(
                    new FailedToConnectBackchannelConnection(
                        "The AppHost server process exited unexpectedly",
                        new SocketException((int)SocketError.ConnectionRefused)));
 
                await runCanExit.Task.WaitAsync(cancellationToken);
                return CliExitCodes.FailedToDotnetRunAppHost;
            }
        };
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => interactionService;
            options.ProjectLocatorFactory = _ => projectLocator;
            options.AppHostProjectFactory = _ => projectFactory;
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse($"run --apphost {appHostFile.FullName}");
 
        try
        {
            var exitCode = await result.InvokeAsync().DefaultTimeout();
 
            Assert.Equal(CliExitCodes.FailedToDotnetRunAppHost, exitCode);
            var error = Assert.Single(interactionService.DisplayedErrors);
            var expectedError = string.Format(
                CultureInfo.CurrentCulture,
                InteractionServiceStrings.UnexpectedErrorOccurred,
                "The AppHost server process exited unexpectedly");
            Assert.Equal(expectedError, error);
            Assert.DoesNotContain("-1", error, StringComparison.Ordinal);
        }
        finally
        {
            runCanExit.TrySetResult();
        }
    }
 
    [Fact]
    public async Task RunCommand_WhenAppHostExitsDuringStartup_CancelsAndObservesLogCapture()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var interactionService = new TestInteractionService();
 
        var appHostDir = workspace.WorkspaceRoot.CreateSubdirectory("AppHost");
        var appHostFile = new FileInfo(Path.Combine(appHostDir.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(appHostFile.FullName, "<Project />");
 
        var projectLocator = new TestProjectLocator
        {
            UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) =>
                Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile]))
        };
 
        var dashboardRequested = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var logCaptureStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var logCaptureCancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var projectFactory = new TestAppHostProjectFactory
        {
            RunAsyncCallback = async (context, cancellationToken) =>
            {
                context.BuildCompletionSource?.TrySetResult(true);
                context.BackchannelCompletionSource?.TrySetResult(new TestAppHostBackchannel
                {
                    GetAppHostLogEntriesAsyncCallback = ct => CaptureLogsUntilCancelledAsync(logCaptureStarted, logCaptureCancelled, ct),
                    GetDashboardUrlsAsyncCallback = async ct =>
                    {
                        dashboardRequested.TrySetResult();
                        await Task.Delay(Timeout.InfiniteTimeSpan, ct);
                        return new DashboardUrlsState { DashboardHealthy = true };
                    }
                });
 
                await dashboardRequested.Task.WaitAsync(cancellationToken);
                await logCaptureStarted.Task.WaitAsync(cancellationToken);
 
                return CliExitCodes.FailedToDotnetRunAppHost;
            }
        };
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => interactionService;
            options.ProjectLocatorFactory = _ => projectLocator;
            options.AppHostProjectFactory = _ => projectFactory;
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse($"run --apphost {appHostFile.FullName}");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.FailedToDotnetRunAppHost, exitCode);
        await logCaptureCancelled.Task.DefaultTimeout();
        Assert.DoesNotContain(interactionService.DisplayedMessages, message => message.Message == "No longer receiving logs from AppHost.");
 
        static async IAsyncEnumerable<BackchannelLogEntry> CaptureLogsUntilCancelledAsync(
            TaskCompletionSource started,
            TaskCompletionSource cancelled,
            [EnumeratorCancellation] CancellationToken cancellationToken)
        {
            started.TrySetResult();
            try
            {
                await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
            }
            finally
            {
                if (cancellationToken.IsCancellationRequested)
                {
                    cancelled.TrySetResult();
                }
            }
 
            yield break;
        }
    }
 
    private sealed class NoProjectFileProjectLocator : IProjectLocator
    {
        public Task<List<AppHostProjectCandidate>> FindAppHostProjectsAsync(DirectoryInfo searchDirectory, AppHostDiscoveryScope scope, CancellationToken cancellationToken)
        {
            throw new Aspire.Cli.Projects.ProjectLocatorException("No project file found.", Aspire.Cli.Projects.ProjectLocatorFailureReason.NoProjectFileFound);
        }
 
        public Task<List<FileInfo>> FindAppHostProjectFilesAsync(DirectoryInfo searchDirectory, AppHostDiscoveryScope scope, CancellationToken cancellationToken)
        {
            throw new Aspire.Cli.Projects.ProjectLocatorException("No project file found.", Aspire.Cli.Projects.ProjectLocatorFailureReason.NoProjectFileFound);
        }
 
        public Task<AppHostProjectSearchResult> UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, MultipleAppHostProjectsFoundBehavior multipleAppHostProjectsFoundBehavior, bool createSettingsFile, CancellationToken cancellationToken)
        {
            throw new Aspire.Cli.Projects.ProjectLocatorException("No project file found.", Aspire.Cli.Projects.ProjectLocatorFailureReason.NoProjectFileFound);
        }
 
        public Task<FileInfo?> UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, bool createSettingsFile, CancellationToken cancellationToken)
        {
            throw new Aspire.Cli.Projects.ProjectLocatorException("No project file found.", Aspire.Cli.Projects.ProjectLocatorFailureReason.NoProjectFileFound);
        }
 
        public Task<FileInfo?> GetAppHostFromSettingsAsync(CancellationToken cancellationToken = default) => Task.FromResult<FileInfo?>(null);
    }
 
    private sealed class MultipleProjectFilesProjectLocator : IProjectLocator
    {
        public Task<List<AppHostProjectCandidate>> FindAppHostProjectsAsync(DirectoryInfo searchDirectory, AppHostDiscoveryScope scope, CancellationToken cancellationToken)
        {
            throw new Aspire.Cli.Projects.ProjectLocatorException("Multiple project files found.", Aspire.Cli.Projects.ProjectLocatorFailureReason.MultipleProjectFilesFound);
        }
 
        public Task<List<FileInfo>> FindAppHostProjectFilesAsync(DirectoryInfo searchDirectory, AppHostDiscoveryScope scope, CancellationToken cancellationToken)
        {
            throw new Aspire.Cli.Projects.ProjectLocatorException("Multiple project files found.", Aspire.Cli.Projects.ProjectLocatorFailureReason.MultipleProjectFilesFound);
        }
 
        public Task<AppHostProjectSearchResult> UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, MultipleAppHostProjectsFoundBehavior multipleAppHostProjectsFoundBehavior, bool createSettingsFile, CancellationToken cancellationToken)
        {
            throw new Aspire.Cli.Projects.ProjectLocatorException("Multiple project files found.", Aspire.Cli.Projects.ProjectLocatorFailureReason.MultipleProjectFilesFound);
        }
 
        public Task<FileInfo?> UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, bool createSettingsFile, CancellationToken cancellationToken)
        {
            throw new Aspire.Cli.Projects.ProjectLocatorException("Multiple project files found.", Aspire.Cli.Projects.ProjectLocatorFailureReason.MultipleProjectFilesFound);
        }
 
        public Task<FileInfo?> GetAppHostFromSettingsAsync(CancellationToken cancellationToken = default) => Task.FromResult<FileInfo?>(null);
    }
 
    private async IAsyncEnumerable<BackchannelLogEntry> ReturnLogEntriesUntilCancelledAsync([EnumeratorCancellation] CancellationToken cancellationToken)
    {
        var logEntryIndex = 0;
 
        while (!cancellationToken.IsCancellationRequested)
        {
            await Task.Delay(1000, cancellationToken);
            // Simulate log entries being returned
            yield return new BackchannelLogEntry
            {
                Timestamp = DateTimeOffset.UtcNow,
                LogLevel = LogLevel.Information,
                Message = $"Test log entry {logEntryIndex++}",
                EventId = new EventId(),
                CategoryName = "TestCategory"
            };
        }
    }
 
    private static async IAsyncEnumerable<BackchannelLogEntry> EmptyLogEntriesAsync([EnumeratorCancellation] CancellationToken cancellationToken)
    {
        await Task.Yield();
        yield break;
    }
 
    [Fact]
    public async Task RunCommand_CompletesSuccessfully()
    {
        var getResourceStatesAsyncCalled = new TaskCompletionSource();
 
        var backchannelFactory = (IServiceProvider sp) =>
        {
            var backchannel = new TestAppHostBackchannel();
 
            backchannel.GetAppHostLogEntriesAsyncCallback = ReturnLogEntriesUntilCancelledAsync;
 
            return backchannel;
 
        };
 
        var runnerFactory = (IServiceProvider sp) =>
        {
            var runner = new TestDotNetCliRunner();
            // Fake the build command to always succeed.
            runner.BuildAsyncCallback = (projectFile, noRestore, options, ct) => 0;
 
            // Fake apphost information to return a compatable app host.
            runner.GetAppHostInformationAsyncCallback = (projectFile, options, ct) => (0, true, VersionHelper.GetDefaultTemplateVersion());
 
            // public Task<int> RunAsync(FileInfo projectFile, bool watch, bool noBuild, string[] args, IDictionary<string, string>? env, TaskCompletionSource<AppHostCliBackchannel>? backchannelCompletionSource, CancellationToken cancellationToken)
            runner.RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, ct) =>
            {
                // Make a backchannel and return it, but don't return from the run call until the backchannel
                var backchannel = sp.GetRequiredService<IAppHostCliBackchannel>();
                backchannelCompletionSource!.SetResult(backchannel);
 
                // Just simulate the process running until the user cancels.
                await Task.Delay(Timeout.InfiniteTimeSpan, ct);
 
                return 0;
            };
 
            return runner;
        };
 
        var projectLocatorFactory = (IServiceProvider sp) => new TestProjectLocator();
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = projectLocatorFactory;
            options.AppHostBackchannelFactory = backchannelFactory;
            options.DotNetCliRunnerFactory = runnerFactory;
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("run");
 
        using var cts = new CancellationTokenSource();
        var pendingRun = result.InvokeAsync(cancellationToken: cts.Token);
 
        // Simulate CTRL-C.
        cts.Cancel();
 
        var exitCode = await pendingRun.DefaultTimeout();
        Assert.Equal(CliExitCodes.Success, exitCode);
    }
 
    [Fact]
    public async Task RunCommand_InRemoteExtensionHost_DisplaysDashboardUrlsBeforeLiveEndpointDisplayCompletes()
    {
        var displayLiveStarted = new TaskCompletionSource();
        var allowLiveDisplayToComplete = new TaskCompletionSource();
        var dashboardUrlsDisplayed = new TaskCompletionSource();
 
        var backchannelFactory = (IServiceProvider sp) =>
        {
            var backchannel = new TestAppHostBackchannel();
 
            backchannel.GetAppHostLogEntriesAsyncCallback = ReturnLogEntriesUntilCancelledAsync;
 
            return backchannel;
        };
 
        var runnerFactory = (IServiceProvider sp) =>
        {
            var runner = new TestDotNetCliRunner();
            runner.BuildAsyncCallback = (projectFile, noRestore, options, ct) => 0;
            runner.GetAppHostInformationAsyncCallback = (projectFile, options, ct) => (0, true, VersionHelper.GetDefaultTemplateVersion());
            runner.RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, ct) =>
            {
                var backchannel = sp.GetRequiredService<IAppHostCliBackchannel>();
                backchannelCompletionSource!.SetResult(backchannel);
 
                await Task.Delay(Timeout.InfiniteTimeSpan, ct);
 
                return 0;
            };
 
            return runner;
        };
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.AppHostBackchannelFactory = backchannelFactory;
            options.DotNetCliRunnerFactory = runnerFactory;
            options.ProjectLocatorFactory = _ => new TestProjectLocator();
            options.ExtensionBackchannelFactory = _ => new TestExtensionBackchannel();
            options.InteractionServiceFactory = sp => new TestExtensionInteractionService(sp)
            {
                DisplayDashboardUrlsCallback = _ => dashboardUrlsDisplayed.SetResult(),
                DisplayLiveAsyncCallback = (_, _) =>
                {
                    displayLiveStarted.SetResult();
                    return allowLiveDisplayToComplete.Task;
                }
            };
            options.ConfigurationCallback += config =>
            {
                config[KnownConfigNames.ExtensionDebugSessionId] = "test-session-id";
                config["VSCODE_IPC_HOOK_CLI"] = "test-ipc-hook";
                config["SSH_CONNECTION"] = "127.0.0.1 1 127.0.0.1 2";
            };
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("run");
 
        using var cts = new CancellationTokenSource();
        var pendingRun = result.InvokeAsync(cancellationToken: cts.Token);
 
        try
        {
            await displayLiveStarted.Task.DefaultTimeout();
 
            var completedTask = await Task.WhenAny(dashboardUrlsDisplayed.Task, Task.Delay(TimeSpan.FromSeconds(1))).DefaultTimeout();
            Assert.Same(dashboardUrlsDisplayed.Task, completedTask);
        }
        finally
        {
            allowLiveDisplayToComplete.TrySetResult();
            cts.Cancel();
 
            var exitCode = await pendingRun.DefaultTimeout();
            Assert.Equal(CliExitCodes.Success, exitCode);
        }
    }
 
    [Fact]
    public async Task RunCommand_WhenAppHostReturnsCancelled_CompletesSuccessfully()
    {
        var runnerFactory = (IServiceProvider sp) =>
        {
            var runner = new TestDotNetCliRunner();
            runner.BuildAsyncCallback = (projectFile, noRestore, options, ct) => 0;
            runner.GetAppHostInformationAsyncCallback = (projectFile, options, ct) => (0, true, VersionHelper.GetDefaultTemplateVersion());
            runner.RunAsyncCallback = (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, ct) =>
            {
                var backchannel = sp.GetRequiredService<IAppHostCliBackchannel>();
                backchannelCompletionSource!.SetResult(backchannel);
 
                return Task.FromResult(CliExitCodes.Cancelled);
            };
 
            return runner;
        };
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = _ => new TestProjectLocator();
            options.AppHostBackchannelFactory = _ => new TestAppHostBackchannel
            {
                GetDashboardUrlsAsyncCallback = _ => Task.FromResult(new DashboardUrlsState
                {
                    DashboardHealthy = true,
                    BaseUrlWithLoginToken = "http://localhost:5000/login?t=abcd"
                })
            };
            options.DotNetCliRunnerFactory = runnerFactory;
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("run");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout(TestConstants.LongTimeoutDuration);
 
        Assert.Equal(CliExitCodes.Success, exitCode);
    }
 
    [Fact]
    public async Task RunCommand_WithCaptureProfile_TreatsRequestedStopAsSuccess()
    {
        var appHostExitCode = new TaskCompletionSource<int>();
        var requestStopCalled = new TaskCompletionSource();
 
        var runnerFactory = (IServiceProvider sp) =>
        {
            var runner = new TestDotNetCliRunner();
            runner.BuildAsyncCallback = (projectFile, noRestore, options, ct) => 0;
            runner.GetAppHostInformationAsyncCallback = (projectFile, options, ct) => (0, true, VersionHelper.GetDefaultTemplateVersion());
            runner.RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, ct) =>
            {
                var backchannel = sp.GetRequiredService<IAppHostCliBackchannel>();
                backchannelCompletionSource!.SetResult(backchannel);
 
                return await appHostExitCode.Task.WaitAsync(ct);
            };
 
            return runner;
        };
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = _ => new TestProjectLocator();
            options.AppHostBackchannelFactory = _ => new TestAppHostBackchannel
            {
                RequestStopAsyncCalled = requestStopCalled,
                RequestStopAsyncCallback = () =>
                {
                    appHostExitCode.SetResult(137);
                    return Task.CompletedTask;
                },
                GetAppHostLogEntriesAsyncCallback = EmptyLogEntriesAsync
            };
            options.DotNetCliRunnerFactory = runnerFactory;
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("run --capture-profile");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout(TestConstants.LongTimeoutDuration);
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.True(requestStopCalled.Task.IsCompleted, "Capture mode should stop the AppHost after startup.");
    }
 
    [Fact]
    public async Task RunCommand_WithCaptureProfile_PreservesExitCodeWhenRunCompletesBeforeStop()
    {
        var requestStopCalled = new TaskCompletionSource();
 
        var runnerFactory = (IServiceProvider sp) =>
        {
            var runner = new TestDotNetCliRunner();
            runner.BuildAsyncCallback = (projectFile, noRestore, options, ct) => 0;
            runner.GetAppHostInformationAsyncCallback = (projectFile, options, ct) => (0, true, VersionHelper.GetDefaultTemplateVersion());
            runner.RunAsyncCallback = (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, ct) =>
            {
                var backchannel = sp.GetRequiredService<IAppHostCliBackchannel>();
                backchannelCompletionSource!.SetResult(backchannel);
 
                return Task.FromResult(123);
            };
 
            return runner;
        };
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = _ => new TestProjectLocator();
            options.AppHostBackchannelFactory = _ => new TestAppHostBackchannel
            {
                RequestStopAsyncCalled = requestStopCalled,
                GetAppHostLogEntriesAsyncCallback = EmptyLogEntriesAsync
            };
            options.DotNetCliRunnerFactory = runnerFactory;
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("run --capture-profile");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout(TestConstants.LongTimeoutDuration);
 
        Assert.Equal(123, exitCode);
        Assert.False(requestStopCalled.Task.IsCompleted, "Capture mode should not mask an AppHost exit before the stop request.");
    }
 
    [Fact]
    public async Task RunCommand_WithCaptureProfile_PropagatesFailureExitCodeAfterStop()
    {
        var appHostExitCode = new TaskCompletionSource<int>();
        var requestStopCalled = new TaskCompletionSource();
 
        var runnerFactory = (IServiceProvider sp) =>
        {
            var runner = new TestDotNetCliRunner();
            runner.BuildAsyncCallback = (projectFile, noRestore, options, ct) => 0;
            runner.GetAppHostInformationAsyncCallback = (projectFile, options, ct) => (0, true, VersionHelper.GetDefaultTemplateVersion());
            runner.RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, ct) =>
            {
                var backchannel = sp.GetRequiredService<IAppHostCliBackchannel>();
                backchannelCompletionSource!.SetResult(backchannel);
 
                return await appHostExitCode.Task.WaitAsync(ct);
            };
 
            return runner;
        };
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = _ => new TestProjectLocator();
            options.AppHostBackchannelFactory = _ => new TestAppHostBackchannel
            {
                RequestStopAsyncCalled = requestStopCalled,
                RequestStopAsyncCallback = () =>
                {
                    // Simulate an AppHost that crashes during shutdown rather than terminating
                    // via a known teardown signal. Capture mode must surface that failure.
                    appHostExitCode.SetResult(CliExitCodes.FailedToDotnetRunAppHost);
                    return Task.CompletedTask;
                },
                GetAppHostLogEntriesAsyncCallback = EmptyLogEntriesAsync
            };
            options.DotNetCliRunnerFactory = runnerFactory;
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("run --capture-profile");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout(TestConstants.LongTimeoutDuration);
 
        Assert.Equal(CliExitCodes.FailedToDotnetRunAppHost, exitCode);
        Assert.True(requestStopCalled.Task.IsCompleted, "Capture mode should stop the AppHost after startup.");
    }
 
    [Fact]
    public async Task RunCommand_WithNoResources_CompletesSuccessfully()
    {
        var getResourceStatesAsyncCalled = new TaskCompletionSource();
        var backchannelFactory = (IServiceProvider sp) =>
        {
            var backchannel = new TestAppHostBackchannel();
 
            // Return empty resources using an empty enumerable
            backchannel.GetAppHostLogEntriesAsyncCallback = ReturnLogEntriesUntilCancelledAsync;
 
            return backchannel;
        };
 
        var runnerFactory = (IServiceProvider sp) =>
        {
            var runner = new TestDotNetCliRunner();
            runner.BuildAsyncCallback = (projectFile, noRestore, options, ct) => 0;
            runner.GetAppHostInformationAsyncCallback = (projectFile, options, ct) => (0, true, VersionHelper.GetDefaultTemplateVersion());
 
            runner.RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, ct) =>
            {
                var backchannel = sp.GetRequiredService<IAppHostCliBackchannel>();
                backchannelCompletionSource!.SetResult(backchannel);
                await Task.Delay(Timeout.InfiniteTimeSpan, ct);
                return 0;
            };
 
            return runner;
        };
 
        var projectLocatorFactory = (IServiceProvider sp) => new TestProjectLocator();
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = projectLocatorFactory;
            options.AppHostBackchannelFactory = backchannelFactory;
            options.DotNetCliRunnerFactory = runnerFactory;
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("run");
 
        using var cts = new CancellationTokenSource();
        var pendingRun = result.InvokeAsync(cancellationToken: cts.Token);
 
        // Simulate CTRL-C.
        cts.Cancel();
 
        var exitCode = await pendingRun.DefaultTimeout(TestConstants.LongTimeoutDuration);
        Assert.Equal(CliExitCodes.Success, exitCode);
    }
 
    [Fact]
    public void RenderAppHostSummary_RendersLogsPathAsClickableFileLink()
    {
        var output = new StringBuilder();
        var console = AnsiConsole.Create(new AnsiConsoleSettings
        {
            Ansi = AnsiSupport.Yes,
            ColorSystem = ColorSystemSupport.TrueColor,
            Out = new AnsiConsoleOutput(new StringWriter(output)),
            Enrichment = new ProfileEnrichment { UseDefaultEnrichers = false }
        });
        console.Profile.Width = int.MaxValue;
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var logFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "cli [run].log");
        var executionContext = workspace.CreateExecutionContext(logFilePath: logFilePath);
 
        var interactionService = new ConsoleInteractionService(
            new ConsoleEnvironment(console, console),
            executionContext,
            TestHelpers.CreateInteractiveHostEnvironment(),
            new EnvironmentProcessPathProvider(),
            NullLoggerFactory.Instance,
            new ConsoleLogBufferContext());
 
        RunCommand.RenderAppHostSummary(
            interactionService,
            "AppHost.csproj",
            dashboardUrl: "http://localhost:1234",
            codespacesUrl: null,
            logFilePath,
            isExtensionHost: false);
 
        var outputString = output.ToString();
        var fileUri = new Uri(Path.GetFullPath(logFilePath)).AbsoluteUri;
 
        Assert.Contains("Logs", outputString);
        TerminalLinkAssert.ContainsLink(outputString, fileUri, logFilePath);
    }
 
    [Fact]
    public async Task RunCommand_WhenDashboardFailsToStart_ContinuesWithWarning()
    {
 
        var backchannelFactory = (IServiceProvider sp) =>
        {
            var backchannel = new TestAppHostBackchannel();
            // Configure the backchannel to return unhealthy dashboard state
            backchannel.GetDashboardUrlsAsyncCallback = (ct) =>
            {
                return Task.FromResult(new DashboardUrlsState
                {
                    DashboardHealthy = false,
                    BaseUrlWithLoginToken = null,
                    CodespacesUrlWithLoginToken = null
                });
            };
            return backchannel;
        };
 
        var runnerFactory = (IServiceProvider sp) =>
        {
            var runner = new TestDotNetCliRunner();
            // Fake the build command to always succeed.
            runner.BuildAsyncCallback = (projectFile, noRestore, options, ct) => 0;
 
            // Fake apphost information to return a compatible app host.
            runner.GetAppHostInformationAsyncCallback = (projectFile, options, ct) => (0, true, VersionHelper.GetDefaultTemplateVersion());
 
            // Configure the runner to establish a backchannel but simulate dashboard failure
            runner.RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, ct) =>
            {
                // Set up the backchannel
                var backchannel = sp.GetRequiredService<IAppHostCliBackchannel>();
                backchannelCompletionSource!.SetResult(backchannel);
 
                // Just simulate the process running until the user cancels.
                await Task.Delay(Timeout.InfiniteTimeSpan, ct);
 
                return 0;
            };
 
            return runner;
        };
 
        var projectLocatorFactory = (IServiceProvider sp) => new TestProjectLocator();
        var testInteractionService = new TestInteractionService();
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = projectLocatorFactory;
            options.AppHostBackchannelFactory = backchannelFactory;
            options.DotNetCliRunnerFactory = runnerFactory;
            options.InteractionServiceFactory = (sp) => testInteractionService;
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("run");
 
        using var cts = new CancellationTokenSource();
        var pendingRun = result.InvokeAsync(cancellationToken: cts.Token);
 
        // Simulate CTRL-C - the command should continue past the unhealthy dashboard
        cts.Cancel();
 
        var exitCode = await pendingRun.DefaultTimeout(TestConstants.LongTimeoutDuration);
 
        // The command should handle cancellation gracefully
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.Single(testInteractionService.DisplayedCancellations);
 
        // Verify a warning was displayed (not an error)
        var m = Assert.Single(testInteractionService.DisplayedMessages);
        Assert.Equal(KnownEmojis.Warning, m.Emoji);
        Assert.Equal(RunCommandStrings.DashboardFailedToStart, m.Message);
        Assert.Empty(testInteractionService.DisplayedErrors);
    }
 
    [Fact]
    public async Task AppHostHelper_BuildAppHostAsync_IncludesRelativePathInStatusMessage()
    {
        var testInteractionService = new TestInteractionService();
        testInteractionService.ShowStatusCallback = (statusText) =>
        {
            Assert.Contains(
                $"{InteractionServiceStrings.BuildingAppHost} src{Path.DirectorySeparatorChar}MyApp.AppHost{Path.DirectorySeparatorChar}MyApp.AppHost.csproj",
                statusText
            );
        };
 
        var testRunner = new TestDotNetCliRunner();
        testRunner.BuildAsyncCallback = (projectFile, noRestore, options, ct) => 0;
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostDirectoryPath = Path.Combine(workspace.WorkspaceRoot.FullName, "src", "MyApp.AppHost");
        var appHostDirectory = Directory.CreateDirectory(appHostDirectoryPath);
        var appHostProjectPath = Path.Combine(appHostDirectory.FullName, "MyApp.AppHost.csproj");
        var appHostProjectFile = new FileInfo(appHostProjectPath);
        File.WriteAllText(appHostProjectFile.FullName, "<Project></Project>");
 
        var options = new ProcessInvocationOptions();
        await AppHostHelper.BuildAppHostAsync(testRunner, testInteractionService, appHostProjectFile, noRestore: false, options, workspace.WorkspaceRoot, CancellationToken.None).DefaultTimeout();
    }
 
    [Fact]
    public async Task RunCommand_SkipsBuild_WhenBuildDotNetUsingCliCapabilityIsAvailable()
    {
        var buildCalled = false;
 
        var extensionBackchannel = new TestExtensionBackchannel();
        extensionBackchannel.GetCapabilitiesAsyncCallback = ct => Task.FromResult(new[] { "devkit" });
 
        var appHostBackchannel = new TestAppHostBackchannel();
        appHostBackchannel.GetDashboardUrlsAsyncCallback = (ct) => Task.FromResult(new DashboardUrlsState
        {
            DashboardHealthy = true,
            BaseUrlWithLoginToken = "http://localhost/dashboard",
            CodespacesUrlWithLoginToken = null
        });
        appHostBackchannel.GetAppHostLogEntriesAsyncCallback = ReturnLogEntriesUntilCancelledAsync;
 
        var backchannelFactory = (IServiceProvider sp) => appHostBackchannel;
 
        var extensionInteractionServiceFactory = (IServiceProvider sp) => new TestExtensionInteractionService(sp);
 
        var runnerFactory = (IServiceProvider sp) =>
        {
            var runner = new TestDotNetCliRunner();
            runner.BuildAsyncCallback = (projectFile, noRestore, options, ct) =>
            {
                buildCalled = true;
                return 0;
            };
            runner.GetAppHostInformationAsyncCallback = (projectFile, options, ct) => (0, true, VersionHelper.GetDefaultTemplateVersion());
            runner.RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, ct) =>
            {
                var backchannel = sp.GetRequiredService<IAppHostCliBackchannel>();
                backchannelCompletionSource!.SetResult(backchannel);
                await Task.Delay(Timeout.InfiniteTimeSpan, ct);
                return 0;
            };
            return runner;
        };
 
        var projectLocatorFactory = (IServiceProvider sp) => new TestProjectLocator();
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = projectLocatorFactory;
            options.AppHostBackchannelFactory = backchannelFactory;
            options.DotNetCliRunnerFactory = runnerFactory;
            options.ExtensionBackchannelFactory = _ => extensionBackchannel;
            options.InteractionServiceFactory = extensionInteractionServiceFactory;
            options.ConfigurationCallback += config =>
            {
                // Set debug session ID so the run command doesn't return early
                config["ASPIRE_EXTENSION_DEBUG_SESSION_ID"] = "test-session-id";
            };
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("run");
 
        using var cts = new CancellationTokenSource();
        var pendingRun = result.InvokeAsync(cancellationToken: cts.Token);
        cts.Cancel();
        var exitCode = await pendingRun.DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.False(buildCalled, "Build should be skipped when extension DevKit capability is available.");
    }
 
    [Fact]
    public async Task RunCommand_SkipsBuild_WhenRunningInExtension_AndNoBuildInCliCapability()
    {
        var buildCalled = false;
 
        var extensionBackchannel = new TestExtensionBackchannel();
        extensionBackchannel.GetCapabilitiesAsyncCallback = ct => Task.FromResult(Array.Empty<string>());
 
        var appHostBackchannel = new TestAppHostBackchannel();
        appHostBackchannel.GetDashboardUrlsAsyncCallback = (ct) => Task.FromResult(new DashboardUrlsState
        {
            DashboardHealthy = true,
            BaseUrlWithLoginToken = "http://localhost/dashboard",
            CodespacesUrlWithLoginToken = null
        });
        appHostBackchannel.GetAppHostLogEntriesAsyncCallback = ReturnLogEntriesUntilCancelledAsync;
 
        var backchannelFactory = (IServiceProvider sp) => appHostBackchannel;
 
        var extensionInteractionServiceFactory = (IServiceProvider sp) => new TestExtensionInteractionService(sp);
 
        var runnerFactory = (IServiceProvider sp) =>
        {
            var runner = new TestDotNetCliRunner();
            runner.BuildAsyncCallback = (projectFile, noRestore, options, ct) =>
            {
                buildCalled = true;
                return 0;
            };
            runner.GetAppHostInformationAsyncCallback = (projectFile, options, ct) => (0, true, VersionHelper.GetDefaultTemplateVersion());
            runner.RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, ct) =>
            {
                var backchannel = sp.GetRequiredService<IAppHostCliBackchannel>();
                backchannelCompletionSource!.SetResult(backchannel);
                await Task.Delay(Timeout.InfiniteTimeSpan, ct);
                return 0;
            };
            return runner;
        };
 
        var projectLocatorFactory = (IServiceProvider sp) => new TestProjectLocator();
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = projectLocatorFactory;
            options.AppHostBackchannelFactory = backchannelFactory;
            options.DotNetCliRunnerFactory = runnerFactory;
            options.ExtensionBackchannelFactory = _ => extensionBackchannel;
            options.InteractionServiceFactory = extensionInteractionServiceFactory;
            options.ConfigurationCallback += config =>
            {
                // Set debug session ID so the run command doesn't return early
                config["ASPIRE_EXTENSION_DEBUG_SESSION_ID"] = "test-session-id";
            };
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("run");
 
        using var cts = new CancellationTokenSource();
        var pendingRun = result.InvokeAsync(cancellationToken: cts.Token);
        cts.Cancel();
        var exitCode = await pendingRun.DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.False(buildCalled, "Build should be skipped when running in extension.");
    }
 
    [Fact]
    public async Task RunCommand_Builds_WhenExtensionHasBuildDotnetUsingCliCapability()
    {
        var buildCalled = false;
        var buildCalledTcs = new TaskCompletionSource();
 
        var extensionBackchannel = new TestExtensionBackchannel();
        extensionBackchannel.GetCapabilitiesAsyncCallback = ct => Task.FromResult(new[] { "build-dotnet-using-cli" });
 
        var appHostBackchannel = new TestAppHostBackchannel();
        appHostBackchannel.GetDashboardUrlsAsyncCallback = (ct) => Task.FromResult(new DashboardUrlsState
        {
            DashboardHealthy = true,
            BaseUrlWithLoginToken = "http://localhost/dashboard",
            CodespacesUrlWithLoginToken = null
        });
        appHostBackchannel.GetAppHostLogEntriesAsyncCallback = ReturnLogEntriesUntilCancelledAsync;
 
        var backchannelFactory = (IServiceProvider sp) => appHostBackchannel;
 
        var extensionInteractionServiceFactory = (IServiceProvider sp) => new TestExtensionInteractionService(sp);
 
        var runnerFactory = (IServiceProvider sp) => {
            var runner = new TestDotNetCliRunner();
            runner.BuildAsyncCallback = (projectFile, noRestore, options, ct) => {
                buildCalled = true;
                buildCalledTcs.TrySetResult();
                return 0;
            };
            runner.GetAppHostInformationAsyncCallback = (projectFile, options, ct) => (0, true, VersionHelper.GetDefaultTemplateVersion());
            runner.RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, ct) => {
                var backchannel = sp.GetRequiredService<IAppHostCliBackchannel>();
                backchannelCompletionSource!.SetResult(backchannel);
                await Task.Delay(Timeout.InfiniteTimeSpan, ct);
                return 0;
            };
            return runner;
        };
 
        var projectLocatorFactory = (IServiceProvider sp) => new TestProjectLocator();
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = projectLocatorFactory;
            options.AppHostBackchannelFactory = backchannelFactory;
            options.DotNetCliRunnerFactory = runnerFactory;
            options.ExtensionBackchannelFactory = _ => extensionBackchannel;
            options.InteractionServiceFactory = extensionInteractionServiceFactory;
            options.ConfigurationCallback += config =>
            {
                // Set debug session ID so the run command doesn't return early
                config["ASPIRE_EXTENSION_DEBUG_SESSION_ID"] = "test-session-id";
            };
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("run --start-debug-session");
 
        using var cts = new CancellationTokenSource();
        var pendingRun = result.InvokeAsync(cancellationToken: cts.Token);
 
        // Wait for the build to be called before cancelling
        await buildCalledTcs.Task.DefaultTimeout();
        cts.Cancel();
 
        var exitCode = await pendingRun.DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.True(buildCalled, "Build should be called when extension has build-dotnet-using-cli capability.");
    }
 
    [Fact]
    public async Task RunCommand_WhenExtensionNoDebugBuildFails_DoesNotRunAppHost()
    {
        var buildCalled = false;
        var runCalled = false;
 
        var extensionBackchannel = new TestExtensionBackchannel();
        extensionBackchannel.HasCapabilityAsyncCallback = (capability, ct) => Task.FromResult(capability == KnownCapabilities.BuildDotnetUsingCli);
 
        var extensionInteractionServiceFactory = (IServiceProvider sp) => new TestExtensionInteractionService(sp);
 
        var runnerFactory = (IServiceProvider sp) =>
        {
            var runner = new TestDotNetCliRunner();
            runner.BuildAsyncCallback = (projectFile, noRestore, options, ct) =>
            {
                buildCalled = true;
                return 1;
            };
            runner.GetAppHostInformationAsyncCallback = (projectFile, options, ct) => (0, true, VersionHelper.GetDefaultTemplateVersion());
            runner.RunAsyncCallback = (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, ct) =>
            {
                runCalled = true;
                return Task.FromResult(0);
            };
            return runner;
        };
 
        var projectLocatorFactory = (IServiceProvider sp) => new TestProjectLocator();
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = projectLocatorFactory;
            options.DotNetCliRunnerFactory = runnerFactory;
            options.ExtensionBackchannelFactory = _ => extensionBackchannel;
            options.InteractionServiceFactory = extensionInteractionServiceFactory;
            options.ConfigurationCallback += config =>
            {
                config["ASPIRE_EXTENSION_DEBUG_SESSION_ID"] = "test-session-id";
            };
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("run");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.FailedToBuildArtifacts, exitCode);
        Assert.True(buildCalled, "Build should be called before launching the AppHost in extension no-debug mode.");
        Assert.False(runCalled, "AppHost should not be launched when the pre-build fails.");
    }
 
    [Fact]
    public async Task RunCommand_WhenExtensionBuildFails_WaitsForBuildOutputToFlush()
    {
        var displayLinesStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var allowDisplayLinesToComplete = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        DisplayLineState[] displayedLines = [];
        var buildError = "error CS0103: The name 'MissingSymbol' does not exist in the current context";
 
        var extensionBackchannel = new TestExtensionBackchannel();
        extensionBackchannel.HasCapabilityAsyncCallback = (capability, ct) => Task.FromResult(capability == KnownCapabilities.BuildDotnetUsingCli);
        extensionBackchannel.DisplayLinesAsyncCallback = async lines =>
        {
            displayedLines = lines.ToArray();
            displayLinesStarted.TrySetResult();
            await allowDisplayLinesToComplete.Task;
        };
 
        var runnerFactory = (IServiceProvider sp) =>
        {
            var runner = new TestDotNetCliRunner();
            runner.BuildAsyncCallback = (projectFile, noRestore, options, ct) =>
            {
                options.StandardErrorCallback?.Invoke(buildError);
                return 1;
            };
            runner.GetAppHostInformationAsyncCallback = (projectFile, options, ct) => (0, true, VersionHelper.GetDefaultTemplateVersion());
            return runner;
        };
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = _ => new TestProjectLocator();
            options.DotNetCliRunnerFactory = runnerFactory;
            options.ExtensionBackchannelFactory = _ => extensionBackchannel;
            options.InteractionServiceFactory = sp =>
            {
                var consoleEnvironment = sp.GetRequiredService<ConsoleEnvironment>();
                var executionContext = sp.GetRequiredService<CliExecutionContext>();
                var hostEnvironment = sp.GetRequiredService<ICliHostEnvironment>();
                var processPathProvider = sp.GetRequiredService<IProcessPathProvider>();
                var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
                var logBufferContext = sp.GetRequiredService<ConsoleLogBufferContext>();
                var consoleInteractionService = new ConsoleInteractionService(consoleEnvironment, executionContext, hostEnvironment, processPathProvider, loggerFactory, logBufferContext);
 
                return new ExtensionInteractionService(consoleInteractionService, extensionBackchannel, extensionPromptEnabled: false, logger: NullLogger<ExtensionInteractionService>.Instance);
            };
            options.ConfigurationCallback += config =>
            {
                config["ASPIRE_EXTENSION_DEBUG_SESSION_ID"] = "test-session-id";
            };
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("run");
 
        var pendingRun = result.InvokeAsync();
 
        try
        {
            await displayLinesStarted.Task.DefaultTimeout();
            Assert.False(pendingRun.IsCompleted, "The command should not exit before queued extension debug console output is flushed.");
        }
        finally
        {
            allowDisplayLinesToComplete.TrySetResult();
        }
 
        var exitCode = await pendingRun.DefaultTimeout();
 
        Assert.Equal(CliExitCodes.FailedToBuildArtifacts, exitCode);
        Assert.Contains(displayedLines, line => line.Stream == "stderr" && line.Line == buildError);
    }
 
    [Fact]
    public async Task RunCommand_WhenSingleFileAppHostAndDefaultWatchEnabled_DoesNotUseWatchMode()
    {
        var watchModeUsed = false;
 
        var runnerFactory = (IServiceProvider sp) =>
        {
            var runner = new TestDotNetCliRunner();
            // Fake the build command to always succeed.
            runner.BuildAsyncCallback = (projectFile, noRestore, options, ct) => 0;
 
            // Fake apphost information to return a compatible app host.
            runner.GetAppHostInformationAsyncCallback = (projectFile, options, ct) => (0, true, VersionHelper.GetDefaultTemplateVersion());
 
            runner.RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, ct) =>
            {
                watchModeUsed = watch;
                // Make a backchannel and return it
                var backchannel = sp.GetRequiredService<IAppHostCliBackchannel>();
                backchannelCompletionSource!.SetResult(backchannel);
 
                // Don't run indefinitely for the test
                await Task.Delay(100, ct);
                return 0;
            };
 
            return runner;
        };
 
        var backchannelFactory = (IServiceProvider sp) =>
        {
            var backchannel = new TestAppHostBackchannel();
            backchannel.GetAppHostLogEntriesAsyncCallback = ReturnLogEntriesUntilCancelledAsync;
            return backchannel;
        };
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = _ => new SingleFileAppHostProjectLocator();
            options.AppHostBackchannelFactory = backchannelFactory;
            options.DotNetCliRunnerFactory = runnerFactory;
            options.EnabledFeatures = [KnownFeatures.DefaultWatchEnabled];
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("run");
 
        using var cts = new CancellationTokenSource();
        cts.CancelAfter(TimeSpan.FromSeconds(2));
 
        var exitCode = await result.InvokeAsync(cancellationToken: cts.Token).DefaultTimeout();
 
        Assert.False(watchModeUsed, "Expected watch mode to be disabled for single file apps even when DefaultWatchEnabled feature flag is true");
    }
 
    [Fact]
    public async Task RunCommand_WhenDefaultWatchEnabledFeatureFlagIsTrue_UsesWatchMode()
    {
        var watchModeUsed = false;
 
        var runnerFactory = (IServiceProvider sp) =>
        {
            var runner = new TestDotNetCliRunner();
            // Fake the build command to always succeed.
            runner.BuildAsyncCallback = (projectFile, noRestore, options, ct) => 0;
 
            // Fake apphost information to return a compatible app host.
            runner.GetAppHostInformationAsyncCallback = (projectFile, options, ct) => (0, true, VersionHelper.GetDefaultTemplateVersion());
 
            runner.RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, ct) =>
            {
                watchModeUsed = watch;
                // Make a backchannel and return it
                var backchannel = sp.GetRequiredService<IAppHostCliBackchannel>();
                backchannelCompletionSource!.SetResult(backchannel);
 
                // Don't run indefinitely for the test
                await Task.Delay(100, ct);
                return 0;
            };
 
            return runner;
        };
 
        var backchannelFactory = (IServiceProvider sp) =>
        {
            var backchannel = new TestAppHostBackchannel();
            backchannel.GetAppHostLogEntriesAsyncCallback = ReturnLogEntriesUntilCancelledAsync;
            return backchannel;
        };
 
        var projectLocatorFactory = (IServiceProvider sp) => new TestProjectLocator();
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = projectLocatorFactory;
            options.AppHostBackchannelFactory = backchannelFactory;
            options.DotNetCliRunnerFactory = runnerFactory;
            options.EnabledFeatures = [KnownFeatures.DefaultWatchEnabled];
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("run");
 
        using var cts = new CancellationTokenSource();
        cts.CancelAfter(TimeSpan.FromSeconds(2));
 
        var exitCode = await result.InvokeAsync(cancellationToken: cts.Token).DefaultTimeout();
 
        Assert.True(watchModeUsed, "Expected watch mode to be enabled when defaultWatchEnabled feature flag is true");
    }
 
    [Fact]
    public async Task RunCommand_WhenDefaultWatchEnabledFeatureFlagIsTrueAndBuildFails_ReturnsBuildFailure()
    {
        var runCalled = false;
 
        var runnerFactory = (IServiceProvider sp) =>
        {
            var runner = new TestDotNetCliRunner();
            runner.BuildAsyncCallback = (projectFile, noRestore, options, ct) =>
            {
                options.StandardErrorCallback?.Invoke("error CS0103: The name 'MissingSymbol' does not exist in the current context");
                return 1;
            };
 
            runner.GetAppHostInformationAsyncCallback = (projectFile, options, ct) => (0, true, VersionHelper.GetDefaultTemplateVersion());
 
            runner.RunAsyncCallback = (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, ct) =>
            {
                runCalled = true;
                return Task.FromResult(0);
            };
 
            return runner;
        };
 
        var backchannelFactory = (IServiceProvider sp) =>
        {
            var backchannel = new TestAppHostBackchannel();
            backchannel.GetAppHostLogEntriesAsyncCallback = ReturnLogEntriesUntilCancelledAsync;
            return backchannel;
        };
 
        var projectLocatorFactory = (IServiceProvider sp) => new TestProjectLocator();
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = projectLocatorFactory;
            options.AppHostBackchannelFactory = backchannelFactory;
            options.DotNetCliRunnerFactory = runnerFactory;
            options.EnabledFeatures = [KnownFeatures.DefaultWatchEnabled];
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("run");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.FailedToBuildArtifacts, exitCode);
        Assert.False(runCalled, "The AppHost should not be started when the initial build fails in watch mode.");
    }
 
    [Fact]
    public async Task RunCommand_WhenDefaultWatchEnabledFeatureFlagIsFalse_DoesNotUseWatchMode()
    {
        var watchModeUsed = false;
 
        var runnerFactory = (IServiceProvider sp) =>
        {
            var runner = new TestDotNetCliRunner();
            // Fake the build command to always succeed.
            runner.BuildAsyncCallback = (projectFile, noRestore, options, ct) => 0;
 
            // Fake apphost information to return a compatible app host.
            runner.GetAppHostInformationAsyncCallback = (projectFile, options, ct) => (0, true, VersionHelper.GetDefaultTemplateVersion());
 
            runner.RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, ct) =>
            {
                watchModeUsed = watch;
                // Make a backchannel and return it
                var backchannel = sp.GetRequiredService<IAppHostCliBackchannel>();
                backchannelCompletionSource!.SetResult(backchannel);
 
                // Don't run indefinitely for the test
                await Task.Delay(100, ct);
                return 0;
            };
 
            return runner;
        };
 
        var backchannelFactory = (IServiceProvider sp) =>
        {
            var backchannel = new TestAppHostBackchannel();
            backchannel.GetAppHostLogEntriesAsyncCallback = ReturnLogEntriesUntilCancelledAsync;
            return backchannel;
        };
 
        var projectLocatorFactory = (IServiceProvider sp) => new TestProjectLocator();
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = projectLocatorFactory;
            options.AppHostBackchannelFactory = backchannelFactory;
            options.DotNetCliRunnerFactory = runnerFactory;
            options.DisabledFeatures = [KnownFeatures.DefaultWatchEnabled];
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("run");
 
        using var cts = new CancellationTokenSource();
        cts.CancelAfter(TimeSpan.FromSeconds(2));
 
        var exitCode = await result.InvokeAsync(cancellationToken: cts.Token).DefaultTimeout();
 
        Assert.False(watchModeUsed, "Expected watch mode to be disabled when defaultWatchEnabled feature flag is false");
    }
 
    [Fact]
    public async Task RunCommand_WhenDefaultWatchEnabledFeatureFlagNotSet_DefaultsToFalse()
    {
        var watchModeUsed = false;
 
        var runnerFactory = (IServiceProvider sp) =>
        {
            var runner = new TestDotNetCliRunner();
            // Fake the build command to always succeed.
            runner.BuildAsyncCallback = (projectFile, noRestore, options, ct) => 0;
 
            // Fake apphost information to return a compatible app host.
            runner.GetAppHostInformationAsyncCallback = (projectFile, options, ct) => (0, true, VersionHelper.GetDefaultTemplateVersion());
 
            runner.RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, ct) =>
            {
                watchModeUsed = watch;
                // Make a backchannel and return it
                var backchannel = sp.GetRequiredService<IAppHostCliBackchannel>();
                backchannelCompletionSource!.SetResult(backchannel);
 
                // Don't run indefinitely for the test
                await Task.Delay(100, ct);
                return 0;
            };
 
            return runner;
        };
 
        var backchannelFactory = (IServiceProvider sp) =>
        {
            var backchannel = new TestAppHostBackchannel();
            backchannel.GetAppHostLogEntriesAsyncCallback = ReturnLogEntriesUntilCancelledAsync;
            return backchannel;
        };
 
        var projectLocatorFactory = (IServiceProvider sp) => new TestProjectLocator();
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = projectLocatorFactory;
            options.AppHostBackchannelFactory = backchannelFactory;
            options.DotNetCliRunnerFactory = runnerFactory;
            // Don't explicitly set the feature flag
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("run");
 
        using var cts = new CancellationTokenSource();
        cts.CancelAfter(TimeSpan.FromSeconds(2));
 
        var exitCode = await result.InvokeAsync(cancellationToken: cts.Token).DefaultTimeout();
 
        Assert.False(watchModeUsed, "Expected watch mode to be disabled by default when defaultWatchEnabled feature flag is not set");
    }
 
    [Fact]
    public async Task DotNetCliRunner_RunAsync_WhenWatchIsTrue_IncludesNonInteractiveFlag()
    {
        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 logger = provider.GetRequiredService<ILogger<DotNetCliRunner>>();
 
        var options = new ProcessInvocationOptions();
 
        var executionContext = workspace.CreateExecutionContext();
 
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (args, env, workingDirectory, options) =>
            {
                // Verify that --non-interactive is included when watch mode is enabled
                Assert.Contains("watch", args);
                Assert.Contains("--non-interactive", args);
 
                // Verify the order: watch should come before --non-interactive
                var watchIndex = Array.IndexOf(args, "watch");
                var nonInteractiveIndex = Array.IndexOf(args, "--non-interactive");
                Assert.True(watchIndex < nonInteractiveIndex);
            },
            0,
            logger: logger
        );
 
        var exitCode = await runner.RunAsync(
            projectFile: projectFile,
            watch: true, // This should add --non-interactive
            noBuild: false,
            noRestore: false,
            args: ["--operation", "inspect"],
            env: new Dictionary<string, string>(),
            null,
            options,
            CancellationToken.None
        );
 
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task DotNetCliRunner_RunAsync_WhenWatchIsFalse_DoesNotIncludeNonInteractiveFlag()
    {
        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 logger = provider.GetRequiredService<ILogger<DotNetCliRunner>>();
 
        var options = new ProcessInvocationOptions();
 
        var executionContext = workspace.CreateExecutionContext();
 
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (args, env, workingDirectory, options) =>
            {
                // Verify that --non-interactive is NOT included when watch mode is disabled
                Assert.Contains("run", args);
                Assert.DoesNotContain("watch", args);
                Assert.DoesNotContain("--non-interactive", args);
            },
            0,
            logger: logger
        );
 
        var exitCode = await runner.RunAsync(
            projectFile: projectFile,
            watch: false, // This should NOT add --non-interactive
            noBuild: false,
            noRestore: false,
            args: ["--operation", "inspect"],
            env: new Dictionary<string, string>(),
            null,
            options,
            CancellationToken.None
        );
 
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task DotNetCliRunner_RunAsync_WhenWatchIsTrueAndDebugIsTrue_IncludesVerboseFlag()
    {
        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 logger = provider.GetRequiredService<ILogger<DotNetCliRunner>>();
 
        var options = new ProcessInvocationOptions { Debug = true };
 
        var executionContext = workspace.CreateExecutionContext();
 
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (args, env, workingDirectory, options) =>
            {
                // Verify that --verbose is included when watch mode and debug are both enabled
                Assert.Contains("watch", args);
                Assert.Contains("--verbose", args);
 
                // Verify the order: watch should come before --verbose
                var watchIndex = Array.IndexOf(args, "watch");
                var verboseIndex = Array.IndexOf(args, "--verbose");
                Assert.True(watchIndex < verboseIndex);
            },
            0,
            logger: logger
        );
 
        var exitCode = await runner.RunAsync(
            projectFile: projectFile,
            watch: true, // This should add --verbose when debug is true
            noBuild: false,
            noRestore: false,
            args: ["--operation", "inspect"],
            env: new Dictionary<string, string>(),
            null,
            options,
            CancellationToken.None
        );
 
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task DotNetCliRunner_RunAsync_WhenWatchIsTrueAndDebugIsFalse_DoesNotIncludeVerboseFlag()
    {
        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 logger = provider.GetRequiredService<ILogger<DotNetCliRunner>>();
 
        var options = new ProcessInvocationOptions { Debug = false };
 
        var executionContext = workspace.CreateExecutionContext();
 
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (args, env, workingDirectory, options) =>
            {
                // Verify that --verbose is NOT included when debug is false
                Assert.Contains("watch", args);
                Assert.DoesNotContain("--verbose", args);
            },
            0,
            logger: logger
        );
 
        var exitCode = await runner.RunAsync(
            projectFile: projectFile,
            watch: true, // This should NOT add --verbose when debug is false
            noBuild: false,
            noRestore: false,
            args: ["--operation", "inspect"],
            env: new Dictionary<string, string>(),
            null,
            options,
            CancellationToken.None
        );
 
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task DotNetCliRunner_RunAsync_WhenWatchIsFalseAndDebugIsTrue_DoesNotIncludeVerboseFlag()
    {
        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 logger = provider.GetRequiredService<ILogger<DotNetCliRunner>>();
 
        var options = new ProcessInvocationOptions { Debug = true };
 
        var executionContext = workspace.CreateExecutionContext();
 
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (args, env, workingDirectory, options) =>
            {
                // Verify that --verbose is NOT included when watch is false even if debug is true
                Assert.Contains("run", args);
                Assert.DoesNotContain("watch", args);
                Assert.DoesNotContain("--verbose", args);
            },
            0,
            logger: logger
        );
 
        var exitCode = await runner.RunAsync(
            projectFile: projectFile,
            watch: false, // This should NOT add --verbose because it's not in watch mode
            noBuild: false,
            noRestore: false,
            args: ["--operation", "inspect"],
            env: new Dictionary<string, string>(),
            null,
            options,
            CancellationToken.None
        );
 
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task DotNetCliRunner_RunAsync_WhenWatchIsTrue_SetsSuppressLaunchBrowserEnvironmentVariable()
    {
        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 logger = provider.GetRequiredService<ILogger<DotNetCliRunner>>();
 
        var options = new ProcessInvocationOptions();
 
        var executionContext = workspace.CreateExecutionContext();
 
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (args, env, workingDirectory, options) =>
            {
                // Verify that DOTNET_WATCH_SUPPRESS_LAUNCH_BROWSER is set when watch mode is enabled
                Assert.NotNull(env);
                Assert.True(env.ContainsKey("DOTNET_WATCH_SUPPRESS_LAUNCH_BROWSER"));
                Assert.Equal("true", env["DOTNET_WATCH_SUPPRESS_LAUNCH_BROWSER"]);
            },
            0,
            logger: logger
        );
 
        var exitCode = await runner.RunAsync(
            projectFile: projectFile,
            watch: true, // This should set DOTNET_WATCH_SUPPRESS_LAUNCH_BROWSER=true
            noBuild: false,
            noRestore: false,
            args: ["--operation", "inspect"],
            env: new Dictionary<string, string>(),
            null,
            options,
            CancellationToken.None
        );
 
        Assert.Equal(0, exitCode);
    }
 
    [Fact]
    public async Task DotNetCliRunner_RunAsync_WhenWatchIsFalse_DoesNotSetSuppressLaunchBrowserEnvironmentVariable()
    {
        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 logger = provider.GetRequiredService<ILogger<DotNetCliRunner>>();
 
        var options = new ProcessInvocationOptions();
 
        var executionContext = workspace.CreateExecutionContext();
 
        var runner = DotNetCliRunnerTestHelper.Create(
            provider,
            executionContext,
            (args, env, workingDirectory, options) =>
            {
                // Verify that DOTNET_WATCH_SUPPRESS_LAUNCH_BROWSER is NOT set when watch mode is disabled
                if (env != null)
                {
                    Assert.False(env.ContainsKey("DOTNET_WATCH_SUPPRESS_LAUNCH_BROWSER"));
                }
            },
            0,
            logger: logger
        );
 
        var exitCode = await runner.RunAsync(
            projectFile: projectFile,
            watch: false, // This should NOT set DOTNET_WATCH_SUPPRESS_LAUNCH_BROWSER
            noBuild: false,
            noRestore: false,
            args: ["--operation", "inspect"],
            env: new Dictionary<string, string>(),
            null,
            options,
            CancellationToken.None
        );
 
        Assert.Equal(0, exitCode);
    }
 
    private sealed class SingleFileAppHostProjectLocator : Aspire.Cli.Projects.IProjectLocator
    {
        public Task<List<AppHostProjectCandidate>> FindAppHostProjectsAsync(DirectoryInfo searchDirectory, AppHostDiscoveryScope scope, CancellationToken cancellationToken)
        {
            var appHostFile = new FileInfo("/tmp/apphost.cs");
            return Task.FromResult<List<AppHostProjectCandidate>>([new(appHostFile, "test")]);
        }
 
        public Task<List<FileInfo>> FindAppHostProjectFilesAsync(DirectoryInfo searchDirectory, AppHostDiscoveryScope scope, CancellationToken cancellationToken)
        {
            return Task.FromResult<List<FileInfo>>([new FileInfo("/tmp/apphost.cs")]);
        }
 
        public Task<AppHostProjectSearchResult> UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, MultipleAppHostProjectsFoundBehavior multipleAppHostProjectsFoundBehavior, bool createSettingsFile, CancellationToken cancellationToken)
        {
            return Task.FromResult(new AppHostProjectSearchResult(new FileInfo("/tmp/apphost.cs"), [new FileInfo("/tmp/apphost.cs")]));
        }
 
        public Task<FileInfo?> UseOrFindAppHostProjectFileAsync(FileInfo? projectFile, bool createSettingsFile, CancellationToken cancellationToken)
        {
            // Return a .cs file to simulate single file AppHost
            return Task.FromResult<FileInfo?>(new FileInfo("/tmp/apphost.cs"));
        }
 
        public Task<FileInfo?> GetAppHostFromSettingsAsync(CancellationToken cancellationToken = default) => Task.FromResult<FileInfo?>(null);
    }
 
    private sealed class FixedTimeProvider(DateTimeOffset utcNow) : TimeProvider
    {
        public override DateTimeOffset GetUtcNow() => utcNow;
    }
 
    [Fact]
    public async Task RunCommand_WithNoBuildOption_SkipsBuildAndPassesNoBuildAndNoRestoreToRunner()
    {
        var buildCalled = false;
        var noBuildPassedToRunner = false;
        var noRestorePassedToRunner = false;
 
        var backchannelFactory = (IServiceProvider sp) =>
        {
            var backchannel = new TestAppHostBackchannel();
            backchannel.GetAppHostLogEntriesAsyncCallback = ReturnLogEntriesUntilCancelledAsync;
            return backchannel;
        };
 
        var runnerFactory = (IServiceProvider sp) =>
        {
            var runner = new TestDotNetCliRunner();
            runner.BuildAsyncCallback = (projectFile, noRestore, options, ct) =>
            {
                buildCalled = true;
                return 0;
            };
            runner.GetAppHostInformationAsyncCallback = (projectFile, options, ct) => (0, true, VersionHelper.GetDefaultTemplateVersion());
 
            runner.RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, ct) =>
            {
                noBuildPassedToRunner = noBuild;
                noRestorePassedToRunner = noRestore;
                var backchannel = sp.GetRequiredService<IAppHostCliBackchannel>();
                backchannelCompletionSource!.SetResult(backchannel);
                await Task.Delay(100, ct);
                return 0;
            };
 
            return runner;
        };
 
        var projectLocatorFactory = (IServiceProvider sp) => new TestProjectLocator();
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = projectLocatorFactory;
            options.AppHostBackchannelFactory = backchannelFactory;
            options.DotNetCliRunnerFactory = runnerFactory;
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("run --no-build");
 
        using var cts = new CancellationTokenSource();
        cts.CancelAfter(TimeSpan.FromSeconds(2));
 
        var exitCode = await result.InvokeAsync(cancellationToken: cts.Token).DefaultTimeout();
 
        Assert.False(buildCalled, "Build should be skipped when --no-build is specified");
        Assert.True(noBuildPassedToRunner, "noBuild=true should be passed to the runner when --no-build is specified");
        Assert.True(noRestorePassedToRunner, "noRestore=true should be passed to the runner when --no-build is specified (--no-build implies --no-restore)");
    }
 
    [Fact]
    public async Task RunCommand_WithIsolatedOption_SetsRandomizePortsAndIsolatesUserSecrets()
    {
        var tcs = new TaskCompletionSource<Dictionary<string, string>>(TaskCreationOptions.RunContinuationsAsynchronously);
        var originalUserSecretsId = Guid.NewGuid().ToString();
 
        // Set up user secrets file to simulate existing secrets
        var originalSecretsPath = UserSecretsPathHelper.GetSecretsPathFromSecretsId(originalUserSecretsId);
        var originalSecretsDir = Path.GetDirectoryName(originalSecretsPath)!;
        Directory.CreateDirectory(originalSecretsDir);
        File.WriteAllText(originalSecretsPath, """{"TestSecret": "TestValue"}""");
 
        try
        {
            var backchannelFactory = (IServiceProvider sp) =>
            {
                var backchannel = new TestAppHostBackchannel();
                backchannel.GetAppHostLogEntriesAsyncCallback = ReturnLogEntriesUntilCancelledAsync;
                return backchannel;
            };
 
            var runnerFactory = (IServiceProvider sp) =>
            {
                var runner = new TestDotNetCliRunner();
                runner.BuildAsyncCallback = (projectFile, noRestore, options, ct) => 0;
                runner.GetAppHostInformationAsyncCallback = (projectFile, options, ct) => (0, true, VersionHelper.GetDefaultTemplateVersion());
 
                // After issue #17197 the AppHost MSBuild inspection is fetched once and cached,
                // so the IsAspireHost shape and UserSecretsId both come back in the same response.
                runner.GetProjectItemsAndPropertiesAsyncCallback = (projectFile, items, properties, options, ct) =>
                {
                    var json = $$$"""
                        {
                          "Properties": {
                            "IsAspireHost": "true",
                            "AspireHostingSDKVersion": "{{{VersionHelper.GetDefaultTemplateVersion()}}}",
                            "UserSecretsId": "{{{originalUserSecretsId}}}"
                          },
                          "Items": {}
                        }
                        """;
                    return (0, JsonDocument.Parse(json));
                };
 
                runner.RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, ct) =>
                {
                    // Capture environment variables
                    tcs.SetResult(env?.ToDictionary() ?? []);
 
                    var backchannel = sp.GetRequiredService<IAppHostCliBackchannel>();
                    backchannelCompletionSource!.SetResult(backchannel);
                    return 0;
                };
 
                return runner;
            };
 
            var projectLocatorFactory = (IServiceProvider sp) => new TestProjectLocator();
 
            using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
            var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
            {
                options.ProjectLocatorFactory = projectLocatorFactory;
                options.AppHostBackchannelFactory = backchannelFactory;
                options.DotNetCliRunnerFactory = runnerFactory;
            });
 
            using var provider = services.BuildServiceProvider();
            var command = provider.GetRequiredService<RootCommand>();
            var result = command.Parse("run --isolated");
 
            using var cts = new CancellationTokenSource();
            var pendingRun = result.InvokeAsync(cancellationToken: cts.Token);
 
            // Give the command time to start and set up
            var capturedEnv = await tcs.Task.DefaultTimeout();
 
            // Simulate CTRL-C
            cts.Cancel();
 
            var exitCode = await pendingRun.DefaultTimeout();
            Assert.Equal(CliExitCodes.Success, exitCode);
 
            // Verify DcpPublisher__RandomizePorts is set to true for isolated mode
            Assert.True(capturedEnv.ContainsKey("DcpPublisher__RandomizePorts"), "DcpPublisher__RandomizePorts should be set in isolated mode");
            Assert.Equal("true", capturedEnv["DcpPublisher__RandomizePorts"]);
 
            // Verify DOTNET_USER_SECRETS_ID is set to a different value (isolated secrets)
            Assert.True(capturedEnv.ContainsKey("DOTNET_USER_SECRETS_ID"), "DOTNET_USER_SECRETS_ID should be set in isolated mode with user secrets");
            Assert.NotEqual(originalUserSecretsId, capturedEnv["DOTNET_USER_SECRETS_ID"]);
 
            // Verify the isolated secrets ID is a valid GUID
            Assert.True(Guid.TryParse(capturedEnv["DOTNET_USER_SECRETS_ID"], out _), "Isolated user secrets ID should be a valid GUID");
        }
        finally
        {
            // Clean up the original secrets file we created
            if (File.Exists(originalSecretsPath))
            {
                File.Delete(originalSecretsPath);
            }
            if (Directory.Exists(originalSecretsDir) && !Directory.EnumerateFileSystemEntries(originalSecretsDir).Any())
            {
                Directory.Delete(originalSecretsDir);
            }
        }
    }
 
    [Fact]
    public async Task RunCommand_WithNoBuildAndWatchModeEnabled_ReturnsInvalidCommandError()
    {
        // This test verifies that when --no-build is specified and watch mode is enabled
        // (via feature flag), the CLI returns an error because this combination is not supported
        // (dotnet watch doesn't support --no-build)
 
        var projectLocatorFactory = (IServiceProvider sp) => new TestProjectLocator();
 
        // Create a features factory that enables DefaultWatchEnabled
        var featuresFactory = (IServiceProvider sp) => new TestFeatures()
            .SetFeature(KnownFeatures.DefaultWatchEnabled, true);
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = projectLocatorFactory;
            options.FeatureFlagsFactory = featuresFactory;
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("run --no-build");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        // Should return InvalidCommand error because --no-build is not supported with watch mode enabled
        Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
    }
 
    [Fact]
    public void RunCommand_ForwardsUnmatchedTokensToAppHost()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("run -- --custom-arg value --launch-profile E2E");
 
        Assert.Empty(result.Errors);
        Assert.Null(result.GetValue(AppHostLauncher.s_launchProfileOption));
        Assert.Contains("--custom-arg", result.UnmatchedTokens);
        Assert.Contains("value", result.UnmatchedTokens);
        Assert.Contains("--launch-profile", result.UnmatchedTokens);
        Assert.Contains("E2E", result.UnmatchedTokens);
    }
 
    [Fact]
    public async Task CaptureAppHostLogsAsync_WritesCategoryWithAppHostPrefix()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var logFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "test.log");
        var errorWriter = new TestStartupErrorWriter();
        using var fileLoggerProvider = new FileLoggerProvider(logFilePath, errorWriter);
 
        var entries = new BackchannelLogEntry[]
        {
            new()
            {
                Timestamp = new DateTimeOffset(2026, 3, 16, 12, 0, 0, TimeSpan.Zero),
                LogLevel = LogLevel.Information,
                Message = "Application started",
                EventId = new EventId(),
                CategoryName = "Microsoft.Hosting.Lifetime"
            },
            new()
            {
                Timestamp = new DateTimeOffset(2026, 3, 16, 12, 0, 1, TimeSpan.Zero),
                LogLevel = LogLevel.Warning,
                Message = "Slow response",
                EventId = new EventId(),
                CategoryName = "Microsoft.AspNetCore.Server.Kestrel"
            },
            new()
            {
                Timestamp = new DateTimeOffset(2026, 3, 16, 12, 0, 2, TimeSpan.Zero),
                LogLevel = LogLevel.Error,
                Message = "Connection refused",
                EventId = new EventId(),
                CategoryName = "SimpleCategory"
            },
        };
 
        var backchannel = new TestAppHostBackchannel();
        backchannel.GetAppHostLogEntriesAsyncCallback = YieldEntries;
        var interactionService = new TestInteractionService();
 
        await RunCommand.CaptureAppHostLogsAsync(fileLoggerProvider, backchannel, interactionService, CancellationToken.None);
 
        fileLoggerProvider.Dispose();
 
        var lines = await File.ReadAllLinesAsync(logFilePath);
        var nonEmptyLines = lines.Where(l => !string.IsNullOrWhiteSpace(l)).ToArray();
 
        Assert.Collection(nonEmptyLines,
            line => Assert.Equal("[2026-03-16 12:00:00.000] [INFO] [AppHost/Lifetime] Application started", line),
            line => Assert.Equal("[2026-03-16 12:00:01.000] [WARN] [AppHost/Kestrel] Slow response", line),
            line => Assert.Equal("[2026-03-16 12:00:02.000] [FAIL] [AppHost/SimpleCategory] Connection refused", line));
 
        async IAsyncEnumerable<BackchannelLogEntry> YieldEntries([EnumeratorCancellation] CancellationToken cancellationToken)
        {
            foreach (var entry in entries)
            {
                yield return entry;
            }
            await Task.CompletedTask;
        }
    }
 
    [Fact]
    public async Task CaptureAppHostLogsAsync_ForwardsStructuredEntries()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var logFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "test.log");
        var extensionBackchannel = new TestExtensionBackchannel
        {
            HasCapabilityAsyncCallback = (capability, _) => Task.FromResult(capability == KnownCapabilities.AppHostLogOutput)
        };
        using var services = new ServiceCollection()
            .AddSingleton<IExtensionBackchannel>(extensionBackchannel)
            .BuildServiceProvider();
        var forwarded = new List<ExtensionAppHostLogEntry>();
        var interactionService = new TestExtensionInteractionService(services)
        {
            WriteAppHostLogEntryCallback = forwarded.Add
        };
        var backchannel = new TestAppHostBackchannel
        {
            GetAppHostLogEntriesAsyncCallback = YieldEntries
        };
 
        using (var fileLoggerProvider = new FileLoggerProvider(logFilePath, new TestStartupErrorWriter()))
        {
            await RunCommand.CaptureAppHostLogsAsync(fileLoggerProvider, backchannel, interactionService, CancellationToken.None);
        }
 
        Assert.Collection(forwarded,
            entry => Assert.Equal((1L, "Warning", "Warning message", (string?)null), (entry.SequenceNumber, entry.LogLevel, entry.Message, entry.Exception)),
            entry => Assert.Equal((2L, "Error", "Error message", "System.InvalidOperationException: boom"), (entry.SequenceNumber, entry.LogLevel, entry.Message, entry.Exception)));
        var logFileContents = await File.ReadAllTextAsync(logFilePath);
        Assert.Contains($"Error message{Environment.NewLine}System.InvalidOperationException: boom", logFileContents);
 
        static async IAsyncEnumerable<BackchannelLogEntry> YieldEntries([EnumeratorCancellation] CancellationToken cancellationToken)
        {
            yield return CreateEntry(1, LogLevel.Warning, "Warning message");
            yield return CreateEntry(2, LogLevel.Error, "Error message", "System.InvalidOperationException: boom");
            yield return CreateEntry(3, LogLevel.Debug, "Debug message");
            await Task.CompletedTask;
        }
    }
 
    [Fact]
    public async Task CaptureAppHostLogsAsync_SuppressesRepeatedPositiveSequencesWithinEachGenerationOnly()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var logFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "test.log");
        var firstGeneration = Guid.Parse("11111111-1111-1111-1111-111111111111");
        var secondGeneration = Guid.Parse("22222222-2222-2222-2222-222222222222");
        var extensionBackchannel = new TestExtensionBackchannel
        {
            HasCapabilityAsyncCallback = (_, _) => Task.FromResult(true)
        };
        using var services = new ServiceCollection()
            .AddSingleton<IExtensionBackchannel>(extensionBackchannel)
            .BuildServiceProvider();
        var forwarded = new List<ExtensionAppHostLogEntry>();
        var legacyMessages = new List<string>();
        var interactionService = new TestExtensionInteractionService(services)
        {
            WriteAppHostLogEntryCallback = forwarded.Add,
            WriteDebugSessionMessageCallback = (message, _, _) => legacyMessages.Add(message)
        };
        var backchannel = new TestAppHostBackchannel
        {
            GetAppHostLogEntriesAsyncCallback = cancellationToken => YieldEntries(firstGeneration, secondGeneration, cancellationToken)
        };
 
        using (var fileLoggerProvider = new FileLoggerProvider(logFilePath, new TestStartupErrorWriter()))
        {
            await RunCommand.CaptureAppHostLogsAsync(fileLoggerProvider, backchannel, interactionService, CancellationToken.None);
        }
 
        Assert.Collection(forwarded,
            entry => Assert.Equal((firstGeneration, 42L, "Numbered entry"), (entry.GenerationId, entry.SequenceNumber, entry.Message)),
            entry => Assert.Equal((secondGeneration, 42L, "Next generation entry"), (entry.GenerationId, entry.SequenceNumber, entry.Message)));
        Assert.Equal(["Legacy entry", "Legacy entry"], legacyMessages);
        var lines = (await File.ReadAllLinesAsync(logFilePath))
            .Where(line => !string.IsNullOrWhiteSpace(line))
            .ToArray();
        Assert.Collection(lines,
            line => Assert.Equal("[2026-03-16 12:00:00.000] [WARN] [AppHost/Category] Numbered entry", line),
            line => Assert.Equal("[2026-03-16 12:00:00.000] [WARN] [AppHost/Category] Next generation entry", line),
            line => Assert.Equal("[2026-03-16 12:00:00.000] [INFO] [AppHost/Category] Legacy entry", line),
            line => Assert.Equal("[2026-03-16 12:00:00.000] [INFO] [AppHost/Category] Legacy entry", line));
 
        static async IAsyncEnumerable<BackchannelLogEntry> YieldEntries(
            Guid firstGeneration,
            Guid secondGeneration,
            [EnumeratorCancellation] CancellationToken cancellationToken)
        {
            yield return CreateEntry(42, LogLevel.Warning, "Numbered entry", generationId: firstGeneration);
            yield return CreateEntry(42, LogLevel.Warning, "Numbered replay", generationId: firstGeneration);
            yield return CreateEntry(42, LogLevel.Warning, "Next generation entry", generationId: secondGeneration);
            yield return CreateEntry(0, LogLevel.Information, "Legacy entry");
            yield return CreateEntry(0, LogLevel.Information, "Legacy entry");
            await Task.CompletedTask;
        }
    }
 
    [Fact]
    public async Task CaptureAppHostLogsAsync_SuppressesAFullReplayBuffer()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var logFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "test.log");
        var extensionBackchannel = new TestExtensionBackchannel
        {
            HasCapabilityAsyncCallback = (_, _) => Task.FromResult(true)
        };
        using var services = new ServiceCollection()
            .AddSingleton<IExtensionBackchannel>(extensionBackchannel)
            .BuildServiceProvider();
        var forwardedSequences = new List<long>();
        var interactionService = new TestExtensionInteractionService(services)
        {
            WriteAppHostLogEntryCallback = entry => forwardedSequences.Add(entry.SequenceNumber)
        };
        var backchannel = new TestAppHostBackchannel
        {
            GetAppHostLogEntriesAsyncCallback = YieldEntries
        };
 
        using (var fileLoggerProvider = new FileLoggerProvider(logFilePath, new TestStartupErrorWriter()))
        {
            await RunCommand.CaptureAppHostLogsAsync(fileLoggerProvider, backchannel, interactionService, CancellationToken.None);
        }
 
        Assert.Equal(Enumerable.Range(1, 1000).Select(value => (long)value), forwardedSequences);
        Assert.Equal(1000, (await File.ReadAllLinesAsync(logFilePath)).Count(line => !string.IsNullOrWhiteSpace(line)));
 
        static async IAsyncEnumerable<BackchannelLogEntry> YieldEntries([EnumeratorCancellation] CancellationToken cancellationToken)
        {
            for (var replay = 0; replay < 2; replay++)
            {
                for (var sequenceNumber = 1L; sequenceNumber <= 1000; sequenceNumber++)
                {
                    yield return CreateEntry(sequenceNumber, LogLevel.Information, $"Entry {sequenceNumber}");
                }
            }
 
            await Task.CompletedTask;
        }
    }
 
    [Fact]
    public async Task CaptureAppHostLogsAsync_EvictsTheOldestRememberedSequence()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var logFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "test.log");
        var extensionBackchannel = new TestExtensionBackchannel
        {
            HasCapabilityAsyncCallback = (_, _) => Task.FromResult(true)
        };
        using var services = new ServiceCollection()
            .AddSingleton<IExtensionBackchannel>(extensionBackchannel)
            .BuildServiceProvider();
        var forwardedSequences = new List<long>();
        var interactionService = new TestExtensionInteractionService(services)
        {
            WriteAppHostLogEntryCallback = entry => forwardedSequences.Add(entry.SequenceNumber)
        };
        var backchannel = new TestAppHostBackchannel
        {
            GetAppHostLogEntriesAsyncCallback = YieldEntries
        };
 
        using (var fileLoggerProvider = new FileLoggerProvider(logFilePath, new TestStartupErrorWriter()))
        {
            await RunCommand.CaptureAppHostLogsAsync(fileLoggerProvider, backchannel, interactionService, CancellationToken.None);
        }
 
        Assert.Equal(1002, forwardedSequences.Count);
        Assert.Equal(1, forwardedSequences[0]);
        Assert.Equal(1, forwardedSequences[^1]);
        Assert.Equal(1002, (await File.ReadAllLinesAsync(logFilePath)).Count(line => !string.IsNullOrWhiteSpace(line)));
 
        static async IAsyncEnumerable<BackchannelLogEntry> YieldEntries([EnumeratorCancellation] CancellationToken cancellationToken)
        {
            for (var sequenceNumber = 1L; sequenceNumber <= 1001; sequenceNumber++)
            {
                yield return CreateEntry(sequenceNumber, LogLevel.Information, $"Entry {sequenceNumber}");
            }
 
            yield return CreateEntry(1, LogLevel.Information, "Evicted entry");
            await Task.CompletedTask;
        }
    }
 
    [Fact]
    public async Task CaptureAppHostLogsAsync_UsesLegacyOutputForUnnumberedEntries()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var extensionBackchannel = new TestExtensionBackchannel
        {
            HasCapabilityAsyncCallback = (_, _) => Task.FromResult(true)
        };
        using var services = new ServiceCollection()
            .AddSingleton<IExtensionBackchannel>(extensionBackchannel)
            .BuildServiceProvider();
        var legacyMessages = new List<string>();
        var interactionService = new TestExtensionInteractionService(services)
        {
            WriteDebugSessionMessageCallback = (message, _, _) => legacyMessages.Add(message)
        };
        var backchannel = new TestAppHostBackchannel
        {
            GetAppHostLogEntriesAsyncCallback = YieldEntries
        };
        using var fileLoggerProvider = new FileLoggerProvider(
            Path.Combine(workspace.WorkspaceRoot.FullName, "test.log"),
            new TestStartupErrorWriter());
 
        await RunCommand.CaptureAppHostLogsAsync(fileLoggerProvider, backchannel, interactionService, CancellationToken.None);
 
        Assert.Equal(["Legacy message"], legacyMessages);
 
        static async IAsyncEnumerable<BackchannelLogEntry> YieldEntries([EnumeratorCancellation] CancellationToken cancellationToken)
        {
            yield return CreateEntry(0, LogLevel.Information, "Legacy message");
            await Task.CompletedTask;
        }
    }
 
    [Fact]
    public async Task CaptureAppHostLogsAsync_ForwardsBufferedEntryWhenCapabilityProbeCompletes()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var capabilityProbe = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
        var nextEntryRequested = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var extensionBackchannel = new TestExtensionBackchannel
        {
            HasCapabilityAsyncCallback = (_, _) => capabilityProbe.Task
        };
        using var services = new ServiceCollection()
            .AddSingleton<IExtensionBackchannel>(extensionBackchannel)
            .BuildServiceProvider();
        var forwarded = new TaskCompletionSource<ExtensionAppHostLogEntry>(TaskCreationOptions.RunContinuationsAsynchronously);
        var interactionService = new TestExtensionInteractionService(services)
        {
            WriteAppHostLogEntryCallback = entry => forwarded.TrySetResult(entry)
        };
        var backchannel = new TestAppHostBackchannel
        {
            GetAppHostLogEntriesAsyncCallback = YieldOneEntryThenWait
        };
        using var captureCancellationSource = new CancellationTokenSource();
        using var fileLoggerProvider = new FileLoggerProvider(
            Path.Combine(workspace.WorkspaceRoot.FullName, "test.log"),
            new TestStartupErrorWriter());
        var captureTask = RunCommand.CaptureAppHostLogsAsync(
            fileLoggerProvider,
            backchannel,
            interactionService,
            captureCancellationSource.Token);
 
        try
        {
            await nextEntryRequested.Task.DefaultTimeout();
            capabilityProbe.SetResult(true);
 
            var entry = await forwarded.Task.WaitAsync(TimeSpan.FromSeconds(5));
 
            Assert.Equal("Buffered entry", entry.Message);
        }
        finally
        {
            await captureCancellationSource.CancelAsync();
            await captureTask;
        }
 
        async IAsyncEnumerable<BackchannelLogEntry> YieldOneEntryThenWait([EnumeratorCancellation] CancellationToken cancellationToken)
        {
            yield return CreateEntry(1, LogLevel.Information, "Buffered entry");
            nextEntryRequested.SetResult();
            await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
        }
    }
 
    [Fact]
    public async Task CaptureAppHostLogsAsync_DoesNotLetTheCapabilityProbeGateLogFileWrites()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var logFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "test.log");
        var secondEntryRequested = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var extensionBackchannel = new TestExtensionBackchannel
        {
            // The probe cannot answer until capture requests the second entry. Awaiting the probe
            // inside the loop deadlocks that request until the probe's fallback timeout fires.
            HasCapabilityAsyncCallback = async (capability, _) =>
            {
                await secondEntryRequested.Task;
                return capability == KnownCapabilities.AppHostLogOutput;
            }
        };
        using var services = new ServiceCollection()
            .AddSingleton<IExtensionBackchannel>(extensionBackchannel)
            .BuildServiceProvider();
        var forwarded = new List<ExtensionAppHostLogEntry>();
        var interactionService = new TestExtensionInteractionService(services)
        {
            WriteAppHostLogEntryCallback = forwarded.Add
        };
        var backchannel = new TestAppHostBackchannel
        {
            GetAppHostLogEntriesAsyncCallback = YieldEntries
        };
 
        using (var fileLoggerProvider = new FileLoggerProvider(logFilePath, new TestStartupErrorWriter()))
        {
            await RunCommand.CaptureAppHostLogsAsync(fileLoggerProvider, backchannel, interactionService, CancellationToken.None)
                .WaitAsync(TimeSpan.FromSeconds(30));
        }
 
        Assert.Equal(["First entry", "Second entry"], forwarded.Select(entry => entry.Message));
        var logFileContents = await File.ReadAllTextAsync(logFilePath);
        Assert.Contains("First entry", logFileContents);
        Assert.Contains("Second entry", logFileContents);
 
        async IAsyncEnumerable<BackchannelLogEntry> YieldEntries([EnumeratorCancellation] CancellationToken cancellationToken)
        {
            yield return CreateEntry(1, LogLevel.Information, "First entry");
            secondEntryRequested.SetResult();
            yield return CreateEntry(2, LogLevel.Information, "Second entry");
            await Task.CompletedTask;
        }
    }
 
    [Fact]
    public async Task CaptureAppHostLogsAsync_KeepsWritingTheLogFileWhenTheCapabilityProbeNeverAnswers()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var logFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "test.log");
        var wedgedProbe = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
        var extensionBackchannel = new TestExtensionBackchannel
        {
            HasCapabilityAsyncCallback = (_, _) => wedgedProbe.Task
        };
        using var services = new ServiceCollection()
            .AddSingleton<IExtensionBackchannel>(extensionBackchannel)
            .BuildServiceProvider();
        var legacyMessages = new List<string>();
        var interactionService = new TestExtensionInteractionService(services)
        {
            WriteDebugSessionMessageCallback = (message, _, _) => legacyMessages.Add(message)
        };
        var backchannel = new TestAppHostBackchannel
        {
            GetAppHostLogEntriesAsyncCallback = YieldEntries
        };
 
        using (var fileLoggerProvider = new FileLoggerProvider(logFilePath, new TestStartupErrorWriter()))
        {
            await RunCommand.CaptureAppHostLogsAsync(fileLoggerProvider, backchannel, interactionService, CancellationToken.None)
                .WaitAsync(TimeSpan.FromSeconds(30));
        }
 
        Assert.Equal(["First entry", "Second entry", "Third entry"], legacyMessages);
        var logFileContents = await File.ReadAllTextAsync(logFilePath);
        Assert.Contains("First entry", logFileContents);
        Assert.Contains("Second entry", logFileContents);
        Assert.Contains("Third entry", logFileContents);
 
        static async IAsyncEnumerable<BackchannelLogEntry> YieldEntries([EnumeratorCancellation] CancellationToken cancellationToken)
        {
            yield return CreateEntry(1, LogLevel.Information, "First entry");
            yield return CreateEntry(2, LogLevel.Information, "Second entry");
            yield return CreateEntry(3, LogLevel.Information, "Third entry");
            await Task.CompletedTask;
        }
    }
 
    [Fact]
    public async Task CaptureAppHostLogsAsync_LogsCapabilityProbeFailures()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var logFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "test.log");
        var extensionBackchannel = new TestExtensionBackchannel
        {
            HasCapabilityAsyncCallback = (_, _) => Task.FromException<bool>(new InvalidOperationException("Probe failed"))
        };
        using var services = new ServiceCollection()
            .AddSingleton<IExtensionBackchannel>(extensionBackchannel)
            .BuildServiceProvider();
        var interactionService = new TestExtensionInteractionService(services);
        var backchannel = new TestAppHostBackchannel
        {
            GetAppHostLogEntriesAsyncCallback = YieldEntries
        };
 
        using (var fileLoggerProvider = new FileLoggerProvider(logFilePath, new TestStartupErrorWriter()))
        {
            await RunCommand.CaptureAppHostLogsAsync(fileLoggerProvider, backchannel, interactionService, CancellationToken.None);
        }
 
        var logFileContents = await File.ReadAllTextAsync(logFilePath);
        Assert.Contains("Structured AppHost log capability probe failed", logFileContents);
        Assert.Contains("Probe failed", logFileContents);
 
        static async IAsyncEnumerable<BackchannelLogEntry> YieldEntries([EnumeratorCancellation] CancellationToken cancellationToken)
        {
            yield return CreateEntry(1, LogLevel.Information, "Fallback entry");
            await Task.CompletedTask;
        }
    }
 
    [Fact]
    public async Task CaptureAppHostLogsAsync_ExpectedDisconnectDrainsBufferedEntries()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var capabilityProbe = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
        var waitingToDisconnect = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        // Synchronous continuations ensure SetException does not return until the in-flight
        // MoveNextAsync has observed the disconnect. The capability probe completes afterward.
        var disconnect = new TaskCompletionSource();
        var extensionBackchannel = new TestExtensionBackchannel
        {
            HasCapabilityAsyncCallback = (_, _) => capabilityProbe.Task
        };
        using var services = new ServiceCollection()
            .AddSingleton<IExtensionBackchannel>(extensionBackchannel)
            .BuildServiceProvider();
        var forwarded = new List<ExtensionAppHostLogEntry>();
        var interactionService = new TestExtensionInteractionService(services)
        {
            WriteAppHostLogEntryCallback = forwarded.Add
        };
        var backchannel = new TestAppHostBackchannel
        {
            GetAppHostLogEntriesAsyncCallback = YieldOneEntryThenDisconnect
        };
        using var fileLoggerProvider = new FileLoggerProvider(
            Path.Combine(workspace.WorkspaceRoot.FullName, "test.log"),
            new TestStartupErrorWriter());
        var captureTask = RunCommand.CaptureAppHostLogsAsync(
            fileLoggerProvider,
            backchannel,
            interactionService,
            CancellationToken.None);
 
        await waitingToDisconnect.Task.DefaultTimeout();
        disconnect.SetException(new ConnectionLostException());
        capabilityProbe.SetResult(true);
        await captureTask.WaitAsync(TimeSpan.FromSeconds(5));
 
        Assert.Equal("Buffered entry", Assert.Single(forwarded).Message);
 
        async IAsyncEnumerable<BackchannelLogEntry> YieldOneEntryThenDisconnect([EnumeratorCancellation] CancellationToken cancellationToken)
        {
            yield return CreateEntry(1, LogLevel.Warning, "Buffered entry");
            waitingToDisconnect.SetResult();
            await disconnect.Task;
        }
    }
 
    private static BackchannelLogEntry CreateEntry(long sequenceNumber, LogLevel level, string message, string? exception = null, Guid? generationId = null)
    {
        return new BackchannelLogEntry
        {
            GenerationId = generationId ?? Guid.Empty,
            SequenceNumber = sequenceNumber,
            Timestamp = new DateTimeOffset(2026, 3, 16, 12, 0, 0, TimeSpan.Zero),
            LogLevel = level,
            Message = message,
            Exception = exception,
            EventId = new EventId(42, "ExampleEvent"),
            CategoryName = "Example.Category",
        };
    }
 
    [Fact]
    public async Task CaptureAppHostLogsAsync_ConnectionLostException_TreatedAsNormalCompletion()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var logFilePath = Path.Combine(workspace.WorkspaceRoot.FullName, "test.log");
        var errorWriter = new TestStartupErrorWriter();
        using var fileLoggerProvider = new FileLoggerProvider(logFilePath, errorWriter);
 
        var backchannel = new TestAppHostBackchannel();
        backchannel.GetAppHostLogEntriesAsyncCallback = ThrowConnectionLostAfterOneEntry;
        var interactionService = new TestInteractionService();
 
        // Should complete without throwing, even though the cancellation token is NOT cancelled.
        // This simulates the AppHost process exiting and the backchannel dropping before the
        // logCaptureCancellationSource.Cancel() fires in the RunCommand finally block.
        await RunCommand.CaptureAppHostLogsAsync(fileLoggerProvider, backchannel, interactionService, CancellationToken.None);
 
        fileLoggerProvider.Dispose();
 
        var lines = await File.ReadAllLinesAsync(logFilePath);
        var nonEmptyLines = lines.Where(l => !string.IsNullOrWhiteSpace(l)).ToArray();
 
        Assert.Single(nonEmptyLines);
        Assert.Equal("[2026-03-16 12:00:00.000] [INFO] [AppHost/Lifetime] Application started", nonEmptyLines[0]);
 
        async static IAsyncEnumerable<BackchannelLogEntry> ThrowConnectionLostAfterOneEntry([EnumeratorCancellation] CancellationToken cancellationToken)
        {
            yield return new BackchannelLogEntry
            {
                Timestamp = new DateTimeOffset(2026, 3, 16, 12, 0, 0, TimeSpan.Zero),
                LogLevel = LogLevel.Information,
                Message = "Application started",
                EventId = new EventId(),
                CategoryName = "Microsoft.Hosting.Lifetime"
            };
            await Task.CompletedTask;
            throw new ConnectionLostException();
        }
    }
 
    [Fact]
    public void DebugSessionOptions_SerializesAppHostSelectionOriginUsingWireContract()
    {
        var options = new DebugSessionOptions
        {
            AppHostSelectionOrigin = DebugSessionOptions.ExplicitCliAppHostSelectionOrigin
        };
 
        var json = JsonSerializer.Serialize(options, BackchannelJsonSerializerContext.Default.DebugSessionOptions);
        using var document = JsonDocument.Parse(json);
 
        Assert.Equal("explicit-cli", document.RootElement.GetProperty("appHostSelectionOrigin").GetString());
    }
 
    [Theory]
    [InlineData(false, "default-discovery")]
    [InlineData(true, "explicit-cli")]
    public async Task RunCommand_WhenDelegatingToExtension_CarriesAppHostSelectionOrigin(bool explicitAppHost, string expectedOrigin)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostDir = workspace.WorkspaceRoot.CreateSubdirectory("AppHost");
        var appHostFile = new FileInfo(Path.Combine(appHostDir.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(appHostFile.FullName, "<Project />");
 
        string? workingDirectory = null;
        string? projectFile = null;
        bool? debug = null;
        DebugSessionOptions? options = null;
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, testOptions =>
        {
            testOptions.ExtensionBackchannelFactory = _ => new TestExtensionBackchannel();
            testOptions.InteractionServiceFactory = sp =>
            {
                var service = new TestExtensionInteractionService(sp);
                service.StartDebugSessionCallback = (wd, pf, dbg, debugSessionOptions) =>
                {
                    workingDirectory = wd;
                    projectFile = pf;
                    debug = dbg;
                    options = debugSessionOptions;
                };
                return service;
            };
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse(explicitAppHost ? $"run --apphost \"{appHostFile.FullName}\"" : "run");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.Equal(workspace.WorkspaceRoot.FullName, workingDirectory);
        Assert.Equal(explicitAppHost ? appHostFile.FullName : null, projectFile);
        Assert.False(debug);
        Assert.NotNull(options);
        Assert.Equal("run", options.Command);
        Assert.Empty(options.Args!);
        Assert.Equal(expectedOrigin, options.AppHostSelectionOrigin);
    }
 
    [Fact]
    public async Task RunCommand_WhenRunningInExtension_ForwardsExplicitArgumentsInSemanticOrder()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        TestGitWorktree.WriteLinkedWorktreeMetadata(
            workspace.WorkspaceRoot.FullName,
            Path.Combine(workspace.WorkspaceRoot.FullName, "common", ".git"));
        var appHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("App Host");
        var appHostFile = new FileInfo(Path.Combine(appHostDirectory.FullName, "AppHost.csproj"));
        File.WriteAllText(appHostFile.FullName, "<Project />");
        var relativeCapturePath = Path.Combine("Profile Output", "profile.zip");
 
        bool? debug = null;
        DebugSessionOptions? options = null;
        using var provider = CliTestHelper.CreateExtensionServiceProvider(workspace, outputHelper, (_, _, dbg, debugSessionOptions) =>
        {
            debug = dbg;
            options = debugSessionOptions;
        });
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse(
        [
            "run",
            "--apphost", appHostFile.FullName,
            "--debug",
            "--capture-profile",
            "--detach=false",
            "--no-build",
            "--launch-profile", "E2E",
            "--isolated=false",
            "--format=table",
            "--wait-for-debugger",
            "--capture-profile-output", relativeCapturePath,
            "--non-interactive=false",
            "--log-level", "Debug",
            "--start-debug-session",
            "--capture-profile-delay=1",
            "--",
            "--custom-arg", "value"
        ]);
 
        Assert.Empty(result.Errors);
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.True(debug);
        Assert.NotNull(options);
        Assert.Equal("run", options.Command);
        Assert.NotNull(options.Args);
        Assert.Equal(
            [
                "--debug",
                "--capture-profile",
                "--no-build",
                "--launch-profile", "E2E",
                "--isolated", "false",
                "--wait-for-debugger",
                "--capture-profile-output", new FileInfo(relativeCapturePath).FullName,
                "--log-level", "Debug",
                "--capture-profile-delay", "1",
                "--",
                "--custom-arg", "value"
            ],
            options.Args);
    }
 
    [Fact]
    public async Task RunCommand_WhenRunningInExtensionInLinkedWorktree_DoesNotInferIsolation()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        TestGitWorktree.WriteLinkedWorktreeMetadata(
            workspace.WorkspaceRoot.FullName,
            Path.Combine(workspace.WorkspaceRoot.FullName, "common", ".git"));
 
        var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        File.WriteAllText(appHostFile.FullName, "<Project />");
 
        DebugSessionOptions? options = null;
        using var provider = CliTestHelper.CreateExtensionServiceProvider(
            workspace,
            outputHelper,
            (_, _, _, debugSessionOptions) => options = debugSessionOptions);
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse(["run", "--apphost", appHostFile.FullName, "--debug", "--", "--custom-arg", "value"]);
 
        Assert.Empty(result.Errors);
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.NotNull(options);
        Assert.NotNull(options.Args);
        Assert.Equal(["--debug", "--", "--custom-arg", "value"], options.Args);
    }
 
    [Fact]
    public async Task RunCommand_WhenRunningInExtension_SynthesizesSeparatorBeforeDelimiterFreeAppHostArguments()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        TestGitWorktree.WriteLinkedWorktreeMetadata(
            workspace.WorkspaceRoot.FullName,
            Path.Combine(workspace.WorkspaceRoot.FullName, "common", ".git"));
 
        var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        File.WriteAllText(appHostFile.FullName, "<Project />");
 
        DebugSessionOptions? options = null;
        using var provider = CliTestHelper.CreateExtensionServiceProvider(
            workspace,
            outputHelper,
            (_, _, _, debugSessionOptions) => options = debugSessionOptions);
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse(["run", "--apphost", appHostFile.FullName, "--debug", "--secret", "value"]);
 
        Assert.Empty(result.Errors);
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.NotNull(options);
        Assert.NotNull(options.Args);
        Assert.Equal(["--debug", "--", "--secret", "value"], options.Args);
 
        var childParseResult = command.Parse(["run", .. options.Args]);
 
        Assert.Empty(childParseResult.Errors);
        Assert.True(childParseResult.GetValue(RootCommand.DebugOption));
        Assert.False(childParseResult.GetValue(AppHostLauncher.s_isolatedOption));
        Assert.Equal(["--secret", "value"], childParseResult.UnmatchedTokens);
    }
 
    [Theory]
    [InlineData(true, true)]
    [InlineData(false, true)]
    [InlineData(true, false)]
    public async Task DelegatedCommands_RecordTransferStateOnlyAfterSuccessfulHandoff(
        bool captureProfile,
        bool handoffSucceeds)
    {
        await AssertProfileTransferAsync("run", captureProfile, handoffSucceeds);
        await AssertProfileTransferAsync("start", captureProfile, handoffSucceeds);
    }
 
    [Fact]
    public async Task RunCommand_NonInteractive_SkipsExtensionDelegation()
    {
        // When `aspire start` spawns `aspire run --non-interactive`, the child process
        // may inherit ASPIRE_EXTENSION_* env vars from the parent terminal. Without the
        // --non-interactive guard, the child would delegate to the extension via
        // StartDebugSessionAsync and exit immediately instead of launching the AppHost.
        var startDebugSessionCalled = false;
 
        var extensionBackchannel = new TestExtensionBackchannel();
        extensionBackchannel.GetCapabilitiesAsyncCallback = ct => Task.FromResult(Array.Empty<string>());
 
        var appHostBackchannel = new TestAppHostBackchannel();
        appHostBackchannel.GetDashboardUrlsAsyncCallback = (ct) => Task.FromResult(new DashboardUrlsState
        {
            DashboardHealthy = true,
            BaseUrlWithLoginToken = "http://localhost/dashboard",
            CodespacesUrlWithLoginToken = null
        });
        appHostBackchannel.GetAppHostLogEntriesAsyncCallback = ReturnLogEntriesUntilCancelledAsync;
 
        var backchannelFactory = (IServiceProvider sp) => appHostBackchannel;
 
        var extensionInteractionServiceFactory = (IServiceProvider sp) =>
        {
            var service = new TestExtensionInteractionService(sp);
            service.StartDebugSessionCallback = (_, _, _, _) =>
            {
                startDebugSessionCalled = true;
            };
            return service;
        };
 
        var runnerFactory = (IServiceProvider sp) =>
        {
            var runner = new TestDotNetCliRunner();
            runner.BuildAsyncCallback = (projectFile, noRestore, options, ct) => 0;
            runner.GetAppHostInformationAsyncCallback = (projectFile, options, ct) => (0, true, VersionHelper.GetDefaultTemplateVersion());
            runner.RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, ct) =>
            {
                var backchannel = sp.GetRequiredService<IAppHostCliBackchannel>();
                backchannelCompletionSource!.SetResult(backchannel);
                await Task.Delay(Timeout.InfiniteTimeSpan, ct);
                return 0;
            };
            return runner;
        };
 
        var projectLocatorFactory = (IServiceProvider sp) => new TestProjectLocator();
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = projectLocatorFactory;
            options.AppHostBackchannelFactory = backchannelFactory;
            options.DotNetCliRunnerFactory = runnerFactory;
            options.ExtensionBackchannelFactory = _ => extensionBackchannel;
            options.InteractionServiceFactory = extensionInteractionServiceFactory;
            // Deliberately NOT setting ASPIRE_EXTENSION_DEBUG_SESSION_ID —
            // without --non-interactive, this would trigger the early return.
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        // Parse with --non-interactive to simulate the child of `aspire start`
        var result = command.Parse("run --non-interactive");
 
        using var cts = new CancellationTokenSource();
        var pendingRun = result.InvokeAsync(cancellationToken: cts.Token);
        cts.Cancel();
 
        var exitCode = await pendingRun.DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.False(startDebugSessionCalled, "StartDebugSessionAsync should not be called in non-interactive mode.");
    }
 
    [Fact]
    public async Task RunCommand_AllowsNoBuildInActiveExtensionDebugSession()
    {
        var startDebugSessionCalled = false;
        var runNoBuildValue = false;
 
        var extensionBackchannel = new TestExtensionBackchannel();
        extensionBackchannel.GetCapabilitiesAsyncCallback = ct => Task.FromResult(Array.Empty<string>());
 
        var appHostBackchannel = new TestAppHostBackchannel();
        appHostBackchannel.GetDashboardUrlsAsyncCallback = (ct) => Task.FromResult(new DashboardUrlsState
        {
            DashboardHealthy = true,
            BaseUrlWithLoginToken = "http://localhost/dashboard",
            CodespacesUrlWithLoginToken = null
        });
        appHostBackchannel.GetAppHostLogEntriesAsyncCallback = ReturnLogEntriesUntilCancelledAsync;
 
        var backchannelFactory = (IServiceProvider sp) => appHostBackchannel;
 
        var extensionInteractionServiceFactory = (IServiceProvider sp) =>
        {
            var service = new TestExtensionInteractionService(sp);
            service.StartDebugSessionCallback = (_, _, _, _) =>
            {
                startDebugSessionCalled = true;
            };
            return service;
        };
 
        var runnerFactory = (IServiceProvider sp) =>
        {
            var runner = new TestDotNetCliRunner();
            runner.BuildAsyncCallback = (projectFile, noRestore, options, ct) => 0;
            runner.GetAppHostInformationAsyncCallback = (projectFile, options, ct) => (0, true, VersionHelper.GetDefaultTemplateVersion());
            runner.RunAsyncCallback = async (projectFile, watch, noBuild, noRestore, args, env, backchannelCompletionSource, options, ct) =>
            {
                runNoBuildValue = noBuild;
                var backchannel = sp.GetRequiredService<IAppHostCliBackchannel>();
                backchannelCompletionSource!.SetResult(backchannel);
                await Task.Delay(Timeout.InfiniteTimeSpan, ct);
                return 0;
            };
            return runner;
        };
 
        var projectLocatorFactory = (IServiceProvider sp) => new TestProjectLocator();
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ConfigurationCallback += config => config[KnownConfigNames.ExtensionDebugSessionId] = "existing-session";
            options.ProjectLocatorFactory = projectLocatorFactory;
            options.AppHostBackchannelFactory = backchannelFactory;
            options.DotNetCliRunnerFactory = runnerFactory;
            options.ExtensionBackchannelFactory = _ => extensionBackchannel;
            options.InteractionServiceFactory = extensionInteractionServiceFactory;
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("run --no-build");
 
        using var cts = new CancellationTokenSource();
        var pendingRun = result.InvokeAsync(cancellationToken: cts.Token);
        cts.Cancel();
 
        var exitCode = await pendingRun.DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.False(startDebugSessionCalled, "StartDebugSessionAsync should not be called from an active extension debug session.");
        Assert.True(runNoBuildValue);
    }
 
    [Theory]
    [InlineData(false, false)]
    [InlineData(true, false)]
    [InlineData(false, true)]
    [InlineData(true, true)]
    public async Task RunCommand_RecordsRunAppHostTelemetryActivity(bool detached, bool isolated)
    {
        using var fixture = new TelemetryFixture();
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = CreateAppHostFile(workspace);
        var projectLocator = new TestProjectLocator
        {
            UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (_, _, _, _) =>
                Task.FromResult(new AppHostProjectSearchResult(appHostFile, [appHostFile]))
        };
        var projectFactory = new TestAppHostProjectFactory
        {
            RunAsyncCallback = (context, _) =>
            {
                context.BuildCompletionSource?.TrySetResult(false);
                return Task.FromResult(CliExitCodes.FailedToBuildArtifacts);
            }
        };
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.ProjectLocatorFactory = _ => projectLocator;
            options.AppHostProjectFactory = _ => projectFactory;
            options.TelemetryFactory = _ => fixture.Telemetry;
 
            if (detached)
            {
                options.ConfigurationCallback += config =>
                {
                    config[KnownConfigNames.CliRunDetached] = "true";
                };
            }
        });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var args = isolated ? "run --isolated" : "run";
        var result = command.Parse(args);
 
        await result.InvokeAsync().DefaultTimeout();
 
        Assert.NotNull(fixture.CapturedActivity);
        Assert.Equal(TelemetryConstants.Activities.RunAppHost, fixture.CapturedActivity.OperationName);
 
        var tags = fixture.CapturedActivity.TagObjects.ToDictionary(t => t.Key, t => t.Value);
        Assert.Equal(KnownLanguageId.CSharp, tags[TelemetryConstants.Tags.AppHostLanguage]);
        Assert.Equal(detached, tags[TelemetryConstants.Tags.AppHostDetached]);
        Assert.Equal(isolated, tags[TelemetryConstants.Tags.AppHostIsolated]);
        Assert.Equal("build_failed", tags[TelemetryConstants.Tags.ErrorType]);
    }
 
    private async Task AssertProfileTransferAsync(
        string commandName,
        bool captureProfile,
        bool handoffSucceeds)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        File.WriteAllText(appHostFile.FullName, "<Project />");
 
        ProfileCaptureState? captureState = null;
        using var provider = CliTestHelper.CreateExtensionServiceProvider(workspace, outputHelper, (_, _, _, _) =>
        {
            Assert.NotNull(captureState);
            Assert.False(captureState.IsTransferred);
            if (!handoffSucceeds)
            {
                throw new InvalidOperationException("Extension handoff failed.");
            }
        });
 
        captureState = provider.GetRequiredService<ProfileCaptureState>();
        var command = provider.GetRequiredService<RootCommand>();
        var args = new List<string> { commandName, "--apphost", appHostFile.FullName };
        if (captureProfile)
        {
            args.Add("--capture-profile");
        }
 
        var result = command.Parse(args);
 
        Assert.Empty(result.Errors);
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(handoffSucceeds ? CliExitCodes.Success : CliExitCodes.InvalidCommand, exitCode);
        Assert.Equal(handoffSucceeds, captureState.IsTransferred);
    }
 
    private static long GetProcessStartTimeUnixMilliseconds(Process process)
    {
        var startTime = ProcessStartTimeHelper.TryGetProcessStartTimeUnixMilliseconds(process.Id);
        Assert.NotNull(startTime);
        return startTime.Value;
    }
 
    private static FileInfo CreateAppHostFile(TemporaryWorkspace workspace)
    {
        var appHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("AppHost");
        var appHostFile = new FileInfo(Path.Combine(appHostDirectory.FullName, "AppHost.csproj"));
        File.WriteAllText(appHostFile.FullName, "<Project />");
 
        return appHostFile;
    }
 
    private static string CreateMatchingSocketFile(FileInfo appHostFile, DirectoryInfo homeDirectory)
    {
        var backchannelsDirectory = Path.Combine(homeDirectory.FullName, ".aspire", "cli", "bch");
        Directory.CreateDirectory(backchannelsDirectory);
 
        var resolvedAppHostPath = PathNormalizer.ResolveSymlinks(appHostFile.FullName);
        var prefix = AppHostHelper.ComputeAuxiliarySocketPrefix(resolvedAppHostPath, homeDirectory.FullName);
        var appHostId = Path.GetFileName(prefix);
        var socketPath = Path.Combine(
            backchannelsDirectory,
            $"{appHostId}a1b2C3d4.{(int.MaxValue - 1).ToString(CultureInfo.InvariantCulture)}");
        File.WriteAllText(socketPath, "");
        return socketPath;
    }
 
    private static void AssertDetachedChildArguments(RootCommand command, string[]? childArguments, string expectedLaunchProfile, string[] expectedAppHostArguments)
    {
        var forwardedArguments = ExtractForwardedRunArguments(Assert.IsType<string[]>(childArguments));
        var separatorIndex = Array.IndexOf(forwardedArguments, "--");
        var noBuildIndex = Array.IndexOf(forwardedArguments, "--no-build");
 
        Assert.Equal(1, forwardedArguments.Count(argument => argument == "--"));
        Assert.True(separatorIndex > 0, "Expected a single child/AppHost separator.");
        Assert.True(noBuildIndex > 0, "Expected detached child arguments to include --no-build.");
        Assert.Equal(1, forwardedArguments.Count(argument => argument == "--no-build"));
        Assert.Equal(["--no-build", $"--launch-profile={expectedLaunchProfile}", "--", .. expectedAppHostArguments], forwardedArguments[noBuildIndex..]);
        Assert.DoesNotContain("--detach", forwardedArguments.Take(separatorIndex));
        var childParseResult = command.Parse(forwardedArguments);
 
        Assert.Empty(childParseResult.Errors);
        Assert.Equal(expectedLaunchProfile, childParseResult.GetValue(AppHostLauncher.s_launchProfileOption));
        Assert.Equal(expectedAppHostArguments, childParseResult.UnmatchedTokens);
    }
 
    private static string[] ExtractForwardedRunArguments(string[] childArguments)
    {
        var runIndex = Array.IndexOf(childArguments, "run");
        Assert.True(runIndex >= 0, "Expected detached child arguments to include the run command.");
 
        return childArguments[runIndex..];
    }
 
}