File: Helpers\CliE2EAutomatorHelpers.cs
Web Access
Project: src\tests\Aspire.Cli.EndToEnd.Tests\Aspire.Cli.EndToEnd.Tests.csproj (Aspire.Cli.EndToEnd.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.Globalization;
using System.Xml.Linq;
using Aspire.Cli.Resources;
using Hex1b.Automation;
using Xunit;
 
namespace Aspire.Cli.EndToEnd.Tests.Helpers;
 
/// <summary>
/// Extension methods for <see cref="Hex1bTerminalAutomator"/> providing Docker E2E test helpers.
/// These helpers compose the shared Hex1b shell helpers with CLI-specific behavior.
/// </summary>
/// <remarks>
/// These helpers are intentionally bash-first and Linux-specific. The tests drive a real terminal session, so the
/// implementation keeps the shell commands visible instead of abstracting every step behind helper layers. That keeps
/// the code, the asciinema recording, and the failure output aligned when a scenario needs debugging.
/// </remarks>
internal static class CliE2EAutomatorHelpers
{
    private const string AspireStartJsonFile = "/tmp/aspire-start.json";
    private static readonly string s_expectedStableVersionMarker = GetExpectedStableVersionMarker();
 
    /// <summary>
    /// AppHost startup budget (in seconds) applied to raw <c>aspire run</c> invocations in the E2E smoke tests via
    /// <c>ASPIRE_CLI_START_TIMEOUT</c>. Without it, the CLI falls back to its default AppHost startup timeout (120s),
    /// which is too tight for a cold NuGet restore + build + container start against the daily feed on a contended CI
    /// runner — the smoke tests then fail intermittently before the AppHost reports ready. This mirrors the explicit
    /// budget <see cref="AspireStartAsync"/> already sets for <c>aspire start</c>.
    /// </summary>
    internal const int AspireRunStartupBudgetSeconds = 180;
 
    /// <summary>
    /// Terminal-side timeout for waiting on the "Press CTRL+C" ready message after launching <c>aspire run</c>.
    /// Intentionally larger than <see cref="AspireRunStartupBudgetSeconds"/> so the CLI's own startup timeout fires
    /// (surfacing its diagnostic) before this wait gives up on a genuine hang.
    /// </summary>
    internal static TimeSpan AspireRunReadyTimeout => TimeSpan.FromSeconds(AspireRunStartupBudgetSeconds + 60);
 
    /// <summary>
    /// Builds the shell command that launches <c>aspire run</c> with an explicit AppHost startup budget so cold
    /// daily-feed restores don't trip the CLI's default 120s startup timeout. See <see cref="AspireRunStartupBudgetSeconds"/>.
    /// </summary>
    /// <param name="additionalArguments">Extra arguments appended after <c>aspire run</c>, for example <c>--apphost &lt;path&gt;</c>.</param>
    internal static string GetAspireRunCommand(string? additionalArguments = null)
    {
        var command = $"ASPIRE_CLI_START_TIMEOUT={AspireRunStartupBudgetSeconds.ToString(CultureInfo.InvariantCulture)} aspire run";
 
        return string.IsNullOrEmpty(additionalArguments) ? command : $"{command} {additionalArguments}";
    }
 
    /// <summary>
    /// Prepares the Docker environment by setting up prompt counting, umask, and environment variables.
    /// </summary>
    internal static async Task PrepareDockerEnvironmentAsync(
        this Hex1bTerminalAutomator auto,
        SequenceCounter counter,
        TemporaryWorkspace? workspace = null,
        bool enableDcpDiagnostics = false)
    {
        // Wait for container to be ready (root prompt)
        await auto.WaitUntilTextAsync("# ", timeout: TimeSpan.FromSeconds(60));
 
        await auto.WaitAsync(500);
 
        // Install the numbered prompt contract used throughout these tests. The prompt encodes both the command
        // sequence number and the exit code so waits can synchronize on shell completion instead of timing guesses.
        await auto.TypeAsync(AspireCliShellCommandHelpers.NumberedPromptSetupCommand);
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter);
 
        // Set permissive umask
        await auto.RunCommandAsync("umask 000", counter);
 
        // Set environment variables
        await auto.RunCommandAsync(AspireCliShellCommandHelpers.GetPrepareAspireEnvironmentCommand(), counter);
 
        if (enableDcpDiagnostics)
        {
            await auto.RunCommandAsync("export DCP_DIAGNOSTICS_LOG_LEVEL=debug DCP_DIAGNOSTICS_LOG_FOLDER=~/.aspire/dcp-logs DCP_PRESERVE_EXECUTABLE_LOGS=1", counter);
        }
 
        if (workspace is not null)
        {
            var containerWorkspace = $"/workspace/{workspace.WorkspaceRoot.Name}";
 
            await auto.RunCommandAsync($"cd {AspireCliShellCommandHelpers.QuoteBashArg(containerWorkspace)}", counter);
 
            await auto.RunCommandAsync($"export ASPIRE_E2E_WORKSPACE={AspireCliShellCommandHelpers.QuoteBashArg(containerWorkspace)}", counter);
 
            if (!CliE2ETestHelpers.IsRunningInCI && ShouldPreserveLocalWorkspace())
            {
                workspace.Preserve();
            }
 
            if (ShouldCaptureWorkspaceDiagnostics())
            {
                await auto.RunCommandAsync(
                    "trap 'if [ -n \"$ASPIRE_E2E_WORKSPACE\" ]; then " +
                    BuildAspireDiagnosticsCaptureCommand("$ASPIRE_E2E_WORKSPACE") +
                    "fi' EXIT",
                    counter);
            }
        }
    }
 
    internal static Task WaitUntilAppHostStoppedSuccessfullyAsync(this Hex1bTerminalAutomator auto, TimeSpan timeout)
    {
        return auto.WaitUntilTextAsync(GetAppHostStoppedSuccessfullySuffix(), timeout: timeout);
    }
 
    /// <summary>
    /// Installs the Aspire CLI inside a Docker container using the given install strategy.
    /// Handles all modes: LocalHive, PullRequest, and InstallScript.
    /// </summary>
    internal static async Task InstallAspireCliAsync(
        this Hex1bTerminalAutomator auto,
        CliInstallStrategy strategy,
        SequenceCounter counter)
    {
        switch (strategy.Mode)
        {
            case CliInstallMode.LocalHive:
                await auto.ExtractLocalHiveArchiveAsync("/tmp/aspire-localhive.tar.gz", counter);
                await auto.SourceAspireCliEnvironmentAsync(counter);
                await auto.ConfigureLocalHiveAsync(counter);
                break;
 
            case CliInstallMode.Preinstalled:
                throw new InvalidOperationException("Preinstalled CLI mode is only supported for non-Docker test environments.");
 
            case CliInstallMode.PullRequest:
                var prNumber = CliE2ETestHelpers.GetRequiredPrNumber();
                await auto.RunCommandAsync(
                    AspireCliShellCommandHelpers.GetPullRequestInstallCommand(prNumber, AspireCliShellCommandHelpers.DockerPullRequestInstallCommandPrefix),
                    counter,
                    TimeSpan.FromSeconds(300));
                await auto.SourceAspireBundleEnvironmentAsync(counter);
                break;
 
            case CliInstallMode.LocalArchive:
                await auto.RunCommandAsync(
                    AspireCliShellCommandHelpers.GetLocalArchiveInstallCommand("/tmp/aspire-cli-archives", AspireCliShellCommandHelpers.DockerPullRequestInstallCommandPrefix),
                    counter,
                    TimeSpan.FromSeconds(120));
                await auto.SourceAspireBundleEnvironmentAsync(counter);
                break;
 
            case CliInstallMode.InstallScript:
                await auto.RunCommandAsync(
                    AspireCliShellCommandHelpers.GetInstallScriptCommand(strategy, AspireCliShellCommandHelpers.DockerInstallScriptCommandPrefix),
                    counter,
                    TimeSpan.FromSeconds(120));
                await auto.SourceAspireCliEnvironmentAsync(counter);
                break;
 
            case CliInstallMode.DotnetTool:
                await auto.SourceDotnetToolEnvironmentAsync(counter);
                await auto.RunCommandAsync(
                    AspireCliShellCommandHelpers.GetDotnetToolInstallCommandInDocker(strategy),
                    counter,
                    TimeSpan.FromSeconds(120));
                break;
 
            default:
                throw new ArgumentOutOfRangeException(nameof(strategy), strategy.Mode, "Unknown install mode");
        }
 
        await auto.VerifyAspireCliVersionAsync(strategy, counter);
    }
 
    private static string GetAppHostStoppedSuccessfullySuffix()
    {
        const string appHostMarker = "__AspireAppHost__";
 
        var formattedMessage = string.Format(CultureInfo.CurrentCulture, StopCommandStrings.AppHostStoppedSuccessfully, appHostMarker);
        var markerIndex = formattedMessage.IndexOf(appHostMarker, StringComparison.Ordinal);
        var suffixStart = markerIndex + appHostMarker.Length;
        if (markerIndex < 0 || suffixStart == formattedMessage.Length)
        {
            throw new InvalidOperationException($"Unable to derive a waitable suffix from {nameof(StopCommandStrings.AppHostStoppedSuccessfully)}.");
        }
 
        return formattedMessage[suffixStart..];
    }
 
    /// <summary>
    /// Creates a C# empty AppHost using the direct <c>aspire new aspire-empty</c> command.
    /// Handles CLI versions where C# is implicit and newer versions that prompt for the AppHost language.
    /// </summary>
    internal static async Task AspireNewCSharpEmptyAppHostAsync(
        this Hex1bTerminalAutomator auto,
        string projectName,
        SequenceCounter counter,
        string? outputPath = null,
        string? channel = null,
        bool useLocalhostTld = false,
        TimeSpan? timeout = null)
    {
        var effectiveTimeout = timeout ?? TimeSpan.FromMinutes(2);
        var command = BuildAspireNewEmptyAppHostCommand(
            "aspire-empty",
            projectName,
            outputPath,
            channel,
            useLocalhostTld);
 
        await auto.TypeAsync(command);
        await auto.EnterAsync();
        await auto.WaitForAspireNewEmptyAppHostCompletionAsync(
            projectName,
            "C#",
            counter,
            AppHostLanguagePromptSelection.DefaultCSharp,
            effectiveTimeout);
    }
 
    /// <summary>
    /// Creates a TypeScript empty AppHost using the direct <c>aspire new aspire-ts-empty</c> command.
    /// </summary>
    internal static async Task AspireNewTypeScriptEmptyAppHostAsync(
        this Hex1bTerminalAutomator auto,
        string projectName,
        SequenceCounter counter,
        string? outputPath = null,
        string? channel = null,
        bool useLocalhostTld = false,
        TimeSpan? timeout = null)
    {
        var effectiveTimeout = timeout ?? TimeSpan.FromMinutes(2);
        var command = BuildAspireNewEmptyAppHostCommand(
            "aspire-ts-empty",
            projectName,
            outputPath,
            channel,
            useLocalhostTld);
 
        await auto.TypeAsync(command);
        await auto.EnterAsync();
        await auto.WaitForAspireNewEmptyAppHostCompletionAsync(
            projectName,
            "TypeScript",
            counter,
            AppHostLanguagePromptSelection.TypeScript,
            effectiveTimeout);
    }
 
    private static string BuildAspireNewEmptyAppHostCommand(
        string templateName,
        string projectName,
        string? outputPath,
        string? channel,
        bool useLocalhostTld)
    {
        var output = string.IsNullOrWhiteSpace(outputPath) ? projectName : outputPath;
        var channelArgument = string.IsNullOrWhiteSpace(channel)
            ? string.Empty
            : $" --channel {AspireCliShellCommandHelpers.QuoteBashArg(channel)}";
        var localhostTldValue = useLocalhostTld ? "true" : "false";
 
        return
            $"aspire new {AspireCliShellCommandHelpers.QuoteBashArg(templateName)} " +
            $"--name {AspireCliShellCommandHelpers.QuoteBashArg(projectName)} " +
            $"--output {AspireCliShellCommandHelpers.QuoteBashArg(output)}" +
            $"{channelArgument} --localhost-tld {localhostTldValue}";
    }
 
    private static async Task WaitForAspireNewEmptyAppHostCompletionAsync(
        this Hex1bTerminalAutomator auto,
        string projectName,
        string languageDisplayName,
        SequenceCounter counter,
        AppHostLanguagePromptSelection languagePromptSelection,
        TimeSpan effectiveTimeout)
    {
        var languagePrompt = new CellPatternSearcher()
            .Find("Which language would you like to use?");
        var agentInitPrompt = new CellPatternSearcher()
            .Find("configure AI agent environments");
        var result = AspireNewEmptyAppHostResult.None;
 
        await auto.WaitUntilAsync(snapshot =>
        {
            if (languagePrompt.Search(snapshot).Count > 0)
            {
                result = AspireNewEmptyAppHostResult.LanguagePrompt;
                return true;
            }
 
            if (agentInitPrompt.Search(snapshot).Count > 0)
            {
                result = AspireNewEmptyAppHostResult.AgentInitPrompt;
                return true;
            }
 
            var successPrompt = new CellPatternSearcher()
                .FindPattern(counter.Value.ToString())
                .RightText(" OK] $ ");
            if (successPrompt.Search(snapshot).Count > 0)
            {
                result = AspireNewEmptyAppHostResult.SuccessPrompt;
                return true;
            }
 
            var errorPrompt = new CellPatternSearcher()
                .FindPattern(counter.Value.ToString())
                .RightText(" ERR:");
            if (errorPrompt.Search(snapshot).Count > 0)
            {
                result = AspireNewEmptyAppHostResult.ErrorPrompt;
                return true;
            }
 
            return false;
        }, timeout: effectiveTimeout, description: $"{languageDisplayName} empty AppHost creation prompt or completion");
 
        switch (result)
        {
            case AspireNewEmptyAppHostResult.LanguagePrompt:
                await auto.SelectAppHostLanguageAsync(languagePromptSelection);
                await auto.DeclineAgentInitPromptAsync(counter, effectiveTimeout);
                return;
 
            case AspireNewEmptyAppHostResult.AgentInitPrompt:
                await auto.DeclineAgentInitPromptAsync(counter, effectiveTimeout);
                return;
 
            case AspireNewEmptyAppHostResult.SuccessPrompt:
                counter.Increment();
                return;
 
            case AspireNewEmptyAppHostResult.ErrorPrompt:
                throw new InvalidOperationException($"aspire new failed while creating {languageDisplayName} empty AppHost project '{projectName}'.");
 
            default:
                throw new InvalidOperationException($"Unexpected aspire new result while creating {languageDisplayName} empty AppHost project '{projectName}': {result}.");
        }
    }
 
    private static async Task SelectAppHostLanguageAsync(
        this Hex1bTerminalAutomator auto,
        AppHostLanguagePromptSelection languagePromptSelection)
    {
        switch (languagePromptSelection)
        {
            case AppHostLanguagePromptSelection.DefaultCSharp:
                await auto.EnterAsync();
                return;
 
            case AppHostLanguagePromptSelection.TypeScript:
                await auto.DownAsync();
                await auto.WaitUntilAsync(
                    s => new CellPatternSearcher().Find("> TypeScript (Node.js)").Search(s).Count > 0,
                    timeout: TimeSpan.FromSeconds(5),
                    description: "TypeScript AppHost language selected");
                await auto.EnterAsync();
                return;
 
            default:
                throw new InvalidOperationException($"Unexpected AppHost language prompt selection: {languagePromptSelection}.");
        }
    }
 
    /// <summary>
    /// Handles <c>aspire add</c> completing directly or stopping on a version selection prompt.
    /// </summary>
    internal static async Task WaitForAspireAddSuccessAsync(
        this Hex1bTerminalAutomator auto,
        SequenceCounter counter,
        TimeSpan? timeout = null)
    {
        var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(180);
        var versionPickerShown = false;
        var versionPicker = new CellPatternSearcher()
            .Find("(based on NuGet.config)");
 
        await auto.WaitUntilAsync(snapshot =>
        {
            if (versionPicker.Search(snapshot).Count > 0)
            {
                versionPickerShown = true;
                return true;
            }
 
            var successPrompt = new CellPatternSearcher()
                .FindPattern(counter.Value.ToString())
                .RightText(" OK] $ ");
 
            return successPrompt.Search(snapshot).Count > 0;
        }, timeout: effectiveTimeout, description: $"aspire add completion or version picker [{counter.Value} OK] $");
 
        if (versionPickerShown)
        {
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, effectiveTimeout);
            return;
        }
 
        counter.Increment();
    }
 
    /// <summary>
    /// Installs the Aspire CLI in a non-Docker shell using the given install strategy.
    /// </summary>
    internal static async Task InstallAspireCliInShellAsync(
        this Hex1bTerminalAutomator auto,
        CliInstallStrategy strategy,
        SequenceCounter counter)
    {
        switch (strategy.Mode)
        {
            case CliInstallMode.LocalHive:
                var archivePath = strategy.ArchivePath ?? throw new InvalidOperationException("LocalHive strategy is missing the archive path.");
                await auto.ExtractLocalHiveArchiveAsync(archivePath, counter);
                await auto.SourceAspireCliEnvironmentAsync(counter);
                await auto.ConfigureLocalHiveAsync(counter);
                break;
 
            case CliInstallMode.Preinstalled:
                await auto.SourceAspireCliEnvironmentAsync(counter);
                break;
 
            case CliInstallMode.PullRequest:
                var prNumber = CliE2ETestHelpers.GetRequiredPrNumber();
                await auto.InstallAspireCliFromPullRequestAsync(prNumber, counter);
                await auto.SourceAspireCliEnvironmentAsync(counter);
                break;
 
            case CliInstallMode.LocalArchive:
                var archiveDir = strategy.ArchiveDir ?? throw new InvalidOperationException("LocalArchive strategy is missing the archive directory.");
                var localDirPrScript = AspireCliShellCommandHelpers.QuoteBashArg(Path.Combine(CliE2ETestHelpers.GetRepoRoot(), "eng", "scripts", "get-aspire-cli-pr.sh"));
                await auto.RunCommandAsync(
                    AspireCliShellCommandHelpers.GetLocalArchiveInstallCommand(archiveDir, $"bash {localDirPrScript}"),
                    counter,
                    TimeSpan.FromSeconds(120));
                await auto.SourceAspireCliEnvironmentAsync(counter);
                break;
 
            case CliInstallMode.InstallScript:
                var getAspireCliScript = AspireCliShellCommandHelpers.QuoteBashArg(Path.Combine(CliE2ETestHelpers.GetRepoRoot(), "eng", "scripts", "get-aspire-cli.sh"));
                await auto.RunCommandAsync(
                    AspireCliShellCommandHelpers.GetInstallScriptCommand(strategy, $"bash {getAspireCliScript}"),
                    counter,
                    TimeSpan.FromSeconds(120));
                await auto.SourceAspireCliEnvironmentAsync(counter);
                break;
 
            case CliInstallMode.DotnetTool:
                throw new InvalidOperationException(
                    "DotnetTool CLI mode is only supported in Docker test environments. " +
                    "Use CreateDockerTestTerminal instead of CreateTestTerminal to avoid mutating the host machine.");
 
            default:
                throw new ArgumentOutOfRangeException(nameof(strategy), strategy.Mode, "Unknown install mode");
        }
 
        await auto.VerifyAspireCliVersionAsync(strategy, counter);
    }
 
    /// <summary>
    /// Verifies the installed Aspire CLI version matches the expected version from the install strategy.
    /// When <see cref="CliInstallStrategy.ExpectedVersion"/> is set, runs <c>aspire --version</c> and asserts
    /// an exact match (stripping build metadata like <c>+commit</c>).
    /// On CI with <see cref="CliInstallMode.LocalArchive"/>, fails hard if no expected version could be determined.
    /// Otherwise, just logs the installed version for diagnostics.
    /// </summary>
    internal static async Task VerifyAspireCliVersionAsync(
        this Hex1bTerminalAutomator auto,
        CliInstallStrategy strategy,
        SequenceCounter counter)
    {
        var expectedVersion = strategy.ExpectedVersion;
        var recordVersionCommand = GetRecordAspireCliVersionCommand(strategy, "VER", "BASE_VER");
 
        if (expectedVersion is null)
        {
            if (CliE2ETestHelpers.IsRunningInCI && strategy.Mode is CliInstallMode.LocalArchive)
            {
                Assert.Fail(
                    "Running on CI with LocalArchive mode but could not extract expected CLI version " +
                    $"from Aspire.Cli.*.nupkg in the archive directory ({strategy.ArchiveDir}). " +
                    "This may indicate the nupkg was not included in the archive or the copy step failed.");
            }
 
            // No version to verify — just log for diagnostics
            await auto.TypeAsync(
                $"VER=$(aspire --version 2>/dev/null) && BASE_VER=${{VER%%+*}} && echo \"$VER\" && {recordVersionCommand}");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(30));
            return;
        }
 
        // Run bash version comparison: get installed version, strip +commit build metadata, compare
        await auto.TypeAsync(
            $"VER=$(aspire --version 2>/dev/null) && BASE_VER=${{VER%%+*}} && " +
            $"[ \"$BASE_VER\" = \"{expectedVersion}\" ] && " +
            $"echo \"CLI_VERSION_EXACT:$VER\" || " +
            $"echo \"CLI_VERSION_MISMATCH:expected={expectedVersion} actual=$VER\"; " +
            recordVersionCommand);
        await auto.EnterAsync();
 
        var foundExact = false;
        await auto.WaitUntilAsync(
            snapshot =>
            {
                if (new CellPatternSearcher().Find("CLI_VERSION_EXACT:").Search(snapshot).Count > 0)
                {
                    foundExact = true;
                    return true;
                }
 
                return new CellPatternSearcher().Find("CLI_VERSION_MISMATCH:").Search(snapshot).Count > 0;
            },
            timeout: TimeSpan.FromSeconds(30),
            description: "CLI version verification");
 
        await auto.WaitForAnyPromptAsync(counter);
 
        Assert.True(foundExact,
            $"Aspire CLI version mismatch. Expected '{expectedVersion}' (from {strategy.Mode}) " +
            "but got a different version. This may indicate the wrong CLI binary was installed.");
    }
 
    internal static string GetRecordAspireCliVersionCommand(
        CliInstallStrategy strategy,
        string versionVariableName,
        string baseVersionVariableName)
    {
        var requestedVersion = strategy.ExpectedVersion ?? strategy.Version ?? "";
        var testName = GetCurrentTestName();
 
        return
            "if [ -n \"${ASPIRE_E2E_CLI_VERSION_OUTPUT_DIR:-}\" ]; then " +
            "if mkdir -p \"$ASPIRE_E2E_CLI_VERSION_OUTPUT_DIR\" && " +
            "CLI_VERSION_RECORD=\"$ASPIRE_E2E_CLI_VERSION_OUTPUT_DIR/$(date +%s%N)-$$.env\" && " +
            "{ " +
            $"printf '%s\\n' {AspireCliShellCommandHelpers.QuoteBashArg($"test={testName}")}; " +
            $"printf '%s\\n' {AspireCliShellCommandHelpers.QuoteBashArg($"mode={strategy.Mode}")}; " +
            $"printf '%s\\n' {AspireCliShellCommandHelpers.QuoteBashArg($"strategy={strategy}")}; " +
            $"printf '%s\\n' {AspireCliShellCommandHelpers.QuoteBashArg($"expected={requestedVersion}")}; " +
            $"printf 'version=%s\\n' \"${versionVariableName}\"; " +
            $"printf 'baseVersion=%s\\n' \"${baseVersionVariableName}\"; " +
            "} > \"$CLI_VERSION_RECORD\"; then " +
            "echo \"CLI_VERSION_RECORDED:$CLI_VERSION_RECORD\"; " +
            "else " +
            "echo \"CLI_VERSION_RECORD_FAILED:$ASPIRE_E2E_CLI_VERSION_OUTPUT_DIR\"; " +
            "fi; " +
            "fi";
    }
 
    private static string GetCurrentTestName()
    {
        var testCase = TestContext.Current.TestCase;
 
        return testCase is null
            ? "unknown"
            : $"{testCase.TestClassName}.{testCase.TestMethodName}";
    }
 
    /// <summary>
    /// Prepares a non-Docker terminal environment with prompt counting and workspace navigation.
    /// Used by tests that run with <see cref="CliE2ETestHelpers.CreateTestTerminal"/> (bare bash, no Docker).
    /// </summary>
    internal static async Task PrepareEnvironmentAsync(
        this Hex1bTerminalAutomator auto,
        TemporaryWorkspace workspace,
        SequenceCounter counter)
    {
        await auto.PrepareBashEnvironmentAsync(workspace.WorkspaceRoot.FullName, counter, TimeSpan.FromSeconds(10));
        await auto.RunCommandAsync($"export ASPIRE_E2E_WORKSPACE={AspireCliShellCommandHelpers.QuoteBashArg(workspace.WorkspaceRoot.FullName)}", counter);
 
        if (!CliE2ETestHelpers.IsRunningInCI && ShouldPreserveLocalWorkspace())
        {
            workspace.Preserve();
        }
 
        if (ShouldCaptureWorkspaceDiagnostics())
        {
            await auto.RunCommandAsync(
                "trap 'if [ -n \"$ASPIRE_E2E_WORKSPACE\" ]; then " +
                BuildAspireDiagnosticsCaptureCommand("$ASPIRE_E2E_WORKSPACE") +
                "fi' EXIT",
                counter);
        }
    }
 
    /// <summary>
    /// Installs the Aspire CLI from PR build artifacts in a non-Docker environment.
    /// </summary>
    internal static async Task InstallAspireCliFromPullRequestAsync(
        this Hex1bTerminalAutomator auto,
        int prNumber,
        SequenceCounter counter)
    {
        var command = AspireCliShellCommandHelpers.GetPullRequestInstallCommand(prNumber, AspireCliShellCommandHelpers.MainPullRequestInstallCommandPrefix);
        await auto.RunCommandAsync(command, counter, TimeSpan.FromSeconds(300));
    }
 
    /// <summary>
    /// Configures the PATH and environment variables for the Aspire CLI in a non-Docker environment.
    /// </summary>
    internal static async Task SourceAspireCliEnvironmentAsync(
        this Hex1bTerminalAutomator auto,
        SequenceCounter counter)
    {
        await auto.SourceAspireEnvironmentAsync(counter);
    }
 
    /// <summary>
    /// Verifies the installed Aspire CLI version matches the expected build.
    /// Always checks the dynamic version prefix from eng/Versions.props.
    /// For non-stabilized builds (all normal PR builds), also verifies the commit SHA suffix.
    /// </summary>
    internal static async Task VerifyAspireCliVersionAsync(
        this Hex1bTerminalAutomator auto,
        string commitSha,
        SequenceCounter counter)
    {
        if (commitSha.Length != 40)
        {
            throw new ArgumentException($"Commit SHA must be exactly 40 characters, got {commitSha.Length}: '{commitSha}'", nameof(commitSha));
        }
 
        var shortCommitSha = commitSha[..8];
 
        await auto.TypeAsync("aspire --version");
        await auto.EnterAsync();
 
        // Stabilized PR builds can omit the commit SHA from the printed version, so accept
        // either the expected major/minor marker from eng/Versions.props or the PR commit SHA.
        await auto.WaitUntilAsync(
            snapshot => snapshot.ContainsText(s_expectedStableVersionMarker) || snapshot.ContainsText($"g{shortCommitSha}"),
            timeout: TimeSpan.FromSeconds(10),
            description: $"Aspire CLI version containing '{s_expectedStableVersionMarker}' or 'g{shortCommitSha}'");
 
        await auto.WaitForSuccessPromptAsync(counter);
    }
 
    internal static async Task VerifyPullRequestCliVersionAsync(
        this Hex1bTerminalAutomator auto,
        SequenceCounter counter)
    {
        if (CliE2ETestHelpers.TryGetPullRequestHeadSha(out var commitSha))
        {
            await auto.VerifyAspireCliVersionAsync(commitSha, counter);
        }
    }
 
    private static string GetExpectedStableVersionMarker()
    {
        var versionsPropsPath = Path.Combine(CliE2ETestHelpers.GetRepoRoot(), "eng", "Versions.props");
        var document = XDocument.Load(versionsPropsPath);
 
        var majorVersion = document.Descendants("MajorVersion").FirstOrDefault()?.Value;
        var minorVersion = document.Descendants("MinorVersion").FirstOrDefault()?.Value;
 
        return !string.IsNullOrEmpty(majorVersion) && !string.IsNullOrEmpty(minorVersion)
            ? $"{majorVersion}.{minorVersion}."
            : throw new InvalidOperationException($"Could not determine Aspire version marker from '{versionsPropsPath}'.");
    }
 
    /// <summary>
    /// Configures the PATH and environment variables for the Aspire CLI bundle in a non-Docker environment.
    /// Unlike <see cref="SourceAspireCliEnvironmentAsync"/>, this includes <c>~/.aspire</c> in PATH for bundle tools.
    /// </summary>
    internal static async Task SourceAspireBundleEnvironmentAsync(
        this Hex1bTerminalAutomator auto,
        SequenceCounter counter)
    {
        await auto.SourceAspireEnvironmentAsync(counter, includeBundlePath: true);
    }
 
    /// <summary>
    /// Configures the PATH and environment variables for the Aspire CLI installed via <c>dotnet tool install</c>.
    /// Adds <c>~/.dotnet/tools</c> to PATH and sets the standard Aspire environment variables.
    /// </summary>
    internal static async Task SourceDotnetToolEnvironmentAsync(
        this Hex1bTerminalAutomator auto,
        SequenceCounter counter)
    {
        await auto.RunCommandAsync(
            $"export PATH=~/.dotnet/tools:$PATH {AspireCliShellCommandHelpers.CommonAspireEnvironmentAssignments}",
            counter,
            TimeSpan.FromSeconds(30));
    }
 
    /// <summary>
    /// Clears the terminal screen by running the <c>clear</c> command and waiting for the prompt.
    /// </summary>
    internal static async Task ClearScreenAsync(
        this Hex1bTerminalAutomator auto,
        SequenceCounter counter)
    {
        await auto.RunCommandAsync("clear", counter);
    }
 
    /// <summary>
    /// Ensures polyglot support is enabled for tests.
    /// Polyglot support now defaults to enabled, so this is currently a no-op.
    /// </summary>
    internal static Task EnablePolyglotSupportAsync(
        this Hex1bTerminalAutomator auto,
        SequenceCounter counter)
    {
        _ = auto;
        _ = counter;
        return Task.CompletedTask;
    }
 
    /// <summary>
    /// Enables experimental Java polyglot support for CLI tests.
    /// </summary>
    internal static async Task EnableExperimentalJavaSupportAsync(
        this Hex1bTerminalAutomator auto,
        SequenceCounter counter)
    {
        await auto.TypeAsync("aspire config set features:experimentalPolyglot:java true --global --non-interactive");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter);
    }
 
    /// <summary>
    /// Installs a specific GA version of the Aspire CLI using the install script.
    /// </summary>
    internal static async Task InstallAspireCliVersionAsync(
        this Hex1bTerminalAutomator auto,
        string version,
        SequenceCounter counter)
    {
        var command = AspireCliShellCommandHelpers.GetInstallScriptCommand(
            CliInstallStrategy.FromVersion(version),
            AspireCliShellCommandHelpers.MainInstallScriptCommandPrefix);
        await auto.RunCommandAsync(command, counter, TimeSpan.FromSeconds(300));
    }
 
    /// <summary>
    /// Starts an Aspire AppHost with <c>aspire start --format json</c>, extracts the dashboard URL,
    /// and verifies the dashboard is reachable. Caller is responsible for calling
    /// <see cref="AspireStopAsync"/> when done.
    /// On failure, dumps the latest CLI log file to the terminal output and promotes the highest-signal
    /// diagnostics into the workspace for artifact capture.
    /// </summary>
    /// <param name="auto">The terminal automator.</param>
    /// <param name="counter">The prompt sequence counter.</param>
    /// <param name="startTimeout">How long to wait for <c>aspire start</c> to complete.</param>
    /// <param name="isolated">Pass <c>--isolated</c> to <c>aspire start</c>.</param>
    /// <param name="apphost">Explicit AppHost path to pass via <c>--apphost</c>.</param>
    /// <param name="additionalArgs">Extra arguments appended to the <c>aspire start</c> command.</param>
    /// <param name="skipDashboardCheck">When <see langword="true"/>, skip the dashboard URL extraction and
    /// HTTP health check. Use this for modes like <c>--capture-profile</c> where the AppHost is already
    /// stopped by the time the command returns.</param>
    internal static async Task AspireStartAsync(
        this Hex1bTerminalAutomator auto,
        SequenceCounter counter,
        TimeSpan? startTimeout = null,
        bool isolated = false,
        string? apphost = null,
        string? additionalArgs = null,
        bool skipDashboardCheck = false)
    {
        var effectiveTimeout = startTimeout ?? TimeSpan.FromMinutes(3);
        var expectedCounter = counter.Value;
        // In CI the JSON transcript lives in /tmp first and is copied into the captured workspace on failure.
        // Local runs write directly into the preserved workspace so the file is already where developers inspect it.
        var jsonFile = CliE2ETestHelpers.IsRunningInCI
            ? AspireStartJsonFile
            : "$ASPIRE_E2E_WORKSPACE/_aspire-start.json";
 
        var isolatedFlag = isolated ? " --isolated" : "";
        var apphostFlag = apphost is not null ? $" --apphost {AspireCliShellCommandHelpers.QuoteBashArg(apphost)}" : "";
        var extraArgs = !string.IsNullOrEmpty(additionalArgs) ? $" {additionalArgs}" : "";
        var startupTimeoutSeconds = Math.Max(1, (int)Math.Ceiling(effectiveTimeout.TotalSeconds));
 
        // Keep aspire start as a single shell pipeline so tee captures the exact JSON emitted to the terminal while
        // pipefail preserves the real CLI exit code instead of letting tee mask build/startup failures.
        await auto.TypeAsync($"(set -o pipefail; ASPIRE_CLI_START_TIMEOUT={startupTimeoutSeconds.ToString(CultureInfo.InvariantCulture)} aspire start{isolatedFlag}{apphostFlag}{extraArgs} --format json | tee \"{jsonFile}\")");
        await auto.EnterAsync();
 
        // Wait for the command to finish — check for success or error exit.
        var succeeded = false;
        await auto.WaitUntilAsync(snapshot =>
        {
            var successSearcher = new CellPatternSearcher()
                .FindPattern(expectedCounter.ToString())
                .RightText(" OK] $ ");
            if (successSearcher.Search(snapshot).Count > 0)
            {
                succeeded = true;
                return true;
            }
 
            var errorSearcher = new CellPatternSearcher()
                .FindPattern(expectedCounter.ToString())
                .RightText(" ERR:");
            return errorSearcher.Search(snapshot).Count > 0;
        }, timeout: effectiveTimeout, description: $"aspire start to complete [{expectedCounter} OK/ERR]");
 
        counter.Increment();
 
        if (!succeeded)
        {
            await auto.TypeAsync(
                "LOG=$(ls -t ~/.aspire/logs/cli_*.log 2>/dev/null | head -1); " +
                "echo '=== ASPIRE LOG ==='; " +
                "[ -n \"$LOG\" ] && tail -100 \"$LOG\"; " +
                "echo '=== END LOG ==='; " +
                $"cat \"{jsonFile}\"");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter);
 
            await auto.CaptureRegisteredWorkspaceDiagnosticsAsync(counter);
 
            var workspacePath = GetRegisteredWorkspacePath();
            throw new InvalidOperationException(
                workspacePath is null || !ShouldCaptureWorkspaceDiagnostics()
                    ? "aspire start failed. Check terminal output for CLI logs."
                    : $"aspire start failed. Workspace: {workspacePath}. See {DiagnosticsDirectoryName}/ in the captured workspace.");
        }
 
        if (skipDashboardCheck)
        {
            return;
        }
 
        await auto.TypeAsync(
            $"DASHBOARD_URL=$(sed -n " +
            "'s/.*\"dashboardUrl\"[[:space:]]*:[[:space:]]*\"\\(https\\?:\\/\\/[a-z.]*localhost:[0-9]*\\).*/\\1/p' " +
            $"\"{jsonFile}\" | head -1)");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter);
 
        var dashboardUrlCounter = counter.Value;
        var dashboardUrlFound = false;
 
        // If DASHBOARD_URL is empty, the apphost likely crashed — dump logs for diagnostics.
        await auto.TypeAsync(
            "if [ -z \"$DASHBOARD_URL\" ]; then " +
            "echo 'dashboard-url-empty'; " +
            "echo '=== ASPIRE START JSON ==='; cat \"" + jsonFile + "\"; echo '=== END JSON ==='; " +
            "echo '=== ALL LOGS ==='; ls -lt ~/.aspire/logs/ 2>/dev/null; echo '=== END LIST ==='; " +
            "DETACH_LOG=$(ls -t ~/.aspire/logs/cli_*detach*.log 2>/dev/null | head -1); " +
            "echo \"=== DETACH LOG: $DETACH_LOG ===\"; [ -n \"$DETACH_LOG\" ] && tail -200 \"$DETACH_LOG\"; echo '=== END DETACH ==='; " +
            "CLI_LOG=$(ls -t ~/.aspire/logs/cli_*.log 2>/dev/null | grep -v 'detach' | head -1); " +
            "if [ -z \"$CLI_LOG\" ]; then CLI_LOG=$(ls -t ~/.aspire/logs/cli_*.log 2>/dev/null | head -1); fi; " +
            "echo \"=== CLI LOG: $CLI_LOG ===\"; [ -n \"$CLI_LOG\" ] && tail -100 \"$CLI_LOG\"; echo '=== END CLI ==='; " +
            "false; " +
            "else " +
            "echo \"dashboard-url:$DASHBOARD_URL\"; " +
            "fi");
        await auto.EnterAsync();
 
        await auto.WaitUntilAsync(snapshot =>
        {
            var successSearcher = new CellPatternSearcher()
                .FindPattern(dashboardUrlCounter.ToString())
                .RightText(" OK] $ ");
            if (successSearcher.Search(snapshot).Count > 0)
            {
                dashboardUrlFound = true;
                return true;
            }
 
            var errorSearcher = new CellPatternSearcher()
                .FindPattern(dashboardUrlCounter.ToString())
                .RightText(" ERR:");
            return errorSearcher.Search(snapshot).Count > 0;
        }, timeout: TimeSpan.FromSeconds(30), description: $"dashboard url validation [{dashboardUrlCounter} OK/ERR]");
 
        counter.Increment();
 
        if (!dashboardUrlFound)
        {
            // Missing dashboardUrl is the root startup failure we care about. Stop here so a later curl timeout
            // doesn't replace the useful AppHost / detached-child diagnostics with a secondary HTTP symptom.
            await auto.CaptureRegisteredWorkspaceDiagnosticsAsync(counter);
 
            var workspacePath = GetRegisteredWorkspacePath();
            throw new InvalidOperationException(
                workspacePath is null || !ShouldCaptureWorkspaceDiagnostics()
                    ? "aspire start did not return a dashboard URL. Check terminal output for detached child and CLI logs."
                    : $"aspire start did not return a dashboard URL. Workspace: {workspacePath}. See {DiagnosticsDirectoryName}/ in the captured workspace.");
        }
 
        // Check whether $DASHBOARD_URL was set using variable expansion so the marker
        // text in the output differs from the typed command text. The typed command
        // shows the literal "${DASHBOARD_URL}" on screen, while the shell output
        // shows the expanded value — "URLCHECK::URLEND" when empty.
        await auto.TypeAsync("echo \"URLCHECK:${DASHBOARD_URL}:URLEND\"");
        await auto.EnterAsync();
 
        var dashboardUrlEmpty = false;
        await auto.WaitUntilAsync(snapshot =>
        {
            var emptySearcher = new CellPatternSearcher().FindPattern("URLCHECK::URLEND");
            if (emptySearcher.Search(snapshot).Count > 0)
            {
                dashboardUrlEmpty = true;
            }
 
            var promptSearcher = new CellPatternSearcher()
                .FindPattern(counter.Value.ToString())
                .RightText(" OK] $ ");
            return promptSearcher.Search(snapshot).Count > 0;
        }, timeout: TimeSpan.FromSeconds(30), description: $"dashboard URL check [{counter.Value} OK]");
        counter.Increment();
 
        if (dashboardUrlEmpty)
        {
            throw new InvalidOperationException(
                "Dashboard URL was empty after aspire start. " +
                "The sed extraction failed to find a dashboardUrl in the JSON output. " +
                "Check terminal output for CLI logs and JSON content.");
        }
 
        // Retry curl up to 10 times with 2s delay — the dashboard may still be binding
        // its listening port immediately after aspire start returns.
        await auto.TypeAsync(
            "for i in $(seq 1 10); do " +
            "CODE=$(curl -ksSL -o /dev/null -w '%{http_code}' \"$DASHBOARD_URL\" 2>/dev/null); " +
            "if [ \"$CODE\" = \"200\" ]; then echo 'dashboard-http-200'; break; fi; " +
            "sleep 2; " +
            "done; " +
            "if [ \"$CODE\" != \"200\" ]; then echo \"dashboard-http-${CODE}\"; echo 'dashboard-http-failed'; fi");
        await auto.EnterAsync();
        await auto.WaitUntilTextAsync("dashboard-http-200", timeout: TimeSpan.FromSeconds(30));
        await auto.WaitForSuccessPromptAsync(counter);
    }
 
    /// <summary>
    /// Stops a running Aspire AppHost with <c>aspire stop</c>.
    /// </summary>
    /// <remarks>
    /// Uses <see cref="Hex1bAutomatorTestHelpers.WaitForSuccessPromptAsync"/> so that a
    /// non-zero exit from <c>aspire stop</c> (for example the documented <c>FailedToDotnetRunAppHost</c>
    /// flake in https://github.com/microsoft/aspire/issues/16643) surfaces immediately with a
    /// useful diagnostic rather than the default 500-second wait for the success prompt. <c>aspire stop</c>
    /// is invoked at the end of E2E tests on the happy path; any error result is a real failure to
    /// surface, not something the test should silently sit on.
    /// </remarks>
    internal static async Task AspireStopAsync(
        this Hex1bTerminalAutomator auto,
        SequenceCounter counter,
        string? apphost = null)
    {
        var apphostFlag = apphost is not null ? $" --apphost {AspireCliShellCommandHelpers.QuoteBashArg(apphost)}" : "";
        await auto.TypeAsync($"aspire stop{apphostFlag}");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter);
    }
 
    /// <summary>
    /// Asserts that the specified resources exist in the running AppHost by running
    /// <c>aspire describe &lt;resource&gt; --format json</c> for each expected resource.
    /// The CLI handles name/displayName resolution internally.
    /// On failure, the error output from the CLI is visible in the terminal recording.
    /// </summary>
    internal static async Task AssertResourcesExistAsync(
        this Hex1bTerminalAutomator auto,
        SequenceCounter counter,
        params string[] expectedResourceNames)
    {
        foreach (var resource in expectedResourceNames)
        {
            var expectedCounter = counter.Value;
            await auto.TypeAsync($"aspire describe {resource} --format json");
            await auto.EnterAsync();
 
            var succeeded = false;
            await auto.WaitUntilAsync(s =>
            {
                var successSearcher = new CellPatternSearcher()
                    .FindPattern(expectedCounter.ToString())
                    .RightText(" OK] $ ");
                if (successSearcher.Search(s).Count > 0)
                {
                    succeeded = true;
                    return true;
                }
 
                var errorSearcher = new CellPatternSearcher()
                    .FindPattern(expectedCounter.ToString())
                    .RightText(" ERR:");
                return errorSearcher.Search(s).Count > 0;
            }, timeout: TimeSpan.FromSeconds(30), description: $"aspire describe {resource}");
 
            counter.Increment();
 
            if (!succeeded)
            {
                // Dump all resources so we can see what's actually running
                await auto.TypeAsync("aspire describe --format json");
                await auto.EnterAsync();
                await auto.WaitForAnyPromptAsync(counter);
 
                throw new InvalidOperationException(
                    $"Resource '{resource}' not found. 'aspire describe {resource}' exited with an error. " +
                    "Check the terminal recording for the full resource list above.");
            }
        }
    }
 
    /// <summary>
    /// Copies interesting diagnostics from <c>~/.aspire</c> to the workspace so they are captured by
    /// <see cref="CaptureWorkspaceOnFailureAttribute"/>. Call this before exiting the terminal.
    /// </summary>
    internal static async Task CaptureAspireDiagnosticsAsync(
        this Hex1bTerminalAutomator auto,
        SequenceCounter counter,
        TemporaryWorkspace workspace)
    {
        if (!CliE2ETestHelpers.IsRunningInCI)
        {
            await auto.TypeAsync("echo diagnostics-available-in-workspace");
            await auto.EnterAsync();
            await auto.WaitForSuccessPromptAsync(counter);
            return;
        }
 
        var containerWorkspace = $"/workspace/{workspace.WorkspaceRoot.Name}";
 
        await auto.TypeAsync(BuildAspireDiagnosticsCaptureCommand(containerWorkspace) + "echo done");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter);
    }
 
    /// <summary>
    /// Destroys the current deployment using <c>aspire destroy --yes</c> and waits for pipeline success.
    /// </summary>
    internal static async Task AspireDestroyAsync(
        this Hex1bTerminalAutomator auto,
        SequenceCounter counter,
        TimeSpan? timeout = null)
    {
        timeout ??= TimeSpan.FromMinutes(2);
        await auto.TypeAsync("aspire destroy --yes");
        await auto.EnterAsync();
        await auto.WaitForPipelineSuccessAsync(timeout: timeout.Value);
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(1));
    }
 
    private static async Task CaptureRegisteredWorkspaceDiagnosticsAsync(
        this Hex1bTerminalAutomator auto,
        SequenceCounter counter)
    {
        if (!ShouldCaptureWorkspaceDiagnostics())
        {
            return;
        }
 
        await auto.TypeAsync(
            "if [ -n \"$ASPIRE_E2E_WORKSPACE\" ]; then " +
            BuildAspireDiagnosticsCaptureCommand("$ASPIRE_E2E_WORKSPACE") +
            "echo \"copied-failure-artifacts:$ASPIRE_E2E_WORKSPACE\"; " +
            "fi");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter);
    }
 
    /// <summary>
    /// The well-known subdirectory name under the workspace where diagnostics are captured.
    /// Both the in-Docker bash capture and the host-side <see cref="TerminalRun"/> copy use this name.
    /// </summary>
    internal const string DiagnosticsDirectoryName = ".aspire-diagnostics";
 
    private static string BuildAspireDiagnosticsCaptureCommand(string destinationExpression)
    {
        // This returns a single bash fragment because it is reused from EXIT traps and failure paths where the helper
        // needs to inject one inline shell command rather than orchestrate several terminal round-trips.
        // All diagnostics are placed under a single .aspire-diagnostics/ subdirectory so the host-side
        // capture in TerminalRun can copy one directory instead of enumerating individual files.
        var diag = $"{destinationExpression}/{DiagnosticsDirectoryName}";
        return
            $"mkdir -p \"{diag}\"; " +
            $"rm -rf \"{diag}/logs\" \"{diag}/packages\" \"{diag}/dcp-logs\"; " +
            $"cp -r ~/.aspire/logs \"{diag}/logs\" 2>/dev/null || true; " +
            $"cp -r ~/.aspire/packages \"{diag}/packages\" 2>/dev/null || true; " +
            $"cp -r ~/.aspire/dcp-logs \"{diag}/dcp-logs\" 2>/dev/null || true; " +
            $"cp {AspireStartJsonFile} \"{diag}/aspire-start.json\" 2>/dev/null || true; " +
            $"echo \"diagnostics: logs=$(find \"{diag}/logs\" -type f 2>/dev/null | wc -l) " +
            $"packages=$(find \"{diag}/packages\" -type f 2>/dev/null | wc -l) " +
            $"dcp-logs=$(find \"{diag}/dcp-logs\" -type f 2>/dev/null | wc -l)\"; ";
    }
 
    private static string? GetRegisteredWorkspacePath()
    {
        if (TestContext.Current?.KeyValueStorage.TryGetValue("WorkspacePath", out var value) == true &&
            value is string workspacePath)
        {
            return workspacePath;
        }
 
        return null;
    }
 
    /// <summary>
    /// Sends a JSON-RPC initialize/initialized/tools-call sequence to <c>aspire agent mcp</c> and verifies the response.
    /// </summary>
    /// <param name="auto">The terminal automator.</param>
    /// <param name="counter">The prompt sequence counter.</param>
    /// <param name="toolName">The MCP tool name to invoke (e.g. <c>list_structured_logs</c>).</param>
    /// <param name="expectedMarker">A string expected in the tool call output (e.g. <c>STRUCTURED LOGS DATA</c>).</param>
    /// <param name="doesNotContainMarker">An optional string that must NOT appear in the tool call output.</param>
    /// <param name="mcpArgs">Additional arguments to pass to <c>aspire agent mcp</c> (e.g. <c>--dashboard-url "..."</c>).</param>
    internal static async Task CallAgentMcpToolAsync(
        this Hex1bTerminalAutomator auto,
        SequenceCounter counter,
        string toolName,
        string expectedMarker,
        string? doesNotContainMarker = null,
        string? mcpArgs = null)
    {
        var argsFragment = mcpArgs is not null ? $" {mcpArgs}" : string.Empty;
 
        // Send JSON-RPC messages to the MCP server via a compound command.
        // The sleeps ensure proper protocol timing between initialize, initialized notification, and tool call.
        await auto.TypeAsync(
            "{ " +
            "echo '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{},\"clientInfo\":{\"name\":\"e2e-test\",\"version\":\"0.1.0\"}}}'; " +
            "sleep 3; " +
            "echo '{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}'; " +
            "sleep 1; " +
            $"echo '{{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{{\"name\":\"{toolName}\",\"arguments\":{{}}}}}}'; " +
            "sleep 15; " +
            $"}} | aspire agent mcp{argsFragment} > /tmp/mcp_out.txt 2>/tmp/mcp_err.txt || true");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(60));
 
        // Dump output for debugging visibility in the recording
        await auto.TypeAsync("cat /tmp/mcp_out.txt | head -50");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter);
 
        // Check that the response contains the expected data marker
        await auto.TypeAsync(
            $"if grep -q '{expectedMarker}' /tmp/mcp_out.txt; then echo 'MCP_DATA_PRESENT'; " +
            $"elif grep -q '{toolName}' /tmp/mcp_out.txt; then echo 'MCP_TOOL_FOUND_BUT_NO_DATA'; " +
            "else echo 'MCP_DATA_MISSING'; fi");
        await auto.EnterAsync();
        await auto.WaitUntilTextAsync("MCP_DATA_PRESENT", timeout: TimeSpan.FromSeconds(10));
        await auto.WaitForAnyPromptAsync(counter);
 
        // If a doesNotContainMarker is specified, verify it is NOT in the output
        if (doesNotContainMarker is not null)
        {
            await auto.TypeAsync(
                $"if grep -q '{doesNotContainMarker}' /tmp/mcp_out.txt; then echo 'MCP_EXCLUDED_MARKER_FOUND'; " +
                "else echo 'MCP_EXCLUDED_MARKER_ABSENT'; fi");
            await auto.EnterAsync();
            await auto.WaitUntilTextAsync("MCP_EXCLUDED_MARKER_ABSENT", timeout: TimeSpan.FromSeconds(10));
            await auto.WaitForAnyPromptAsync(counter);
        }
 
        // Verify the initialize response was received (confirms MCP handshake worked)
        await auto.TypeAsync(
            "grep -q 'aspire-mcp-server' /tmp/mcp_out.txt && echo 'MCP_INIT_OK' || echo 'MCP_INIT_MISSING'");
        await auto.EnterAsync();
        await auto.WaitUntilTextAsync("MCP_INIT_OK", timeout: TimeSpan.FromSeconds(10));
        await auto.WaitForAnyPromptAsync(counter);
    }
 
    private static bool ShouldPreserveLocalWorkspace()
    {
        return TestContext.Current?.KeyValueStorage.TryGetValue("PreserveWorkspaceOnFailure", out var value) == true &&
            value is true;
    }
 
    private static bool ShouldCaptureWorkspaceDiagnostics()
    {
        return CliE2ETestHelpers.IsRunningInCI || ShouldPreserveLocalWorkspace();
    }
 
    private enum AspireNewEmptyAppHostResult
    {
        None,
        LanguagePrompt,
        AgentInitPrompt,
        SuccessPrompt,
        ErrorPrompt
    }
 
    private enum AppHostLanguagePromptSelection
    {
        DefaultCSharp,
        TypeScript
    }
}