File: Interaction\ConsoleInteractionServiceTests.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 Microsoft.AspNetCore.InternalTesting;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Interaction;
using Aspire.Cli.Resources;
using Aspire.Cli.Tests.Utils;
using Aspire.Cli.Utils;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Spectre.Console;
using Spectre.Console.Rendering;
 
using System.CommandLine;
using System.Text;
 
namespace Aspire.Cli.Tests.Interaction;
 
public class ConsoleInteractionServiceTests
{
    private static readonly DirectoryInfo s_tempRoot = Directory.CreateTempSubdirectory();
    private static readonly DirectoryInfo s_runtimeDirectory = s_tempRoot.CreateSubdirectory("runtimes");
    private static readonly DirectoryInfo s_logsDirectory = s_tempRoot.CreateSubdirectory("logs");
 
    private static CliExecutionContext CreateExecutionContext(bool debugMode = false, LogLevel? consoleLogLevel = null, string? logFilePath = null) =>
        new(new DirectoryInfo("."), new DirectoryInfo("."), new DirectoryInfo("."), s_runtimeDirectory, s_logsDirectory, logFilePath ?? "test.log", identityChannel: "local", debugMode: debugMode, consoleLogLevel: consoleLogLevel);
 
    private static ConsoleInteractionService CreateInteractionService(IAnsiConsole console, CliExecutionContext? executionContext = null, ICliHostEnvironment? hostEnvironment = null, ILoggerFactory? loggerFactory = null)
    {
        executionContext ??= CreateExecutionContext();
        var consoleEnvironment = new ConsoleEnvironment(console, console);
        return new ConsoleInteractionService(consoleEnvironment, executionContext, hostEnvironment ?? TestHelpers.CreateInteractiveHostEnvironment(), new EnvironmentProcessPathProvider(), loggerFactory ?? NullLoggerFactory.Instance, new ConsoleLogBufferContext());
    }
 
    [Fact]
    public async Task PromptForSelectionAsync_EmptyChoices_ThrowsEmptyChoicesException()
    {
        // Arrange
        var interactionService = CreateInteractionService(AnsiConsole.Console);
        var choices = Array.Empty<string>();
 
        // Act & Assert
        await Assert.ThrowsAsync<EmptyChoicesException>(() =>
            interactionService.PromptForSelectionAsync("Select an item:", choices, x => x, cancellationToken: CancellationToken.None));
    }
 
    [Fact]
    public async Task PromptForSelectionsAsync_EmptyChoices_ThrowsEmptyChoicesException()
    {
        // Arrange
        var interactionService = CreateInteractionService(AnsiConsole.Console);
        var choices = Array.Empty<string>();
 
        // Act & Assert
        await Assert.ThrowsAsync<EmptyChoicesException>(() =>
            interactionService.PromptForSelectionsAsync("Select items:", choices, x => x, cancellationToken: CancellationToken.None));
    }
 
    [Fact]
    public void DisplayError_WithMarkupCharacters_DoesNotCauseMarkupParsingError()
    {
        // Arrange
        var output = new StringBuilder();
        var console = AnsiConsole.Create(new AnsiConsoleSettings
        {
            Ansi = AnsiSupport.No,
            ColorSystem = ColorSystemSupport.NoColors,
            Out = new AnsiConsoleOutput(new StringWriter(output))
        });
        
        var interactionService = CreateInteractionService(console);
        var errorMessage = "The JSON value could not be converted to <Type>. Path: $.values[0].Type | LineNumber: 0 | BytePositionInLine: 121.";
 
        // Act - this should not throw an exception due to markup parsing
        var exception = Record.Exception(() => interactionService.DisplayError(errorMessage));
 
        // Assert
        Assert.Null(exception);
        var outputString = output.ToString();
        Assert.Contains("The JSON value could not be converted to", outputString);
    }
 
    [Fact]
    public void DisplaySubtleMessage_WithMarkupCharacters_DoesNotCauseMarkupParsingError()
    {
        // Arrange
        var output = new StringBuilder();
        var console = AnsiConsole.Create(new AnsiConsoleSettings
        {
            Ansi = AnsiSupport.No,
            ColorSystem = ColorSystemSupport.NoColors,
            Out = new AnsiConsoleOutput(new StringWriter(output))
        });
        
        var interactionService = CreateInteractionService(console);
        var message = "Path with <brackets> and [markup] characters";
 
        // Act - this should not throw an exception due to markup parsing
        var exception = Record.Exception(() => interactionService.DisplaySubtleMessage(message));
 
        // Assert
        Assert.Null(exception);
        var outputString = output.ToString();
        Assert.Contains("Path with <brackets> and [markup] characters", outputString);
    }
 
    [Fact]
    public void DisplayLines_WithMarkupCharacters_DoesNotCauseMarkupParsingError()
    {
        // Arrange
        var output = new StringBuilder();
        var console = AnsiConsole.Create(new AnsiConsoleSettings
        {
            Ansi = AnsiSupport.No,
            ColorSystem = ColorSystemSupport.NoColors,
            Out = new AnsiConsoleOutput(new StringWriter(output))
        });
        
        var interactionService = CreateInteractionService(console);
        var lines = new[]
        {
            (OutputLineStream.StdOut, "Command output with <angle> brackets"),
            (OutputLineStream.StdErr, "Error output with [square] brackets")
        };
 
        // Act - this should not throw an exception due to markup parsing
        var exception = Record.Exception(() => interactionService.DisplayLines(lines));
 
        // Assert
        Assert.Null(exception);
        var outputString = output.ToString();
        Assert.Contains("Command output with <angle> brackets", outputString);
        // EscapeMarkup() escapes [ to [[ for Spectre's parser, but Spectre renders [[ back to literal [
        Assert.Contains("Error output with [square] brackets", outputString);
    }
 
    [Fact]
    public void DisplayMarkdown_WithPlainText_DoesNotThrow()
    {
        // Arrange
        var output = new StringBuilder();
        var console = AnsiConsole.Create(new AnsiConsoleSettings
        {
            Ansi = AnsiSupport.No,
            ColorSystem = ColorSystemSupport.NoColors,
            Out = new AnsiConsoleOutput(new StringWriter(output))
        });
        
        var interactionService = CreateInteractionService(console);
        var plainText = "This is just plain text without any markdown.";
 
        // Act
        var exception = Record.Exception(() => interactionService.DisplayMarkdown(plainText));
 
        // Assert
        Assert.Null(exception);
        var outputString = output.ToString();
        Assert.Contains("This is just plain text without any markdown.", outputString);
    }
 
    [Fact]
    public void DisplayMarkdown_WhenNonInteractive_UsesPlainTextFallback()
    {
        var output = new StringBuilder();
        var console = AnsiConsole.Create(new AnsiConsoleSettings
        {
            Ansi = AnsiSupport.No,
            ColorSystem = ColorSystemSupport.NoColors,
            Out = new AnsiConsoleOutput(new StringWriter(output))
        });
 
        var interactionService = CreateInteractionService(console, hostEnvironment: TestHelpers.CreateNonInteractiveHostEnvironment());
 
        interactionService.DisplayMarkdown("Visit [GitHub](https://github.com) for more info.");
 
        Assert.Contains("Visit GitHub (https://github.com) for more info.", output.ToString());
    }
 
    [Fact]
    public void DisplayMarkdown_WithTable_RendersReadableInteractiveOutput()
    {
        var output = new StringBuilder();
        var console = AnsiConsole.Create(new AnsiConsoleSettings
        {
            Ansi = AnsiSupport.No,
            ColorSystem = ColorSystemSupport.NoColors,
            Out = new AnsiConsoleOutput(new StringWriter(output))
        });
 
        var executionContext = CreateExecutionContext();
        var interactionService = CreateInteractionService(console, executionContext);
        var markdown = """
            | Setting | Environment variable | Purpose |
            | ------- | -------------------- | ------- |
            | `Azure:SubscriptionId` | `Azure__SubscriptionId` | Target Azure subscription |
            """;
 
        interactionService.DisplayMarkdown(markdown);
 
        var outputString = output.ToString().Replace("\r\n", "\n");
 
        Assert.Contains("Setting", outputString);
        Assert.Contains("Environment variable", outputString);
        Assert.Contains("Purpose", outputString);
        Assert.Contains("Azure:SubscriptionId", outputString);
        Assert.Contains("Azure__SubscriptionId", outputString);
        Assert.Contains("Target Azure subscription", outputString);
        Assert.True(outputString.Split('\n', StringSplitOptions.RemoveEmptyEntries).Length >= 3);
    }
 
    [Theory]
    [MemberData(nameof(DisplayMarkdown_BetweenPlainText_Data))]
    public void DisplayMarkdown_BetweenPlainText_DoesNotInsertExtraEmptyLine(string markdown, string expectedOutput)
    {
        var output = new StringBuilder();
        var console = AnsiConsole.Create(new AnsiConsoleSettings
        {
            Ansi = AnsiSupport.No,
            ColorSystem = ColorSystemSupport.NoColors,
            Out = new AnsiConsoleOutput(new StringWriter(output))
        });
 
        var interactionService = CreateInteractionService(console);
 
        interactionService.DisplayPlainText("before markdown");
        interactionService.DisplayMarkdown(markdown);
        interactionService.DisplayPlainText("after markdown");
 
        Assert.Equal(expectedOutput, output.ToString(), ignoreLineEndingDifferences: true);
    }
 
    public static TheoryData<string, string> DisplayMarkdown_BetweenPlainText_Data => new()
    {
        {
            """
            # Heading
 
            This is a paragraph.
            """,
            """
            before markdown
            Heading
 
            This is a paragraph.
 
            after markdown
 
            """
        },
        {
            "**bold**",
            """
            before markdown
            bold
            after markdown
 
            """
        },
    };
 
    [Fact]
    public async Task ShowStatusAsync_InDebugMode_DisplaysSubtleMessageInsteadOfSpinner()
    {
        // Arrange
        var output = new StringBuilder();
        var console = AnsiConsole.Create(new AnsiConsoleSettings
        {
            Ansi = AnsiSupport.No,
            ColorSystem = ColorSystemSupport.NoColors,
            Out = new AnsiConsoleOutput(new StringWriter(output))
        });
 
        var executionContext = CreateExecutionContext(debugMode: true);
        var interactionService = CreateInteractionService(console, executionContext);
        var statusText = "Processing request...";
 
        // Act
        var statusValue = await interactionService.ShowStatusAsync(statusText, () => Task.FromResult(GetInStatus(interactionService))).DefaultTimeout();
 
        // Assert
        Assert.Equal(0, statusValue);
        var outputString = output.ToString();
        Assert.Contains(statusText, outputString);
    }
 
    [Fact]
    public async Task ShowDynamicStatusAsync_InDebugMode_DisplaysSubtleMessagesInsteadOfSpinner()
    {
        var output = new StringBuilder();
        var console = AnsiConsole.Create(new AnsiConsoleSettings
        {
            Ansi = AnsiSupport.No,
            ColorSystem = ColorSystemSupport.NoColors,
            Out = new AnsiConsoleOutput(new StringWriter(output))
        });
 
        var interactionService = CreateInteractionService(console, CreateExecutionContext(debugMode: true));
 
        var statusValue = await interactionService.ShowDynamicStatusAsync("Processing request...", updateStatus =>
        {
            updateStatus("Still processing...");
            return Task.FromResult(GetInStatus(interactionService));
        }).DefaultTimeout();
 
        Assert.Equal(0, statusValue);
        Assert.Contains("Processing request...", output.ToString());
        Assert.Contains("Still processing...", output.ToString());
    }
 
    [Theory]
    [InlineData(LogLevel.Trace)]
    [InlineData(LogLevel.Debug)]
    [InlineData(LogLevel.Information)]
    [InlineData(LogLevel.Warning)]
    [InlineData(LogLevel.Error)]
    [InlineData(LogLevel.Critical)]
    public async Task StatusMethods_WithConsoleLogging_DoNotStartSpinner(LogLevel consoleLogLevel)
    {
        var output = new StringBuilder();
        using var loggerFactory = LoggerFactory.Create(builder => builder.AddProvider(new SpectreConsoleLoggerProvider(new StringWriter(output), new ConsoleLogBufferContext())));
        var console = AnsiConsole.Create(new AnsiConsoleSettings
        {
            Ansi = AnsiSupport.No,
            ColorSystem = ColorSystemSupport.NoColors,
            Out = new AnsiConsoleOutput(new StringWriter(output))
        });
        var interactionService = CreateInteractionService(console, CreateExecutionContext(consoleLogLevel: consoleLogLevel), loggerFactory: loggerFactory);
 
        var asyncStatusValue = await interactionService.ShowStatusAsync("Working...", () => Task.FromResult(GetInStatus(interactionService))).DefaultTimeout();
        var dynamicStatusValue = await interactionService.ShowDynamicStatusAsync("Working...", updateStatus =>
        {
            updateStatus("Still working...");
            return Task.FromResult(GetInStatus(interactionService));
        }).DefaultTimeout();
        var synchronousStatusValue = -1;
        interactionService.ShowStatus("Working...", () => synchronousStatusValue = GetInStatus(interactionService));
 
        Assert.Equal(0, asyncStatusValue);
        Assert.Equal(0, dynamicStatusValue);
        Assert.Equal(0, synchronousStatusValue);
        Assert.Contains("Working...", output.ToString());
        Assert.Contains("Still working...", output.ToString());
    }
 
    [Fact]
    public async Task ShowStatusAsync_WithConsoleLoggingDisabled_StartsSpinner()
    {
        var interactionService = CreateInteractionService(AnsiConsole.Console, CreateExecutionContext(consoleLogLevel: LogLevel.None));
 
        var statusValue = await interactionService.ShowStatusAsync("Working...", () => Task.FromResult(GetInStatus(interactionService))).DefaultTimeout();
 
        Assert.Equal(1, statusValue);
    }
 
    [Fact]
    public void ShowStatus_InDebugMode_DisplaysSubtleMessageInsteadOfSpinner()
    {
        // Arrange
        var output = new StringBuilder();
        var console = AnsiConsole.Create(new AnsiConsoleSettings
        {
            Ansi = AnsiSupport.No,
            ColorSystem = ColorSystemSupport.NoColors,
            Out = new AnsiConsoleOutput(new StringWriter(output))
        });
 
        var executionContext = CreateExecutionContext(debugMode: true);
        var interactionService = CreateInteractionService(console, executionContext);
        var statusText = "Processing synchronous request...";
        var statusValue = -1;
 
        // Act
        interactionService.ShowStatus(statusText, () => statusValue = GetInStatus(interactionService));
 
        // Assert
        Assert.Equal(0, statusValue);
        var outputString = output.ToString();
        Assert.Contains(statusText, outputString);
    }
 
    [Fact]
    public async Task PromptForStringAsync_WhenInteractiveInputNotSupported_ThrowsInvalidOperationException()
    {
        // Arrange
        var interactionService = CreateInteractionService(AnsiConsole.Console, hostEnvironment: TestHelpers.CreateNonInteractiveHostEnvironment());
 
        // Act & Assert
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
            interactionService.PromptForStringAsync("Enter value:", null, false, false, binding: null, cancellationToken: CancellationToken.None));
        Assert.Contains(InteractionServiceStrings.InteractiveInputNotSupported, exception.Message);
    }
 
    [Fact]
    public async Task PromptForSelectionAsync_WhenInteractiveInputNotSupported_ThrowsInvalidOperationException()
    {
        // Arrange
        var interactionService = CreateInteractionService(AnsiConsole.Console, hostEnvironment: TestHelpers.CreateNonInteractiveHostEnvironment());
        var choices = new[] { "option1", "option2" };
 
        // Act & Assert
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
            interactionService.PromptForSelectionAsync("Select an item:", choices, x => x, cancellationToken: CancellationToken.None));
        Assert.Contains(InteractionServiceStrings.InteractiveInputNotSupported, exception.Message);
    }
 
    [Fact]
    public async Task PromptForSelectionsAsync_WhenInteractiveInputNotSupported_ThrowsInvalidOperationException()
    {
        // Arrange
        var interactionService = CreateInteractionService(AnsiConsole.Console, hostEnvironment: TestHelpers.CreateNonInteractiveHostEnvironment());
        var choices = new[] { "option1", "option2" };
 
        // Act & Assert
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
            interactionService.PromptForSelectionsAsync("Select items:", choices, x => x, cancellationToken: CancellationToken.None));
        Assert.Contains(InteractionServiceStrings.InteractiveInputNotSupported, exception.Message);
    }
 
    [Fact]
    public async Task ConfirmAsync_WhenInteractiveInputNotSupported_ThrowsInvalidOperationException()
    {
        // Arrange
        var interactionService = CreateInteractionService(AnsiConsole.Console, hostEnvironment: TestHelpers.CreateNonInteractiveHostEnvironment());
 
        // Act & Assert
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
            interactionService.PromptConfirmAsync("Confirm?", cancellationToken: CancellationToken.None));
        Assert.Contains(InteractionServiceStrings.InteractiveInputNotSupported, exception.Message);
    }
 
    [Fact]
    public async Task ShowStatusAsync_NestedCall_DoesNotThrowException()
    {
        // Arrange
        var output = new StringBuilder();
        var console = AnsiConsole.Create(new AnsiConsoleSettings
        {
            Ansi = AnsiSupport.No,
            ColorSystem = ColorSystemSupport.NoColors,
            Out = new AnsiConsoleOutput(new StringWriter(output))
        });
 
        var interactionService = CreateInteractionService(console);
        
        var outerStatusText = "Outer operation...";
        var innerStatusText = "Inner operation...";
        var expectedResult = 42;
 
        // Act
        var actualResult = await interactionService.ShowStatusAsync(outerStatusText, async () =>
        {
            // This nested call should not throw - it should fall back to DisplaySubtleMessage
            return await interactionService.ShowStatusAsync(innerStatusText, () => Task.FromResult(expectedResult));
        });
 
        // Assert
        Assert.Equal(expectedResult, actualResult);
        var outputString = output.ToString();
        Assert.Contains(outerStatusText, outputString);
        Assert.Contains(innerStatusText, outputString);
    }
 
    [Fact]
    public void ShowStatus_NestedCall_DoesNotThrowException()
    {
        // Arrange
        var output = new StringBuilder();
        var console = AnsiConsole.Create(new AnsiConsoleSettings
        {
            Ansi = AnsiSupport.No,
            ColorSystem = ColorSystemSupport.NoColors,
            Out = new AnsiConsoleOutput(new StringWriter(output))
        });
 
        var interactionService = CreateInteractionService(console);
        
        var outerStatusText = "Outer synchronous operation...";
        var innerStatusText = "Inner synchronous operation...";
        var actionExecuted = false;
 
        // Act
        interactionService.ShowStatus(outerStatusText, () =>
        {
            // This nested call should not throw - it should fall back to DisplaySubtleMessage
            interactionService.ShowStatus(innerStatusText, () => actionExecuted = true);
        });
 
        // Assert
        Assert.True(actionExecuted);
        var outputString = output.ToString();
        Assert.Contains(outerStatusText, outputString);
        Assert.Contains(innerStatusText, outputString);
    }
 
    [Fact]
    public async Task ShowStatusAsync_FallbackPath_DoesNotLeaveInStatusFlagSet()
    {
        // Regression test: when ShowStatusAsync takes the fallback path due to debug/non-interactive
        // mode or empty status text, the _inStatus flag must not be set to 1 (and left there).
        // Previously the CompareExchange ran first in the || chain, so the swap happened before the
        // other conditions short-circuited, leaving _inStatus=1 with no try/finally to reset it.
        var output = new StringBuilder();
        var console = AnsiConsole.Create(new AnsiConsoleSettings
        {
            Ansi = AnsiSupport.No,
            ColorSystem = ColorSystemSupport.NoColors,
            Out = new AnsiConsoleOutput(new StringWriter(output))
        });
 
        // Debug mode forces the fallback path even though host environment supports interactive output.
        var executionContext = CreateExecutionContext(debugMode: true);
        var interactionService = CreateInteractionService(console, executionContext);
 
        await interactionService.ShowStatusAsync("Working...", () => Task.FromResult(0)).DefaultTimeout();
 
        Assert.Equal(0, GetInStatus(interactionService));
    }
 
    [Fact]
    public async Task ShowStatusAsync_FallbackPathWithEmptyText_DoesNotLeaveInStatusFlagSet()
    {
        // Regression test: empty status text triggers the fallback path. The flag must not be left set.
        var interactionService = CreateInteractionService(AnsiConsole.Console);
 
        await interactionService.ShowStatusAsync(string.Empty, () => Task.FromResult(0)).DefaultTimeout();
 
        Assert.Equal(0, GetInStatus(interactionService));
    }
 
    [Fact]
    public async Task ShowDynamicStatusAsync_FallbackPath_DoesNotLeaveInStatusFlagSet()
    {
        var executionContext = CreateExecutionContext(debugMode: true);
        var interactionService = CreateInteractionService(AnsiConsole.Console, executionContext);
 
        await interactionService.ShowDynamicStatusAsync<int>("Working...", _ => Task.FromResult(0)).DefaultTimeout();
 
        Assert.Equal(0, GetInStatus(interactionService));
    }
 
    [Fact]
    public void ShowStatus_FallbackPath_DoesNotLeaveInStatusFlagSet()
    {
        var executionContext = CreateExecutionContext(debugMode: true);
        var interactionService = CreateInteractionService(AnsiConsole.Console, executionContext);
 
        interactionService.ShowStatus("Working...", () => { });
 
        Assert.Equal(0, GetInStatus(interactionService));
    }
 
    private static int GetInStatus(ConsoleInteractionService service)
    {
        var field = typeof(ConsoleInteractionService).GetField("_inStatus", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
        Assert.NotNull(field);
        return (int)field!.GetValue(service)!;
    }
 
    [Fact]
    public void DisplayIncompatibleVersionError_WithMarkupCharactersInVersion_DoesNotThrow()
    {
        // Arrange
        var output = new StringBuilder();
        var console = AnsiConsole.Create(new AnsiConsoleSettings
        {
            Ansi = AnsiSupport.No,
            ColorSystem = ColorSystemSupport.NoColors,
            Out = new AnsiConsoleOutput(new StringWriter(output))
        });
 
        var interactionService = CreateInteractionService(console);
 
        var ex = new AppHostIncompatibleException("Incompatible [version]", "capability [Prod]");
 
        // Act - should not throw due to unescaped markup characters
        var exception = Record.Exception(() => interactionService.DisplayIncompatibleVersionError(ex, "9.0.0-preview.1 [rc]"));
 
        // Assert
        Assert.Null(exception);
        var outputString = output.ToString();
        Assert.Contains("capability [Prod]", outputString);
        Assert.Contains("9.0.0-preview.1 [rc]", outputString);
    }
 
    private static (ConsoleInteractionService InteractionService, StringBuilder Output) CreateInteractionServiceWithOutputCapture()
    {
        var output = new StringBuilder();
        var console = AnsiConsole.Create(new AnsiConsoleSettings
        {
            Ansi = AnsiSupport.No,
            ColorSystem = ColorSystemSupport.NoColors,
            Out = new AnsiConsoleOutput(new StringWriter(output))
        });
 
        // Use a wide profile so assertions on full message text aren't broken by line wrapping.
        console.Profile.Width = 512;
 
        return (CreateInteractionService(console), output);
    }
 
    [Fact]
    public void DisplayIncompatibleVersionError_AppHostOlderThanCli_SuggestsUpdatingAppHost()
    {
        // Arrange
        var (interactionService, output) = CreateInteractionServiceWithOutputCapture();
        var ex = new AppHostIncompatibleException("Incompatible", "baseline.v2");
 
        // Act - hosting version is older than any CLI version.
        var exitCode = interactionService.DisplayIncompatibleVersionError(ex, "0.1.0");
 
        // Assert
        Assert.Equal(CliExitCodes.AppHostIncompatible, exitCode);
        var outputString = output.ToString();
        Assert.Contains(InteractionServiceStrings.AppHostNotCompatibleUpdateAppHost, outputString);
        Assert.Contains("aspire update", outputString);
    }
 
    [Fact]
    public void DisplayIncompatibleVersionError_CliOlderThanAppHost_SuggestsUpdatingCli()
    {
        // Arrange
        var (interactionService, output) = CreateInteractionServiceWithOutputCapture();
        var ex = new AppHostIncompatibleException("Incompatible", "baseline.v2");
 
        // Act - hosting version is newer than any CLI version.
        var exitCode = interactionService.DisplayIncompatibleVersionError(ex, "999.0.0");
 
        // Assert
        Assert.Equal(CliExitCodes.AppHostIncompatible, exitCode);
        var outputString = output.ToString();
        Assert.Contains(InteractionServiceStrings.AppHostNotCompatibleUpdateCli, outputString);
        Assert.Contains("To update, run:", outputString);
    }
 
    [Fact]
    public void DisplayIncompatibleVersionError_SameVersion_ShowsGenericMessageWithoutUpdateCommand()
    {
        // Arrange
        var (interactionService, output) = CreateInteractionServiceWithOutputCapture();
        var ex = new AppHostIncompatibleException("Incompatible", "baseline.v2");
 
        // Act - same version on both sides (incompatible for another reason, e.g. a capability).
        var exitCode = interactionService.DisplayIncompatibleVersionError(ex, VersionHelper.GetDefaultTemplateVersion());
 
        // Assert
        Assert.Equal(CliExitCodes.AppHostIncompatible, exitCode);
        var outputString = output.ToString();
        Assert.Contains(InteractionServiceStrings.AppHostNotCompatibleConsiderUpgrading, outputString);
        Assert.DoesNotContain("To update, run:", outputString);
    }
 
    [Fact]
    public void DisplayIncompatibleVersionError_UnparseableVersion_ShowsGenericMessage()
    {
        // Arrange
        var (interactionService, output) = CreateInteractionServiceWithOutputCapture();
        var ex = new AppHostIncompatibleException("Incompatible", "baseline.v2");
 
        // Act - the caller passes the required capability when no version is available.
        var exitCode = interactionService.DisplayIncompatibleVersionError(ex, "baseline.v2");
 
        // Assert
        Assert.Equal(CliExitCodes.AppHostIncompatible, exitCode);
        var outputString = output.ToString();
        Assert.Contains(InteractionServiceStrings.AppHostNotCompatibleConsiderUpgrading, outputString);
        Assert.DoesNotContain("To update, run:", outputString);
    }
 
    [Fact]
    public void DisplayMessage_WithMarkupCharactersInMessage_AutoEscapesByDefault()
    {
        // Arrange
        var output = new StringBuilder();
        var console = AnsiConsole.Create(new AnsiConsoleSettings
        {
            Ansi = AnsiSupport.No,
            ColorSystem = ColorSystemSupport.NoColors,
            Out = new AnsiConsoleOutput(new StringWriter(output))
        });
 
        var interactionService = CreateInteractionService(console);
 
        // DisplayMessage now auto-escapes by default, so callers don't need to escape.
        var message = "See logs at C:\\Users\\test [Dev]\\logs\\aspire.log";
 
        // Act - should not throw since DisplayMessage escapes by default
        var exception = Record.Exception(() => interactionService.DisplayMessage(KnownEmojis.PageFacingUp, message));
 
        // Assert
        Assert.Null(exception);
        var outputString = output.ToString();
        Assert.Contains("C:\\Users\\test [Dev]\\logs\\aspire.log", outputString);
    }
 
    [Fact]
    public void DisplayMessage_WithFileLinkMarkup_RendersClickableLinkWhenAllowMarkup()
    {
        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.Capabilities.Links = true;
        console.Profile.Width = int.MaxValue;
 
        var logFilePath = Path.Combine(s_logsDirectory.FullName, "cli [dev team].log");
        var interactionService = CreateInteractionService(console, CreateExecutionContext(logFilePath: logFilePath));
 
        var fileLinkMarkup = MarkupHelpers.SafeFileLink(interactionService, logFilePath);
        interactionService.DisplayMessage(
            KnownEmojis.PageFacingUp,
            $"See logs at {fileLinkMarkup}",
            allowMarkup: true);
 
        var outputString = output.ToString();
        var fileUri = new Uri(Path.GetFullPath(logFilePath)).AbsoluteUri;
        TerminalLinkAssert.ContainsLink(outputString, fileUri, logFilePath);
    }
 
    [Fact]
    public void DisplayError_WithAllowMarkup_RendersMarkupAsIs()
    {
        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.Capabilities.Links = true;
        console.Profile.Width = int.MaxValue;
 
        var logFilePath = Path.Combine(s_logsDirectory.FullName, "cli [dev team].log");
        var interactionService = CreateInteractionService(console, CreateExecutionContext(logFilePath: logFilePath));
 
        var fileLinkMarkup = MarkupHelpers.SafeFileLink(interactionService, logFilePath);
        interactionService.DisplayError($"Build failed. Logs: {fileLinkMarkup}", allowMarkup: true);
 
        var outputString = output.ToString();
        var fileUri = new Uri(Path.GetFullPath(logFilePath)).AbsoluteUri;
        Assert.Contains("Build failed.", outputString);
        TerminalLinkAssert.ContainsLink(outputString, fileUri, logFilePath);
    }
 
    [Fact]
    public void DisplayError_WithoutAllowMarkup_EscapesBrackets()
    {
        var output = new StringBuilder();
        var console = AnsiConsole.Create(new AnsiConsoleSettings
        {
            Ansi = AnsiSupport.No,
            ColorSystem = ColorSystemSupport.NoColors,
            Out = new AnsiConsoleOutput(new StringWriter(output))
        });
 
        var interactionService = CreateInteractionService(console);
 
        // Bracket characters in the error message would normally break Spectre markup parsing,
        // but DisplayError escapes them by default.
        var message = "Build failed for [Project Alpha].";
        var exception = Record.Exception(() => interactionService.DisplayError(message));
 
        Assert.Null(exception);
        Assert.Contains("Build failed for [Project Alpha].", output.ToString());
    }
 
    [Fact]
    public void DisplayVersionUpdateNotification_WithMarkupCharactersInVersion_DoesNotThrow()
    {
        // Arrange
        var output = new StringBuilder();
        var console = AnsiConsole.Create(new AnsiConsoleSettings
        {
            Ansi = AnsiSupport.No,
            ColorSystem = ColorSystemSupport.NoColors,
            Out = new AnsiConsoleOutput(new StringWriter(output))
        });
 
        var interactionService = CreateInteractionService(console);
 
        // Version strings are unlikely to have brackets, but the method should handle it
        var version = "13.2.0-preview [beta]";
        var updateCommand = "aspire update --channel [stable]";
 
        // Act - should not throw due to unescaped markup characters
        var exception = Record.Exception(() => interactionService.DisplayVersionUpdateNotification(version, updateCommand));
 
        // Assert
        Assert.Null(exception);
        var outputString = output.ToString();
        Assert.Contains("A new version of Aspire is available:", outputString);
        Assert.Contains("13.2.0-preview [beta]", outputString);
        Assert.Contains("aspire update --channel [stable]", outputString);
    }
 
    [Fact]
    public void DisplayError_WithMarkupCharactersInMessage_DoesNotDoubleEscape()
    {
        // Arrange - verifies that DisplayError escapes once (callers should NOT pre-escape)
        var output = new StringBuilder();
        var console = AnsiConsole.Create(new AnsiConsoleSettings
        {
            Ansi = AnsiSupport.No,
            ColorSystem = ColorSystemSupport.NoColors,
            Out = new AnsiConsoleOutput(new StringWriter(output))
        });
 
        var interactionService = CreateInteractionService(console);
 
        // Error message with brackets (e.g., from an exception)
        var errorMessage = "Failed to connect to service [Prod]: Connection refused <timeout>";
 
        // Act - should not throw
        var exception = Record.Exception(() => interactionService.DisplayError(errorMessage));
 
        // Assert
        Assert.Null(exception);
        var outputString = output.ToString();
        // Should contain the original text (not double-escaped like [[Prod]])
        Assert.Contains("[Prod]", outputString);
        Assert.DoesNotContain("[[Prod]]", outputString);
    }
 
    [Fact]
    public void DisplayError_WritesToStderr_RegardlessOfConsoleSetting()
    {
        var stdoutOutput = new StringBuilder();
        var stderrOutput = new StringBuilder();
        var stdoutConsole = AnsiConsole.Create(new AnsiConsoleSettings
        {
            Ansi = AnsiSupport.No,
            ColorSystem = ColorSystemSupport.NoColors,
            Out = new AnsiConsoleOutput(new StringWriter(stdoutOutput))
        });
        var stderrConsole = AnsiConsole.Create(new AnsiConsoleSettings
        {
            Ansi = AnsiSupport.No,
            ColorSystem = ColorSystemSupport.NoColors,
            Out = new AnsiConsoleOutput(new StringWriter(stderrOutput))
        });
 
        var executionContext = CreateExecutionContext();
        var consoleEnvironment = new ConsoleEnvironment(stdoutConsole, stderrConsole);
        var interactionService = new ConsoleInteractionService(consoleEnvironment, executionContext, TestHelpers.CreateInteractiveHostEnvironment(), new EnvironmentProcessPathProvider(), NullLoggerFactory.Instance, new ConsoleLogBufferContext());
 
        // Console defaults to Standard (stdout), but errors should still go to stderr
        interactionService.DisplayError("Something went wrong");
 
        Assert.Empty(stdoutOutput.ToString());
        Assert.Contains("Something went wrong", stderrOutput.ToString());
    }
 
    [Fact]
    public void DisplayMessage_WritesToStdout_WhenConsoleIsStandard()
    {
        var stdoutOutput = new StringBuilder();
        var stderrOutput = new StringBuilder();
        var stdoutConsole = AnsiConsole.Create(new AnsiConsoleSettings
        {
            Ansi = AnsiSupport.No,
            ColorSystem = ColorSystemSupport.NoColors,
            Out = new AnsiConsoleOutput(new StringWriter(stdoutOutput))
        });
        var stderrConsole = AnsiConsole.Create(new AnsiConsoleSettings
        {
            Ansi = AnsiSupport.No,
            ColorSystem = ColorSystemSupport.NoColors,
            Out = new AnsiConsoleOutput(new StringWriter(stderrOutput))
        });
 
        var executionContext = CreateExecutionContext();
        var consoleEnvironment = new ConsoleEnvironment(stdoutConsole, stderrConsole);
        var interactionService = new ConsoleInteractionService(consoleEnvironment, executionContext, TestHelpers.CreateInteractiveHostEnvironment(), new EnvironmentProcessPathProvider(), NullLoggerFactory.Instance, new ConsoleLogBufferContext());
 
        interactionService.DisplayMessage(KnownEmojis.Information, "Status update");
 
        Assert.Contains("Status update", stdoutOutput.ToString());
        Assert.Empty(stderrOutput.ToString());
    }
 
    [Fact]
    public void DisplayMessage_WithUnescapedMarkup_AutoEscapesAndDoesNotThrow()
    {
        // Arrange - verifies that DisplayMessage auto-escapes by default
        var output = new StringBuilder();
        var console = AnsiConsole.Create(new AnsiConsoleSettings
        {
            Ansi = AnsiSupport.No,
            ColorSystem = ColorSystemSupport.NoColors,
            Out = new AnsiConsoleOutput(new StringWriter(output))
        });
 
        var interactionService = CreateInteractionService(console);
 
        // Path with brackets that would be interpreted as Spectre markup if not escaped
        var path = @"C:\Users\[Dev Team]\logs\aspire.log";
 
        // Act - should not throw because DisplayMessage auto-escapes
        var exception = Record.Exception(() => interactionService.DisplayMessage(KnownEmojis.PageFacingUp, $"See logs at {path}"));
 
        // Assert
        Assert.Null(exception);
        var outputString = output.ToString();
        Assert.Contains(@"C:\Users\[Dev Team]\logs\aspire.log", outputString);
    }
 
    [Fact]
    public void DisplayMessage_WithAllowMarkupTrue_PassesThroughMarkup()
    {
        // Arrange - verifies that allowMarkup: true allows intentional Spectre markup
        var output = new StringBuilder();
        var console = AnsiConsole.Create(new AnsiConsoleSettings
        {
            Ansi = AnsiSupport.No,
            ColorSystem = ColorSystemSupport.NoColors,
            Out = new AnsiConsoleOutput(new StringWriter(output))
        });
 
        var interactionService = CreateInteractionService(console);
 
        // Message with intentional Spectre markup tags
        var message = "[bold cyan]MyProject.csproj[/]:";
 
        // Act - should not throw because markup is intentional
        var exception = Record.Exception(() => interactionService.DisplayMessage(KnownEmojis.FileFolder, message, allowMarkup: true));
 
        // Assert
        Assert.Null(exception);
        var outputString = output.ToString();
        Assert.Contains("MyProject.csproj", outputString);
    }
 
    [Fact]
    public void DisplayMessage_WithAllowMarkupTrue_UnescapedDynamicContent_Throws()
    {
        // Arrange - verifies that allowMarkup: true still requires callers to escape dynamic values
        var output = new StringBuilder();
        var console = AnsiConsole.Create(new AnsiConsoleSettings
        {
            Ansi = AnsiSupport.No,
            ColorSystem = ColorSystemSupport.NoColors,
            Out = new AnsiConsoleOutput(new StringWriter(output))
        });
 
        var interactionService = CreateInteractionService(console);
 
        // Dynamic content with brackets embedded in markup - NOT escaped
        var projectName = "MyProject [Beta]";
        var message = $"[bold cyan]{projectName}[/]:";
 
        // Act - should throw because [Beta] is invalid markup when allowMarkup: true
        var exception = Record.Exception(() => interactionService.DisplayMessage(KnownEmojis.FileFolder, message, allowMarkup: true));
 
        // Assert
        Assert.NotNull(exception);
    }
 
    [Fact]
    public void DisplaySuccess_WithMarkupCharacters_AutoEscapesByDefault()
    {
        // Arrange
        var output = new StringBuilder();
        var console = AnsiConsole.Create(new AnsiConsoleSettings
        {
            Ansi = AnsiSupport.No,
            ColorSystem = ColorSystemSupport.NoColors,
            Out = new AnsiConsoleOutput(new StringWriter(output))
        });
 
        var interactionService = CreateInteractionService(console);
 
        // Success message with bracket characters that would break markup if not escaped
        var message = "Package Aspire.Hosting.Azure [1.0.0-preview] added successfully";
 
        // Act - should not throw because DisplaySuccess auto-escapes
        var exception = Record.Exception(() => interactionService.DisplaySuccess(message));
 
        // Assert
        Assert.Null(exception);
        var outputString = output.ToString();
        Assert.Contains("Aspire.Hosting.Azure [1.0.0-preview]", outputString);
    }
 
    [Fact]
    public async Task ShowStatusAsync_WithMarkupCharacters_AutoEscapesByDefault()
    {
        // Arrange - verifies that ShowStatusAsync auto-escapes by default
        var output = new StringBuilder();
        var console = AnsiConsole.Create(new AnsiConsoleSettings
        {
            Ansi = AnsiSupport.No,
            ColorSystem = ColorSystemSupport.NoColors,
            Out = new AnsiConsoleOutput(new StringWriter(output))
        });
 
        var executionContext = CreateExecutionContext(debugMode: true);
        var interactionService = CreateInteractionService(console, executionContext);
 
        // Status text with brackets that would be interpreted as Spectre markup if not escaped
        var statusText = "Downloading CLI from https://example.com/[latest]/aspire.zip";
 
        // Act - should not throw because ShowStatusAsync auto-escapes
        var exception = await Record.ExceptionAsync(() =>
            interactionService.ShowStatusAsync(statusText, () => Task.FromResult(0)));
 
        // Assert
        Assert.Null(exception);
        var outputString = output.ToString();
        Assert.Contains("[latest]", outputString);
    }
 
    [Fact]
    public void ShowStatus_WithMarkupCharacters_AutoEscapesByDefault()
    {
        // Arrange - verifies that ShowStatus auto-escapes by default
        var output = new StringBuilder();
        var console = AnsiConsole.Create(new AnsiConsoleSettings
        {
            Ansi = AnsiSupport.No,
            ColorSystem = ColorSystemSupport.NoColors,
            Out = new AnsiConsoleOutput(new StringWriter(output))
        });
 
        var executionContext = CreateExecutionContext(debugMode: true);
        var interactionService = CreateInteractionService(console, executionContext);
 
        // Status text with brackets that would be interpreted as Spectre markup if not escaped
        var statusText = "Installing .NET SDK [10.0.0-preview.1]...";
 
        // Act - should not throw because ShowStatus auto-escapes
        var exception = Record.Exception(() => interactionService.ShowStatus(statusText, () => { }));
 
        // Assert
        Assert.Null(exception);
        var outputString = output.ToString();
        Assert.Contains("[10.0.0-preview.1]", outputString);
    }
 
    [Fact]
    public async Task ShowStatusAsync_WithAllowMarkupTrue_PassesThroughMarkup()
    {
        // Arrange - verifies that allowMarkup: true allows emoji and other Spectre markup
        var output = new StringBuilder();
        var console = AnsiConsole.Create(new AnsiConsoleSettings
        {
            Ansi = AnsiSupport.No,
            ColorSystem = ColorSystemSupport.NoColors,
            Out = new AnsiConsoleOutput(new StringWriter(output))
        });
 
        var executionContext = CreateExecutionContext(debugMode: true);
        var interactionService = CreateInteractionService(console, executionContext);
 
        // Status text with intentional Spectre emoji markup
        var statusText = ":rocket:  Creating new project";
 
        // Act - should not throw because markup is intentional
        var exception = await Record.ExceptionAsync(() =>
            interactionService.ShowStatusAsync(statusText, () => Task.FromResult(0), allowMarkup: true));
 
        // Assert
        Assert.Null(exception);
        var outputString = output.ToString();
        Assert.Contains("Creating new project", outputString);
    }
 
    [Fact]
    public async Task ShowStatusAsync_WithAllowMarkupTrue_UnescapedDynamicContent_Throws()
    {
        // Arrange - verifies that allowMarkup: true still requires callers to escape dynamic values
        var output = new StringBuilder();
        var console = AnsiConsole.Create(new AnsiConsoleSettings
        {
            Ansi = AnsiSupport.No,
            ColorSystem = ColorSystemSupport.NoColors,
            Out = new AnsiConsoleOutput(new StringWriter(output))
        });
 
        var executionContext = CreateExecutionContext(debugMode: true);
        var interactionService = CreateInteractionService(console, executionContext);
 
        // Dynamic content with invalid brackets when interpreted as markup
        var projectName = "MyProject [Beta]";
        var statusText = $":rocket:  Building {projectName}";
 
        // Act - should throw because [Beta] is invalid markup when allowMarkup: true
        var exception = await Record.ExceptionAsync(() =>
            interactionService.ShowStatusAsync(statusText, () => Task.FromResult(0), allowMarkup: true));
 
        // Assert
        Assert.NotNull(exception);
    }
 
    [Fact]
    public async Task ShowStatusAsync_WithEmojiName_PrependsEmojiAndAutoEscapes()
    {
        // Arrange - verifies that emojiName handles emoji separately and auto-escapes the status text
        var output = new StringBuilder();
        var console = AnsiConsole.Create(new AnsiConsoleSettings
        {
            Ansi = AnsiSupport.No,
            ColorSystem = ColorSystemSupport.NoColors,
            Out = new AnsiConsoleOutput(new StringWriter(output))
        });
 
        var executionContext = CreateExecutionContext(debugMode: true);
        var interactionService = CreateInteractionService(console, executionContext);
 
        // Status text with brackets that would be invalid markup if not escaped
        var statusText = "Building MyProject [Beta]";
 
        // Act - should not throw because emojiName handles emoji separately and text is auto-escaped
        var exception = await Record.ExceptionAsync(() =>
            interactionService.ShowStatusAsync(statusText, () => Task.FromResult(0), emoji: KnownEmojis.Rocket));
 
        // Assert
        Assert.Null(exception);
        var outputString = output.ToString();
        Assert.Contains("Building MyProject [Beta]", outputString);
        Assert.Contains("🚀", outputString);
    }
 
    [Fact]
    public void ShowStatus_WithEmojiName_PrependsEmojiAndAutoEscapes()
    {
        // Arrange - verifies that emojiName handles emoji separately and auto-escapes the status text
        var output = new StringBuilder();
        var console = AnsiConsole.Create(new AnsiConsoleSettings
        {
            Ansi = AnsiSupport.No,
            ColorSystem = ColorSystemSupport.NoColors,
            Out = new AnsiConsoleOutput(new StringWriter(output))
        });
 
        var executionContext = CreateExecutionContext(debugMode: true);
        var interactionService = CreateInteractionService(console, executionContext);
 
        // Status text with brackets that would be invalid markup if not escaped
        var statusText = "Installing .NET SDK [10.0.0-preview.1]...";
 
        // Act - should not throw because emojiName handles emoji separately and text is auto-escaped
        var exception = Record.Exception(() =>
            interactionService.ShowStatus(statusText, () => { }, emoji: KnownEmojis.Package));
 
        // Assert
        Assert.Null(exception);
        var outputString = output.ToString();
        Assert.Contains("Installing .NET SDK [10.0.0-preview.1]", outputString);
        Assert.Contains("📦", outputString);
    }
 
    [Fact]
    public void DisplaySubtleMessage_WithMarkupCharacters_EscapesByDefault()
    {
        // Arrange - verifies that DisplaySubtleMessage escapes by default
        var output = new StringBuilder();
        var console = AnsiConsole.Create(new AnsiConsoleSettings
        {
            Ansi = AnsiSupport.No,
            ColorSystem = ColorSystemSupport.NoColors,
            Out = new AnsiConsoleOutput(new StringWriter(output))
        });
 
        var interactionService = CreateInteractionService(console);
 
        // Message with all kinds of markup characters
        var message = "Error in [Module]: <Config> value $.items[0] invalid";
 
        // Act
        var exception = Record.Exception(() => interactionService.DisplaySubtleMessage(message));
 
        // Assert
        Assert.Null(exception);
        var outputString = output.ToString();
        Assert.Contains("[Module]", outputString);
    }
 
    [Fact]
    public void SelectionPrompt_ConverterPreservesIntentionalMarkup()
    {
        // Arrange - verifies that PromptForSelectionAsync does NOT escape the formatter output,
        // allowing callers to include intentional Spectre markup (e.g., [bold]...[/]).
        // This is a regression test for https://github.com/microsoft/aspire/pull/14422 where
        // blanket EscapeMarkup() in the converter broke [bold] rendering in 'aspire add'.
        var output = new StringBuilder();
        var console = AnsiConsole.Create(new AnsiConsoleSettings
        {
            Ansi = AnsiSupport.Yes,
            ColorSystem = ColorSystemSupport.Standard,
            Out = new AnsiConsoleOutput(new StringWriter(output))
        });
 
        // Build a SelectionPrompt the same way ConsoleInteractionService does,
        // using a formatter that returns intentional markup (like AddCommand does).
        Func<string, string> choiceFormatter = item => $"[bold]{item}[/] (Aspire.Hosting.{item})";
 
        var prompt = new SelectionPrompt<string>()
            .Title("Select an integration:")
            .UseConverter(choiceFormatter)
            .AddChoices(["PostgreSQL", "Redis"]);
 
        // Act - verify the converter output preserves the [bold] markup
        // by checking that the converter is the formatter itself (not wrapped with EscapeMarkup)
        var converterOutput = choiceFormatter("PostgreSQL");
 
        // Assert - the formatter should produce raw markup, not escaped markup
        Assert.Equal("[bold]PostgreSQL[/] (Aspire.Hosting.PostgreSQL)", converterOutput);
        Assert.DoesNotContain("[[bold]]", converterOutput); // Must NOT be escaped
    }
 
    [Fact]
    public void SelectionPrompt_ConverterWithBracketsInData_MustBeEscapedByCaller()
    {
        // Arrange - verifies that callers are responsible for escaping dynamic data
        // that may contain bracket characters, while preserving intentional markup.
        // This tests the pattern used by AddCommand.PackageNameWithFriendlyNameIfAvailable.
        var output = new StringBuilder();
        var console = AnsiConsole.Create(new AnsiConsoleSettings
        {
            Ansi = AnsiSupport.No,
            ColorSystem = ColorSystemSupport.NoColors,
            Out = new AnsiConsoleOutput(new StringWriter(output))
        });
 
        // Simulate a package name that contains brackets (e.g., from an external source)
        var friendlyName = "Azure Storage [Preview]";
        var packageId = "Aspire.Hosting.Azure.Storage";
 
        // The formatter should escape dynamic values but preserve intentional markup
        var formattedOutput = $"[bold]{friendlyName.EscapeMarkup()}[/] ({packageId.EscapeMarkup()})";
 
        // Assert - intentional markup preserved, dynamic brackets escaped
        Assert.Equal("[bold]Azure Storage [[Preview]][/] (Aspire.Hosting.Azure.Storage)", formattedOutput);
 
        // Verify Spectre can render this without throwing
        var exception = Record.Exception(() => console.MarkupLine(formattedOutput));
        Assert.Null(exception);
 
        var outputString = output.ToString();
        Assert.Contains("Azure Storage [Preview]", outputString);
        Assert.Contains("Aspire.Hosting.Azure.Storage", outputString);
    }
 
    [Theory]
    [InlineData(true, "[Y/n]")]
    [InlineData(false, "[y/N]")]
    public async Task ConfirmAsync_DisplaysCapitalizedDefaultChoice(bool defaultValue, string expectedChoiceSuffix)
    {
        // Arrange - simulate pressing Enter (accepts default)
        var output = new StringBuilder();
        var console = CreateInteractiveConsoleWithInput(output, "\n");
        var interactionService = CreateInteractionService(console);
 
        // Act
        await interactionService.PromptConfirmAsync("Proceed?", PromptBinding.CreateDefault(defaultValue), cancellationToken: CancellationToken.None);
 
        // Assert - the output should contain the [Y/n] or [y/N] suffix
        var outputString = output.ToString();
        Assert.Contains(expectedChoiceSuffix, outputString);
    }
 
    [Theory]
    [InlineData(true)]
    [InlineData(false)]
    public async Task ConfirmAsync_WhenUserPressesEnter_ReturnsDefaultValue(bool defaultValue)
    {
        // Arrange - simulate pressing Enter (empty input selects default)
        var output = new StringBuilder();
        var console = CreateInteractiveConsoleWithInput(output, "\n");
        var interactionService = CreateInteractionService(console);
 
        // Act
        var result = await interactionService.PromptConfirmAsync("Proceed?", PromptBinding.CreateDefault(defaultValue), cancellationToken: CancellationToken.None);
 
        // Assert - pressing Enter should accept the default value
        Assert.Equal(defaultValue, result);
    }
 
    [Theory]
    [InlineData(true, "y")]
    [InlineData(true, "Y")]
    [InlineData(false, "y")]
    [InlineData(false, "Y")]
    public async Task ConfirmAsync_WhenUserPressesYWithoutEnter_ReturnsTrue(bool defaultValue, string input)
    {
        var output = new StringBuilder();
        var console = CreateInteractiveConsoleWithInput(output, input);
        var interactionService = CreateInteractionService(console);
 
        var result = await interactionService.PromptConfirmAsync("Proceed?", PromptBinding.CreateDefault(defaultValue), cancellationToken: CancellationToken.None);
 
        Assert.True(result);
        Assert.EndsWith($"{input}\n", output.ToString().Replace("\r\n", "\n", StringComparison.Ordinal));
    }
 
    [Theory]
    [InlineData(true, "n")]
    [InlineData(true, "N")]
    [InlineData(false, "n")]
    [InlineData(false, "N")]
    public async Task ConfirmAsync_WhenUserPressesNWithoutEnter_ReturnsFalse(bool defaultValue, string input)
    {
        var output = new StringBuilder();
        var console = CreateInteractiveConsoleWithInput(output, input);
        var interactionService = CreateInteractionService(console);
 
        var result = await interactionService.PromptConfirmAsync("Proceed?", PromptBinding.CreateDefault(defaultValue), cancellationToken: CancellationToken.None);
 
        Assert.False(result);
        Assert.EndsWith($"{input}\n", output.ToString().Replace("\r\n", "\n", StringComparison.Ordinal));
    }
 
    [Fact]
    public async Task PromptForStringAsync_CliProvidedValue_RunsValidator()
    {
        var output = new StringBuilder();
        var console = CreateInteractiveConsoleWithInput(output, "");
        var interactionService = CreateInteractionService(console);
 
        var option = new Option<string?>("--value");
        var command = new RootCommand { option };
        var parseResult = command.Parse("--value bad-value");
        var binding = PromptBinding.Create(parseResult, option);
        Func<string, ValidationResult> validator = v =>
            v == "bad-value" ? ValidationResult.Error("Invalid!") : ValidationResult.Success();
 
        await Assert.ThrowsAsync<NonInteractiveException>(() =>
            interactionService.PromptForStringAsync("Enter value:", validator: validator, binding: binding, cancellationToken: CancellationToken.None));
    }
 
    [Fact]
    public async Task PromptForStringAsync_CliProvidedEmptyValue_ThrowsWhenRequired()
    {
        var output = new StringBuilder();
        var console = CreateInteractiveConsoleWithInput(output, "");
        var interactionService = CreateInteractionService(console, hostEnvironment: TestHelpers.CreateNonInteractiveHostEnvironment());
 
        var binding = PromptBinding.CreateDefault<string?>("");
 
        await Assert.ThrowsAsync<NonInteractiveException>(() =>
            interactionService.PromptForStringAsync("Enter name:", required: true, binding: binding, cancellationToken: CancellationToken.None));
    }
 
    [Fact]
    public async Task PromptForStringAsync_NonInteractive_DefaultValuePassesValidation_ReturnsDefault()
    {
        var output = new StringBuilder();
        var console = CreateInteractiveConsoleWithInput(output, "");
        var interactionService = CreateInteractionService(console, hostEnvironment: TestHelpers.CreateNonInteractiveHostEnvironment());
 
        var binding = PromptBinding.CreateDefault<string?>("good-value");
        Func<string, ValidationResult> validator = v =>
            v == "good-value" ? ValidationResult.Success() : ValidationResult.Error("Invalid!");
 
        var result = await interactionService.PromptForStringAsync("Enter value:", validator: validator, binding: binding, cancellationToken: CancellationToken.None);
 
        Assert.Equal("good-value", result);
    }
 
    [Fact]
    public async Task PromptForStringAsync_WhenUserAcceptsEscapedDisplayDefault_ReturnsRawDefault()
    {
        var output = new StringBuilder();
        var console = CreateInteractiveConsoleWithInput(output, "\n");
        var interactionService = CreateInteractionService(console);
        const string rawDefault = "./[27;5;13~";
 
        var result = await interactionService.PromptForStringAsync(
            "Enter value:",
            binding: PromptBinding.CreateDefault<string?>(rawDefault),
            cancellationToken: CancellationToken.None);
 
        Assert.Equal(rawDefault, result);
    }
 
    [Fact]
    public async Task ConfirmAsync_NonInteractive_WithoutExplicitDefault_Throws()
    {
        var output = new StringBuilder();
        var console = CreateInteractiveConsoleWithInput(output, "");
        var interactionService = CreateInteractionService(console, hostEnvironment: TestHelpers.CreateNonInteractiveHostEnvironment());
 
        var option = new Option<bool>("--confirm");
        var command = new RootCommand { option };
        var parseResult = command.Parse("");
        var binding = PromptBinding.Create(parseResult, option);
 
        await Assert.ThrowsAsync<NonInteractiveException>(() =>
            interactionService.PromptConfirmAsync("Proceed?", binding: binding, cancellationToken: CancellationToken.None));
    }
 
    [Fact]
    public async Task ConfirmAsync_NonInteractive_WithExplicitDefault_ReturnsDefault()
    {
        var output = new StringBuilder();
        var console = CreateInteractiveConsoleWithInput(output, "");
        var interactionService = CreateInteractionService(console, hostEnvironment: TestHelpers.CreateNonInteractiveHostEnvironment());
 
        var binding = PromptBinding.CreateDefault(true);
 
        var result = await interactionService.PromptConfirmAsync("Proceed?", binding: binding, cancellationToken: CancellationToken.None);
 
        Assert.True(result);
    }
 
    [Fact]
    public async Task ConfirmAsync_NonInteractive_WithSeparateNonInteractiveDefault_ReturnsNonInteractiveDefault()
    {
        var output = new StringBuilder();
        var console = CreateInteractiveConsoleWithInput(output, "");
        var interactionService = CreateInteractionService(console, hostEnvironment: TestHelpers.CreateNonInteractiveHostEnvironment());
 
        var option = new Option<bool?>("--confirm");
        var command = new RootCommand { option };
        var parseResult = command.Parse("");
        var binding = PromptBinding.CreateBoolConfirm(parseResult, option, interactiveDefault: true, nonInteractiveDefault: false);
 
        var result = await interactionService.PromptConfirmAsync("Proceed?", binding: binding, cancellationToken: CancellationToken.None);
 
        Assert.False(result);
    }
 
    [Fact]
    public async Task ConfirmAsync_Interactive_WithSeparateNonInteractiveDefault_UsesInteractiveDefault()
    {
        var output = new StringBuilder();
        var console = CreateInteractiveConsoleWithInput(output, "\n");
        var interactionService = CreateInteractionService(console);
 
        var option = new Option<bool?>("--confirm");
        var command = new RootCommand { option };
        var parseResult = command.Parse("");
        var binding = PromptBinding.CreateBoolConfirm(parseResult, option, interactiveDefault: true, nonInteractiveDefault: false);
 
        var result = await interactionService.PromptConfirmAsync("Proceed?", binding: binding, cancellationToken: CancellationToken.None);
 
        Assert.True(result);
        Assert.Contains("[Y/n]", output.ToString());
    }
 
    [Fact]
    public void MatchChoices_WithDuplicateValues_ReturnsDeduplicated()
    {
        var choices = new[] { "alpha", "beta", "gamma" };
 
        var result = ConsoleInteractionService.MatchChoices("alpha,alpha,beta", choices, c => c);
 
        Assert.NotNull(result);
        Assert.Equal(2, result.Count);
        Assert.Equal("alpha", result[0]);
        Assert.Equal("beta", result[1]);
    }
 
    [Fact]
    public void MatchChoices_All_ReturnsAllChoices()
    {
        var choices = new[] { "alpha", "beta", "gamma" };
 
        var result = ConsoleInteractionService.MatchChoices("all", choices, c => c);
 
        Assert.NotNull(result);
        Assert.Equal(3, result.Count);
    }
 
    [Fact]
    public void MatchChoices_None_ReturnsEmpty()
    {
        var choices = new[] { "alpha", "beta" };
 
        var result = ConsoleInteractionService.MatchChoices("none", choices, c => c);
 
        Assert.NotNull(result);
        Assert.Empty(result);
    }
 
    [Fact]
    public void MatchChoices_InvalidValue_ReturnsNull()
    {
        var choices = new[] { "alpha", "beta" };
 
        var result = ConsoleInteractionService.MatchChoices("alpha,nonexistent", choices, c => c);
 
        Assert.Null(result);
    }
 
    [Fact]
    public void MatchChoice_MatchesByToString()
    {
        var choices = new[] { new TestItem("id1", "Display One"), new TestItem("id2", "Display Two") };
 
        var result = ConsoleInteractionService.MatchChoice("id1", choices, c => c.Display);
 
        Assert.NotNull(result);
        Assert.Equal("id1", result.Id);
    }
 
    [Fact]
    public void PromptBinding_CreateDefault_ResolvesAsNotProvided()
    {
        var binding = PromptBinding.CreateDefault("test");
        var (wasProvided, value) = binding.Resolve();
 
        Assert.False(wasProvided);
        Assert.Null(value);
        Assert.Equal("test", binding.DefaultValue);
        Assert.True(binding.HasExplicitDefault);
    }
 
    [Fact]
    public void PromptBinding_CreateWithoutDefault_HasNoExplicitDefault()
    {
        var option = new Option<bool>("--flag");
        var command = new RootCommand { option };
        var parseResult = command.Parse("");
 
        var binding = PromptBinding.Create(parseResult, option);
 
        Assert.False(binding.HasExplicitDefault);
    }
 
    [Fact]
    public void PromptBinding_CreateWithDefault_HasExplicitDefault()
    {
        var option = new Option<bool>("--flag");
        var command = new RootCommand { option };
        var parseResult = command.Parse("");
 
        var binding = PromptBinding.Create(parseResult, option, true);
 
        Assert.True(binding.HasExplicitDefault);
        Assert.True(binding.DefaultValue);
    }
 
    [Theory]
    [InlineData("--channel", "'--channel'")]
    [InlineData("--yes", "'--yes'")]
    [InlineData("--nuget-config-dir", "'--nuget-config-dir'")]
    public void PromptBinding_SymbolDisplayName_DoesNotDoubleDash(string optionName, string expectedDisplay)
    {
        var option = new Option<string?>(optionName);
        var command = new RootCommand { option };
        var parseResult = command.Parse("");
 
        var binding = PromptBinding.Create(parseResult, option);
 
        Assert.Equal(expectedDisplay, binding.SymbolDisplayName);
    }
 
    [Fact]
    public async Task ConfirmAsync_WithNullBinding_DefaultsToTrue()
    {
        var output = new StringBuilder();
        var console = CreateInteractiveConsoleWithInput(output, "\n");
        var interactionService = CreateInteractionService(console);
 
        var result = await interactionService.PromptConfirmAsync("Proceed?", binding: null, cancellationToken: CancellationToken.None);
 
        Assert.True(result);
        Assert.Contains("[Y/n]", output.ToString());
    }
 
    [Fact]
    public async Task PromptForSelectionAsync_NonInteractive_CliProvidedInvalidValue_ShowsAvailableChoices()
    {
        var output = new StringBuilder();
        var console = CreateInteractiveConsoleWithInput(output, "");
        var interactionService = CreateInteractionService(console, hostEnvironment: TestHelpers.CreateNonInteractiveHostEnvironment());
        var choices = new[] { "option1", "option2", "option3" };
 
        var option = new Option<string?>("--choice");
        var command = new RootCommand { option };
        var parseResult = command.Parse("--choice invalid");
        var binding = PromptBinding.Create(parseResult, option);
 
        var ex = await Assert.ThrowsAsync<NonInteractiveException>(() =>
            interactionService.PromptForSelectionAsync("Select:", choices, x => x, binding, cancellationToken: CancellationToken.None));
 
        var outputString = output.ToString();
        Assert.Contains("option1", outputString);
        Assert.Contains("option2", outputString);
        Assert.Contains("option3", outputString);
    }
 
    [Fact]
    public async Task PromptForSelectionsAsync_NonInteractive_CliProvidedInvalidValue_ShowsAvailableChoices()
    {
        var output = new StringBuilder();
        var console = CreateInteractiveConsoleWithInput(output, "");
        var interactionService = CreateInteractionService(console, hostEnvironment: TestHelpers.CreateNonInteractiveHostEnvironment());
        var choices = new[] { "alpha", "beta", "gamma" };
 
        var option = new Option<string?>("--items");
        var command = new RootCommand { option };
        var parseResult = command.Parse("--items invalid");
        var binding = PromptBinding.Create(parseResult, option);
 
        var ex = await Assert.ThrowsAsync<NonInteractiveException>(() =>
            interactionService.PromptForSelectionsAsync("Select:", choices, x => x, binding: binding, cancellationToken: CancellationToken.None));
 
        var outputString = output.ToString();
        Assert.Contains("alpha", outputString);
        Assert.Contains("beta", outputString);
        Assert.Contains("gamma", outputString);
    }
 
    [Fact]
    public async Task PromptForSelectionsAsync_NonInteractive_CliProvidedInvalidValue_OmitsItemsOutsideBindingChoices()
    {
        // The visible multi-select prompt may include UX-only entries (e.g. a "Configure MCP server"
        // applicator) that share the prompt with the real catalog but must not be addressable from
        // the --option value. Callers narrow non-interactive validation via the bindingChoices subset;
        // entries outside that subset must not leak into the "Available values" rejection message.
        var output = new StringBuilder();
        var console = CreateInteractiveConsoleWithInput(output, "");
        var interactionService = CreateInteractionService(console, hostEnvironment: TestHelpers.CreateNonInteractiveHostEnvironment());
        var visibleChoices = new[] { "alpha", "beta", "ux-only-entry" };
        var bindingChoices = new[] { "alpha", "beta" };
 
        var option = new Option<string?>("--items");
        var command = new RootCommand { option };
        var parseResult = command.Parse("--items invalid");
        var binding = PromptBinding.Create(parseResult, option);
 
        await Assert.ThrowsAsync<NonInteractiveException>(() =>
            interactionService.PromptForSelectionsAsync("Select:", visibleChoices, x => x, binding: binding, bindingChoices: bindingChoices, cancellationToken: CancellationToken.None));
 
        var outputString = output.ToString();
        Assert.Contains("alpha", outputString);
        Assert.Contains("beta", outputString);
        Assert.DoesNotContain("ux-only-entry", outputString);
    }
 
    [Fact]
    public async Task PromptForSelectionsAsync_NonInteractive_CliProvidedInvalidValue_StripsSpectreMarkupFromChoiceLabels()
    {
        // Choice formatters sometimes return Spectre.Console markup (e.g. "[bold]Label[/]") so the
        // interactive multi-select can render styled text. The non-interactive rejection message is
        // plain text, so those tokens must be stripped rather than printed verbatim — otherwise a
        // user who mistypes --option sees `[bold]Label[/]` in the "Available values" list.
        var output = new StringBuilder();
        var console = CreateInteractiveConsoleWithInput(output, "");
        var interactionService = CreateInteractionService(console, hostEnvironment: TestHelpers.CreateNonInteractiveHostEnvironment());
        var choices = new[] { "alpha", "beta" };
 
        var option = new Option<string?>("--items");
        var command = new RootCommand { option };
        var parseResult = command.Parse("--items invalid");
        var binding = PromptBinding.Create(parseResult, option);
 
        await Assert.ThrowsAsync<NonInteractiveException>(() =>
            interactionService.PromptForSelectionsAsync(
                "Select:",
                choices,
                x => $"[bold]{x}[/] [dim](styled)[/]",
                binding: binding,
                cancellationToken: CancellationToken.None));
 
        var outputString = output.ToString();
        Assert.Contains("alpha", outputString);
        Assert.Contains("beta", outputString);
        Assert.Contains("(styled)", outputString);
        Assert.DoesNotContain("[bold]", outputString);
        Assert.DoesNotContain("[/]", outputString);
        Assert.DoesNotContain("[dim]", outputString);
    }
 
    [Fact]
    public async Task PromptForSelectionsAsync_NonInteractive_CliProvidedInvalidValue_WithUnescapedClosingBracketInChoiceLabel_DoesNotThrowInvalidOperationException()
    {
        // Non-interactive error reporting should tolerate literal brackets in labels.
        // These labels can come from user data and are not guaranteed to be valid Spectre markup.
        var output = new StringBuilder();
        var console = CreateInteractiveConsoleWithInput(output, "");
        var interactionService = CreateInteractionService(console, hostEnvironment: TestHelpers.CreateNonInteractiveHostEnvironment());
        var choices = new[] { "alpha", "beta" };
 
        var option = new Option<string?>("--items");
        var command = new RootCommand { option };
        var parseResult = command.Parse("--items invalid");
        var binding = PromptBinding.Create(parseResult, option);
 
        await Assert.ThrowsAsync<NonInteractiveException>(() =>
            interactionService.PromptForSelectionsAsync(
                "Select:",
                choices,
                x => $"{x}]",
                binding: binding,
                cancellationToken: CancellationToken.None));
 
        var outputString = output.ToString();
        Assert.Contains("alpha]", outputString);
        Assert.Contains("beta]", outputString);
    }
 
    [Fact]
    public async Task PromptForSelectionAsync_NonInteractive_WithDefaultValue_ReturnsMatch()
    {
        var output = new StringBuilder();
        var console = CreateInteractiveConsoleWithInput(output, "");
        var interactionService = CreateInteractionService(console, hostEnvironment: TestHelpers.CreateNonInteractiveHostEnvironment());
        var choices = new[] { "option1", "option2" };
 
        var option = new Option<string?>("--choice");
        var command = new RootCommand { option };
        var parseResult = command.Parse("");
        var binding = PromptBinding.Create(parseResult, option, "option2");
 
        var result = await interactionService.PromptForSelectionAsync("Select:", choices, x => x, binding, cancellationToken: CancellationToken.None);
 
        Assert.Equal("option2", result);
    }
 
    [Fact]
    public async Task PromptForSelectionAsync_CliProvidedValidValue_ReturnsMatch()
    {
        var output = new StringBuilder();
        var console = CreateInteractiveConsoleWithInput(output, "");
        var interactionService = CreateInteractionService(console);
        var choices = new[] { "option1", "option2" };
 
        var option = new Option<string?>("--choice");
        var command = new RootCommand { option };
        var parseResult = command.Parse("--choice option1");
        var binding = PromptBinding.Create(parseResult, option);
 
        var result = await interactionService.PromptForSelectionAsync("Select:", choices, x => x, binding, cancellationToken: CancellationToken.None);
 
        Assert.Equal("option1", result);
    }
 
    [Fact]
    public async Task PromptForSelectionsAsync_CliProvidedCommaSeparated_ReturnsMatches()
    {
        var output = new StringBuilder();
        var console = CreateInteractiveConsoleWithInput(output, "");
        var interactionService = CreateInteractionService(console);
        var choices = new[] { "alpha", "beta", "gamma" };
 
        var option = new Option<string?>("--items");
        var command = new RootCommand { option };
        var parseResult = command.Parse("--items alpha,gamma");
        var binding = PromptBinding.Create(parseResult, option);
 
        var result = await interactionService.PromptForSelectionsAsync("Select:", choices, x => x, binding: binding, cancellationToken: CancellationToken.None);
 
        Assert.Equal(2, result.Count);
        Assert.Equal("alpha", result[0]);
        Assert.Equal("gamma", result[1]);
    }
 
    [Fact]
    public void PromptBinding_InvertedBoolConfirm_SymbolDisplayName_IsCorrect()
    {
        var option = new Option<bool?>("--yes");
        var command = new RootCommand { option };
        var parseResult = command.Parse("--yes");
 
        var binding = PromptBinding.CreateInvertedBoolConfirm(parseResult, option, defaultValue: true);
 
        Assert.Equal("'--yes'", binding.SymbolDisplayName);
    }
 
    [Fact]
    public void PromptBinding_BoolConfirm_SymbolDisplayName_IsCorrect()
    {
        var option = new Option<bool?>("--include");
        var command = new RootCommand { option };
        var parseResult = command.Parse("--include");
 
        var binding = PromptBinding.CreateBoolConfirm(parseResult, option, defaultValue: false);
 
        Assert.Equal("'--include'", binding.SymbolDisplayName);
    }
 
    [Fact]
    public void PromptBinding_WithDefault_ChangesDefault()
    {
        var binding = PromptBinding.CreateDefault("original");
        var updated = binding.WithDefault("new-value");
 
        Assert.Equal("new-value", updated.DefaultValue);
        Assert.Equal("new-value", updated.NonInteractiveDefaultValue);
        Assert.True(updated.HasExplicitDefault);
    }
 
    [Fact]
    public void MatchChoices_CaseInsensitive_ReturnsMatches()
    {
        var choices = new[] { "Alpha", "Beta", "Gamma" };
 
        var result = ConsoleInteractionService.MatchChoices("alpha,BETA", choices, c => c);
 
        Assert.NotNull(result);
        Assert.Equal(2, result.Count);
        Assert.Equal("Alpha", result[0]);
        Assert.Equal("Beta", result[1]);
    }
 
    [Fact]
    public void MatchChoice_CaseInsensitive_ReturnsMatch()
    {
        var choices = new[] { "Alpha", "Beta" };
 
        var result = ConsoleInteractionService.MatchChoice("ALPHA", choices, c => c);
 
        Assert.NotNull(result);
        Assert.Equal("Alpha", result);
    }
 
    [Fact]
    public async Task PromptForStringAsync_NonInteractive_NoBinding_ThrowsInvalidOperationException()
    {
        var interactionService = CreateInteractionService(AnsiConsole.Console, hostEnvironment: TestHelpers.CreateNonInteractiveHostEnvironment());
 
        var binding = PromptBinding.CreateDefault<string?>("fallback");
 
        var result = await interactionService.PromptForStringAsync("Enter value:", binding: binding, cancellationToken: CancellationToken.None);
 
        Assert.Equal("fallback", result);
    }
 
    [Fact]
    public async Task PromptForSelectionAsync_NonInteractive_WithoutBinding_ThrowsInvalidOperationException()
    {
        var interactionService = CreateInteractionService(AnsiConsole.Console, hostEnvironment: TestHelpers.CreateNonInteractiveHostEnvironment());
        var choices = new[] { "option1", "option2" };
 
        var option = new Option<string?>("--choice");
        var command = new RootCommand { option };
        var parseResult = command.Parse("");
        var binding = PromptBinding.Create(parseResult, option);
 
        await Assert.ThrowsAsync<NonInteractiveException>(() =>
            interactionService.PromptForSelectionAsync("Select:", choices, x => x, binding, cancellationToken: CancellationToken.None));
    }
 
    [Fact]
    public async Task PromptForSelectionAsync_BindingProvided_DoesNotEchoSelection()
    {
        var output = new StringBuilder();
        var console = CreateInteractiveConsoleWithInput(output, "");
        var interactionService = CreateInteractionService(console);
        var choices = new[] { "option1", "option2" };
 
        var option = new Option<string?>("--choice");
        var command = new RootCommand { option };
        var parseResult = command.Parse("--choice option1");
        var binding = PromptBinding.Create(parseResult, option);
 
        var result = await interactionService.PromptForSelectionAsync("Select:", choices, x => x, binding, cancellationToken: CancellationToken.None);
 
        Assert.Equal("option1", result);
        Assert.Empty(output.ToString());
    }
 
    [Fact]
    public async Task PromptForSelectionsAsync_BindingProvided_DoesNotEchoSelection()
    {
        var output = new StringBuilder();
        var console = CreateInteractiveConsoleWithInput(output, "");
        var interactionService = CreateInteractionService(console);
        var choices = new[] { "alpha", "beta", "gamma" };
 
        var option = new Option<string?>("--items");
        var command = new RootCommand { option };
        var parseResult = command.Parse("--items alpha,gamma");
        var binding = PromptBinding.Create(parseResult, option);
 
        var result = await interactionService.PromptForSelectionsAsync("Select:", choices, x => x, binding: binding, cancellationToken: CancellationToken.None);
 
        Assert.Equal(2, result.Count);
        Assert.Empty(output.ToString());
    }
 
    [Fact]
    public async Task PromptForSelectionAsync_NonInteractiveDefault_DoesNotEchoSelection()
    {
        var output = new StringBuilder();
        var console = CreateInteractiveConsoleWithInput(output, "");
        var interactionService = CreateInteractionService(console, hostEnvironment: TestHelpers.CreateNonInteractiveHostEnvironment());
        var choices = new[] { "option1", "option2" };
 
        var option = new Option<string?>("--choice");
        var command = new RootCommand { option };
        var parseResult = command.Parse("");
        var binding = PromptBinding.Create(parseResult, option, "option2");
 
        var result = await interactionService.PromptForSelectionAsync("Select:", choices, x => x, binding, cancellationToken: CancellationToken.None);
 
        Assert.Equal("option2", result);
        Assert.Empty(output.ToString());
    }
 
    [Fact]
    public async Task PromptForSelectionsAsync_NonInteractiveDefault_DoesNotEchoSelection()
    {
        var output = new StringBuilder();
        var console = CreateInteractiveConsoleWithInput(output, "");
        var interactionService = CreateInteractionService(console, hostEnvironment: TestHelpers.CreateNonInteractiveHostEnvironment());
        var choices = new[] { "alpha", "beta", "gamma" };
 
        var option = new Option<string?>("--items");
        var command = new RootCommand { option };
        var parseResult = command.Parse("");
        var binding = PromptBinding.Create(parseResult, option, "alpha,beta");
 
        var result = await interactionService.PromptForSelectionsAsync("Select:", choices, x => x, binding: binding, cancellationToken: CancellationToken.None);
 
        Assert.Equal(2, result.Count);
        Assert.Empty(output.ToString());
    }
 
    private sealed record TestItem(string Id, string Display)
    {
        public override string ToString() => Id;
    }
 
    private static IAnsiConsole CreateInteractiveConsoleWithInput(StringBuilder output, string input)
    {
        var settings = new AnsiConsoleSettings
        {
            Ansi = AnsiSupport.No,
            ColorSystem = ColorSystemSupport.NoColors,
            Interactive = InteractionSupport.Yes,
            Out = new AnsiConsoleOutput(new StringWriter(output)),
        };
        var console = AnsiConsole.Create(settings);
        console.Profile.Width = int.MaxValue;
        return new TestAnsiConsoleWithInput(console, new StringReader(input));
    }
}
 
/// <summary>
/// A test <see cref="IAnsiConsole"/> wrapper that redirects input reads to a <see cref="TextReader"/>,
/// allowing prompts to be answered in unit tests without blocking on real console input.
/// </summary>
file sealed class TestAnsiConsoleWithInput : IAnsiConsole
{
    private readonly IAnsiConsole _inner;
    private readonly IAnsiConsoleInput _testInput;
 
    public TestAnsiConsoleWithInput(IAnsiConsole inner, TextReader inputReader)
    {
        _inner = inner;
        _testInput = new TextReaderInput(inputReader);
    }
 
    public Profile Profile => _inner.Profile;
    public IAnsiConsoleCursor Cursor => _inner.Cursor;
    public IAnsiConsoleInput Input => _testInput;
    public IExclusivityMode ExclusivityMode => _inner.ExclusivityMode;
    public RenderPipeline Pipeline => _inner.Pipeline;
 
    public void Clear(bool home) => _inner.Clear(home);
    public void Write(IRenderable renderable) => _inner.Write(renderable);
    public void WriteAnsi(Action<AnsiWriter> action) => _inner.WriteAnsi(action);
 
    private sealed class TextReaderInput : IAnsiConsoleInput
    {
        private readonly TextReader _reader;
 
        public TextReaderInput(TextReader reader) => _reader = reader;
 
        public bool IsKeyAvailable() => true;
 
        public ConsoleKeyInfo? ReadKey(bool intercept)
        {
            var read = _reader.Read();
            if (read == -1)
            {
                // End of stream - return Enter as a safe fallback
                return new ConsoleKeyInfo('\r', ConsoleKey.Enter, shift: false, alt: false, control: false);
            }
 
            var ch = (char)read;
            var key = ch switch
            {
                '\n' or '\r' => ConsoleKey.Enter,
                'y' or 'Y'   => ConsoleKey.Y,
                'n' or 'N'   => ConsoleKey.N,
                _            => ConsoleKey.Enter,
            };
            return new ConsoleKeyInfo(ch, key, shift: char.IsUpper(ch), alt: false, control: false);
        }
 
        public Task<ConsoleKeyInfo?> ReadKeyAsync(bool intercept, CancellationToken cancellationToken)
            => Task.FromResult(ReadKey(intercept));
    }
}