File: Commands\RenderCommand.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
#if DEBUG
 
using System.CommandLine;
using System.Reflection;
using System.Runtime.CompilerServices;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Configuration;
using Aspire.Cli.DotNet;
using Aspire.Cli.Interaction;
using Aspire.Cli.Projects;
using Aspire.Cli.Utils;
using Aspire.Cli.Utils.Markdown;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Spectre.Console;
 
namespace Aspire.Cli.Commands;
 
/// <summary>
/// Debug-only command for smoke testing CLI rendering (emoji alignment, status spinners, etc.).
/// </summary>
internal sealed class RenderCommand : BaseCommand
{
    /// <summary>
    /// All emojis defined in <see cref="KnownEmojis"/>, discovered via reflection.
    /// </summary>
    private static readonly KnownEmoji[] s_allEmojis = typeof(KnownEmojis)
        .GetFields(BindingFlags.Public | BindingFlags.Static)
        .Where(f => f.FieldType == typeof(KnownEmoji))
        .Select(f => (KnownEmoji)f.GetValue(null)!)
        .ToArray();
 
    private static readonly Dictionary<string, string> s_choices = new()
    {
        ["displaymessage"] = "Display message (all emojis)",
        ["displaystyles"] = "Display error, success, subtle, and cancellation messages",
        ["showstatus"] = "Show status spinner (first 5 emojis)",
        ["showstatus-markup"] = "Show status with markup rendered",
        ["showstatus-escaped"] = "Show status with markup escaped",
        ["choice"] = "Selection prompt with formatted choices",
        ["choice-simple"] = "Selection prompt without formatter",
        ["mixed"] = "Mixed interaction service methods",
        ["buffered-logging"] = "Background logging during interactive prompt (buffer demo)",
        ["markdown-interactive"] = "Render markdown with DisplayMarkdown (interactive)",
        ["markdown-plain"] = "Render markdown as plain text with DisplayRawText (non-interactive)",
        ["markdown-renderable"] = "Render markdown via ConvertToRenderable with ANSI disabled",
        ["links"] = "Render terminal links with SafeLink and SafeFileLink",
        ["incompatible-version-error"] = "Display incompatible version error (borderless table)",
        ["debug-activities"] = "Debug pipeline activities (calls ProcessPublishingActivitiesDebugAsync)",
        ["pipeline-activities"] = "Pipeline activities with spinner (calls ProcessAndDisplayPublishingActivitiesAsync)",
        ["publish-summary-all"] = "Publish summary timeline (stress scenarios)",
        ["exit"] = "Exit",
    };
 
    private static readonly Dictionary<string, string> s_publishSummaryScenarioDescriptions = new(StringComparers.CommandName)
    {
        ["publish-summary-all"] = "Render all publish summary stress scenarios",
        ["publish-summary-deep-nesting"] = "Render deeply nested publish steps",
        ["publish-summary-long-text"] = "Render long step names and timeline fallback",
        ["publish-summary-markup"] = "Render step names and failures containing markup characters",
        ["publish-summary-mixed-hierarchy"] = "Render a mix of rooted, orphaned, and parentless steps",
        ["publish-summary-duration-extremes"] = "Render very short and very long durations together",
        ["publish-summary-markdown-values"] = "Render pipeline summary items with markdown-enabled values",
    };
 
    private static readonly Option<string?> s_scenarioOption = new("--scenario")
    {
        Description = "Render a specific scenario without prompting.",
        Hidden = true
    };
 
    private static readonly Option<int?> s_consoleWidthOption = new("--console-width")
    {
        Description = "Override the console width used while rendering.",
        Hidden = true
    };
 
    private static readonly Option<bool> s_listScenariosOption = new("--list-scenarios")
    {
        Description = "List supported render scenarios.",
        Hidden = true
    };
 
    private readonly IAnsiConsole _ansiConsole;
    private readonly ICliHostEnvironment _hostEnvironment;
    private readonly IServiceProvider _serviceProvider;
 
    public RenderCommand(
        IAnsiConsole ansiConsole,
        ICliHostEnvironment hostEnvironment,
        IServiceProvider serviceProvider,
        CommonCommandServices services)
        : base("render", "Smoke test CLI rendering", services)
    {
        _ansiConsole = ansiConsole;
        _hostEnvironment = hostEnvironment;
        _serviceProvider = serviceProvider;
 
        Options.Add(s_scenarioOption);
        Options.Add(s_consoleWidthOption);
        Options.Add(s_listScenariosOption);
        Hidden = true;
    }
 
    protected override async Task<CommandResult> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
    {
        if (parseResult.GetValue(s_listScenariosOption))
        {
            ListScenarios();
            return CommandResult.Success();
        }
 
        var requestedScenario = parseResult.GetValue(s_scenarioOption);
        if (!string.IsNullOrEmpty(requestedScenario))
        {
            return CommandResult.FromExitCode(await ExecuteChoiceAsync(requestedScenario, parseResult.GetValue(s_consoleWidthOption), cancellationToken));
        }
 
        var renderedPreviousChoice = false;
        while (true)
        {
            if (renderedPreviousChoice)
            {
                InteractionService.DisplayEmptyLine();
            }
 
            var choice = await InteractionService.PromptForSelectionAsync(
                "What do you want to test?",
                s_choices.Keys,
                key => s_choices[key],
                cancellationToken: cancellationToken);
 
            var exitCode = await ExecuteChoiceAsync(choice, parseResult.GetValue(s_consoleWidthOption), cancellationToken);
            if (choice == "exit" || exitCode != CliExitCodes.Success)
            {
                return CommandResult.FromExitCode(exitCode);
            }
 
            renderedPreviousChoice = true;
        }
    }
 
    private async Task<int> ExecuteChoiceAsync(string choice, int? consoleWidth, CancellationToken cancellationToken)
    {
        var originalWidth = _ansiConsole.Profile.Width;
 
        if (consoleWidth is > 0 and < int.MaxValue)
        {
            _ansiConsole.Profile.Width = consoleWidth.Value;
        }
 
        try
        {
            switch (choice)
            {
                case "displaymessage":
                    return TestDisplayMessage();
                case "displaystyles":
                    return TestDisplayStyles();
                case "showstatus":
                    return await TestShowStatusAsync(cancellationToken);
                case "showstatus-markup":
                    return await TestShowStatusWithMarkupAsync(cancellationToken);
                case "showstatus-escaped":
                    return await TestShowStatusEscapedAsync(cancellationToken);
                case "choice":
                    return await TestChoiceWithFormatterAsync(cancellationToken);
                case "choice-simple":
                    return await TestChoiceSimpleAsync(cancellationToken);
                case "mixed":
                    await TestMixedMethodsAsync(cancellationToken);
                    return CliExitCodes.Success;
                case "buffered-logging":
                    return await TestBufferedLoggingAsync(cancellationToken);
                case "markdown-interactive":
                    return TestMarkdownRenderInteractive();
                case "markdown-plain":
                    return TestMarkdownRenderPlainText();
                case "markdown-renderable":
                    return TestMarkdownRenderRenderable();
                case "links":
                    return await TestLinksAsync(cancellationToken);
                case "incompatible-version-error":
                    return TestIncompatibleVersionError();
                case "debug-activities":
                    return await RenderDebugActivitiesAsync(cancellationToken);
                case "pipeline-activities":
                    return await RenderPipelineActivitiesAsync(cancellationToken);
                case "publish-summary-all":
                    return RenderPublishSummaryScenarios(s_publishSummaryScenarioDescriptions.Keys.Where(k => !StringComparers.CommandName.Equals(k, "publish-summary-all")));
                case "exit":
                    return CliExitCodes.Success;
                default:
                    if (s_publishSummaryScenarioDescriptions.ContainsKey(choice))
                    {
                        return RenderPublishSummaryScenarios([choice]);
                    }
 
                    InteractionService.DisplayError($"Unknown render scenario '{choice}'.");
                    InteractionService.DisplayPlainText("Use 'aspire render --list-scenarios' to see supported values.");
                    return CliExitCodes.InvalidCommand;
            }
        }
        finally
        {
            _ansiConsole.Profile.Width = originalWidth;
        }
    }
 
    private void ListScenarios()
    {
        foreach (var choice in s_choices.Where(choice => !s_publishSummaryScenarioDescriptions.ContainsKey(choice.Key)))
        {
            InteractionService.DisplayPlainText($"{choice.Key}: {choice.Value}");
        }
 
        InteractionService.DisplayEmptyLine();
 
        foreach (var scenario in s_publishSummaryScenarioDescriptions)
        {
            InteractionService.DisplayPlainText($"{scenario.Key}: {scenario.Value}");
        }
    }
 
    private int TestDisplayMessage()
    {
        foreach (var emoji in s_allEmojis)
        {
            InteractionService.DisplayMessage(emoji, $"DisplayMessage with {emoji.Name}");
        }
 
        InteractionService.DisplayEmptyLine();
        InteractionService.DisplayMessage(KnownEmojis.Rocket, "This is a much longer message that is designed to test how text wraps when the terminal window is narrow. It should wrap cleanly beneath the text column without pushing content under the emoji icon on the left side of the display.");
        InteractionService.DisplayMessage(KnownEmojis.CheckMarkButton, "Successfully deployed the application to the remote environment. The deployment included 14 services, 3 databases, and 2 message brokers. All health checks passed and the application is now accepting traffic on the configured endpoints.");
        InteractionService.DisplayError("Something went terribly wrong while attempting to connect to the remote application host. The connection timed out after 30 seconds. Please verify that the host is running and that the network configuration allows traffic on the specified port.");
 
        return CliExitCodes.Success;
    }
 
    private int TestDisplayStyles()
    {
        InteractionService.DisplayError("This is an error message.");
        InteractionService.DisplaySuccess("Operation completed successfully.");
        InteractionService.DisplaySubtleMessage("This is a subtle hint.");
        InteractionService.DisplayCancellationMessage();
 
        InteractionService.DisplayEmptyLine();
        InteractionService.DisplayError("Failed to resolve package 'Aspire.Hosting.Azure.CosmosDB' version 9.2.0. The package source 'https://api.nuget.org/v3/index.json' returned a 503 Service Unavailable response. Please check your network connection and try again, or configure an alternative package source in your NuGet.config file.");
        InteractionService.DisplaySuccess("All 47 integration tests passed successfully across 3 target frameworks (net8.0, net9.0, net10.0). Total execution time: 2 minutes and 14 seconds. Code coverage increased from 78.3% to 82.1%.");
        return CliExitCodes.Success;
    }
 
    private async Task<int> TestShowStatusAsync(CancellationToken cancellationToken)
    {
        foreach (var emoji in s_allEmojis.Take(5))
        {
            await InteractionService.ShowStatusAsync(
                $"ShowStatus with {emoji.Name} for 2 seconds...",
                async () =>
                {
                    await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
                    return CommandResult.Success();
                },
                emoji: emoji);
        }
 
        return CliExitCodes.Success;
    }
 
    private async Task<int> TestShowStatusWithMarkupAsync(CancellationToken cancellationToken)
    {
        await InteractionService.ShowStatusAsync(
            "[bold]Installing[/] packages with [green]markup[/]...",
            async () =>
            {
                await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
                return CommandResult.Success();
            },
            emoji: KnownEmojis.Package,
            allowMarkup: true);
 
        return CliExitCodes.Success;
    }
 
    private async Task<int> TestShowStatusEscapedAsync(CancellationToken cancellationToken)
    {
        await InteractionService.ShowStatusAsync(
            "[bold]Installing[/] packages with [green]markup[/] escaped...",
            async () =>
            {
                await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
                return CommandResult.Success();
            },
            emoji: KnownEmojis.Package);
 
        return CliExitCodes.Success;
    }
 
    private async Task<int> TestChoiceWithFormatterAsync(CancellationToken cancellationToken)
    {
        var packages = new[]
        {
            ("Aspire.Hosting.Redis", "9.2.0", "[green]stable[/]"),
            ("Aspire.Hosting.PostgreSQL", "9.2.0", "[green]stable[/]"),
            ("Aspire.Hosting.RabbitMQ", "9.1.0", "[yellow]preview[/]"),
            ("Aspire.Hosting.MongoDB [Deprecated]", "9.0.0", "[red]deprecated[/]"),
            ("Aspire.Hosting.Kafka", "9.2.0", "[green]stable[/]"),
            ("Aspire.Hosting.MySql [Preview]", "9.1.0", "[yellow]preview[/]"),
        };
 
        var selected = await InteractionService.PromptForSelectionAsync(
            "Select a [bold blue]package[/] to install:",
            packages,
            p => $"{p.Item1.EscapeMarkup()} [dim]v{p.Item2}[/] ({p.Item3})",
            cancellationToken: cancellationToken);
 
        return CliExitCodes.Success;
    }
 
    private async Task<int> TestChoiceSimpleAsync(CancellationToken cancellationToken)
    {
        var environments = new[] { "Development", "Staging", "Production" };
 
        var selected = await InteractionService.PromptForSelectionAsync(
            "Select a target environment:",
            environments,
            e => e,
            cancellationToken: cancellationToken);
 
        InteractionService.DisplayMessage(KnownEmojis.Rocket, $"Deploying to {selected}...");
        return CliExitCodes.Success;
    }
 
    private async Task TestMixedMethodsAsync(CancellationToken cancellationToken)
    {
        InteractionService.DisplayMessage(KnownEmojis.Rocket, "Starting mixed methods test...");
        InteractionService.DisplayEmptyLine();
 
        InteractionService.DisplaySuccess("Step 1 complete!");
        InteractionService.DisplaySubtleMessage("This is a subtle hint.");
        InteractionService.DisplayMessage(KnownEmojis.MagnifyingGlassTiltedLeft, "Searching for [packages]...");
        InteractionService.DisplayEmptyLine();
 
        InteractionService.DisplayMarkupLine("[bold green]Bold green markup[/] and [dim]dim text[/]");
        InteractionService.DisplayPlainText("Plain text with [brackets] that should appear literally.");
        InteractionService.DisplayEmptyLine();
 
        await InteractionService.ShowStatusAsync(
            "Running a quick task...",
            async () =>
            {
                await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
                return 42;
            },
            emoji: KnownEmojis.Gear);
 
        InteractionService.ShowStatus(
            "Synchronous status spinner...",
            () => Thread.Sleep(TimeSpan.FromSeconds(1)),
            emoji: KnownEmojis.Hammer);
 
        InteractionService.DisplayEmptyLine();
 
        var name = await InteractionService.PromptForStringAsync(
            "Enter a test value",
            binding: PromptBinding.CreateDefault<string?>("hello"),
            cancellationToken: cancellationToken);
 
        InteractionService.DisplayMessage(KnownEmojis.CheckMarkButton, $"You entered: {name}");
 
        var confirmed = await InteractionService.PromptConfirmAsync(
            "Do you want to continue?",
            binding: PromptBinding.CreateDefault(true),
            cancellationToken: cancellationToken);
 
        if (confirmed)
        {
            InteractionService.DisplaySuccess("Confirmed!");
        }
        else
        {
            InteractionService.DisplayError("Cancelled.");
        }
 
        InteractionService.DisplayEmptyLine();
        InteractionService.DisplayMessage(KnownEmojis.StopSign, "Mixed methods test complete.");
    }
 
    private async Task<int> TestBufferedLoggingAsync(CancellationToken cancellationToken)
    {
        // Create a dedicated LoggerFactory with SpectreConsoleLoggerProvider so log messages
        // are always visible on stderr without requiring --debug. Uses the shared buffer
        // context from DI so buffering during interactive prompts still works.
        var logBufferContext = _serviceProvider.GetRequiredService<ConsoleLogBufferContext>();
        using var loggerFactory = LoggerFactory.Create(builder =>
        {
            builder.SetMinimumLevel(LogLevel.Debug);
            builder.AddProvider(new SpectreConsoleLoggerProvider(Console.Error, logBufferContext));
        });
        var logger = loggerFactory.CreateLogger<RenderCommand>();
 
        InteractionService.DisplayMessage(KnownEmojis.Information, "This demo fires background log messages while a prompt is active.");
        InteractionService.DisplayMessage(KnownEmojis.Information, "Logs are buffered and flushed after the prompt completes.");
        InteractionService.DisplayEmptyLine();
 
        // Start a background task that writes log messages every 200ms.
        using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        var loggingTask = Task.Run(async () =>
        {
            var counter = 0;
            while (!cts.Token.IsCancellationRequested)
            {
                counter++;
                logger.LogDebug("Background log #{Counter} written while prompt is active", counter);
                await Task.Delay(200, cts.Token).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
            }
        }, cancellationToken);
 
        await Task.Delay(500, cancellationToken); // Let a few logs accumulate before starting the prompt
 
        // Show an interactive prompt — background logs should be buffered during this.
        var answer = await InteractionService.PromptForStringAsync(
            "Type something (background logs are buffering)",
            binding: PromptBinding.CreateDefault<string?>("hello"),
            cancellationToken: cancellationToken);
 
        // Stop background logging and let buffered messages flush.
        cts.Cancel();
        await loggingTask;
 
        InteractionService.DisplayEmptyLine();
        InteractionService.DisplaySuccess($"You entered: {answer}");
        InteractionService.DisplayMessage(KnownEmojis.CheckMarkButton, "Buffered log lines should appear above this message.");
 
        return CliExitCodes.Success;
    }
 
    private async Task<int> TestLinksAsync(CancellationToken cancellationToken)
    {
        var tempDirectory = Directory.CreateTempSubdirectory("aspire-render-links-");
        var filePath = Path.Combine(tempDirectory.FullName, "safe file link sample.txt");
        await File.WriteAllTextAsync(filePath, "This file is used to smoke test SafeFileLink rendering.", cancellationToken).ConfigureAwait(false);
        InteractionService.DisplaySubtleMessage($"Temporary file created at {filePath}", allowMarkup: false);
 
        InteractionService.DisplayPlainText($"Supports links: {InteractionService.SupportsLinks}");
        InteractionService.DisplayMarkupLine($"SafeLink: {MarkupHelpers.SafeLink(InteractionService, "https://www.aspire.dev/", "Aspire documentation")}");
        InteractionService.DisplayMarkupLine($"SafeFileLink: {MarkupHelpers.SafeFileLink(InteractionService, filePath)}");
 
        return CliExitCodes.Success;
    }
 
    private int TestIncompatibleVersionError()
    {
        var ex = new AppHostIncompatibleException(
            "The AppHost is not compatible with this version of the Aspire CLI.",
            requiredCapability: "baseline.v2",
            aspireHostingVersion: "9.2.0");
        return InteractionService.DisplayIncompatibleVersionError(ex, ex.AspireHostingVersion ?? ex.RequiredCapability);
    }
 
    private int RenderPublishSummaryScenarios(IEnumerable<string> scenarioKeys)
    {
        foreach (var scenarioKey in scenarioKeys)
        {
            var scenario = CreatePublishSummaryScenario(scenarioKey);
            InteractionService.DisplayPlainText($"=== {scenario.Title} ===");
 
            var logger = new ConsoleActivityLogger(_ansiConsole, _hostEnvironment, forceColor: _hostEnvironment.SupportsAnsi);
            logger.SeedSummaryState(scenario.Records);
            logger.SetStepDurations(scenario.Records);
            logger.SetFinalResult(scenario.Succeeded, scenario.PipelineSummary);
            logger.WriteSummary();
        }
 
        return CliExitCodes.Success;
    }
 
    private static PublishSummaryRenderScenario CreatePublishSummaryScenario(string scenarioKey) => scenarioKey switch
    {
        "publish-summary-deep-nesting" => new(
            "Deep nesting",
            [
                new("root", "Pipeline", ConsoleActivityLogger.ActivityState.Success, TimeSpan.FromSeconds(14), null, null, 0, 1, TimeSpan.Zero, TimeSpan.FromSeconds(14)),
                new("level-1", "Provision", ConsoleActivityLogger.ActivityState.Success, TimeSpan.FromSeconds(12), null, "root", 1, 2, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(13)),
                new("level-2", "Generate templates", ConsoleActivityLogger.ActivityState.Success, TimeSpan.FromSeconds(10), null, "level-1", 2, 3, TimeSpan.FromSeconds(1.5), TimeSpan.FromSeconds(11.5)),
                new("level-3", "Upload manifests", ConsoleActivityLogger.ActivityState.Success, TimeSpan.FromSeconds(8), null, "level-2", 3, 4, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(10)),
                new("level-4", "Wait for deployment", ConsoleActivityLogger.ActivityState.Success, TimeSpan.FromSeconds(6), null, "level-3", 4, 5, TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(9)),
                new("level-5", "Finalize output", ConsoleActivityLogger.ActivityState.Success, TimeSpan.FromSeconds(2), null, "level-4", 5, 6, TimeSpan.FromSeconds(9), TimeSpan.FromSeconds(11)),
                new("level-10", "Leaf nested 10 levels deep", ConsoleActivityLogger.ActivityState.Warning, TimeSpan.FromSeconds(1), null, "level-5", 10, 7, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(11)),
                new("level-11", "This is a very very very very very long name", ConsoleActivityLogger.ActivityState.Warning, TimeSpan.FromSeconds(1), null, "level-10", 11, 8, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(11)),
            ],
            PipelineSummary:
            [
                new() { Key = "Endpoint", Value = "https://myapp.azurecontainerapps.io", EnableMarkdown = false },
                new() { Key = "Resource Group", Value = "rg-myapp-dev", EnableMarkdown = false },
            ]),
        "publish-summary-long-text" => new(
            "Long text and constrained width",
            [
                new("root", "Publish the application with a deliberately long root step display name", ConsoleActivityLogger.ActivityState.Success, TimeSpan.FromSeconds(20), null, null, 0, 1, TimeSpan.Zero, TimeSpan.FromSeconds(20)),
                new("child", "Generate deployment assets for every resource with an extremely verbose child label", ConsoleActivityLogger.ActivityState.Success, TimeSpan.FromSeconds(12), null, "root", 1, 2, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(14)),
                new("grandchild", "Write an unusually long manifest filename that would normally push the timeline off screen", ConsoleActivityLogger.ActivityState.Success, TimeSpan.FromSeconds(3), null, "child", 2, 3, TimeSpan.FromSeconds(8), TimeSpan.FromSeconds(11)),
            ]),
        "publish-summary-markup" => new(
            "Markup characters in names and failures",
            [
                new("root", "Build [web] frontend", ConsoleActivityLogger.ActivityState.Success, TimeSpan.FromMilliseconds(120), null, null, 0, 1, TimeSpan.Zero, TimeSpan.FromMilliseconds(120)),
                new("child", "Deploy [api] service", ConsoleActivityLogger.ActivityState.Failure, TimeSpan.FromMilliseconds(35), "Failure while parsing [[resource]] => [bold]{bad}[/]", "root", 1, 2, TimeSpan.FromMilliseconds(60), TimeSpan.FromMilliseconds(95)),
                new("sibling", "Notify [observers]", ConsoleActivityLogger.ActivityState.Warning, TimeSpan.FromMilliseconds(12), null, "root", 1, 3, TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(112)),
            ], false),
        "publish-summary-mixed-hierarchy" => new(
            "Mixed roots and orphaned parents",
            [
                new("root-a", "Restore", ConsoleActivityLogger.ActivityState.Success, TimeSpan.FromSeconds(5), null, null, 0, 1, TimeSpan.Zero, TimeSpan.FromSeconds(5)),
                new("orphan", "Orphaned child falls back to root ordering", ConsoleActivityLogger.ActivityState.Success, TimeSpan.FromSeconds(2), null, "missing-parent", 1, 2, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(3)),
                new("root-b", "Publish", ConsoleActivityLogger.ActivityState.Warning, TimeSpan.FromSeconds(4), null, null, 0, 3, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(9)),
                new("child-b", "Package", ConsoleActivityLogger.ActivityState.Success, TimeSpan.FromSeconds(1), null, "root-b", 1, 4, TimeSpan.FromSeconds(6), TimeSpan.FromSeconds(7)),
                new("info-step", "Using cached configuration", ConsoleActivityLogger.ActivityState.Info, TimeSpan.FromSeconds(0), null, "root-b", 1, 5, TimeSpan.FromSeconds(7), TimeSpan.FromSeconds(7)),
                new("root-c", "Validate", ConsoleActivityLogger.ActivityState.Success, TimeSpan.FromSeconds(2), null, null, 0, 6, TimeSpan.FromSeconds(9), TimeSpan.FromSeconds(11)),
            ]),
        "publish-summary-duration-extremes" => new(
            "Duration extremes",
            [
                new("root", "Full pipeline", ConsoleActivityLogger.ActivityState.Success, TimeSpan.FromMinutes(3), null, null, 0, 1, TimeSpan.Zero, TimeSpan.FromMinutes(3)),
                new("tiny", "Tiny 0.2ms event", ConsoleActivityLogger.ActivityState.Success, TimeSpan.FromMilliseconds(0.2), null, "root", 1, 2, TimeSpan.FromMilliseconds(5), TimeSpan.FromMilliseconds(5.2)),
                new("mid", "HTTP publish", ConsoleActivityLogger.ActivityState.Success, TimeSpan.FromSeconds(18), null, "root", 1, 3, TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(48)),
                new("late", "Finalize", ConsoleActivityLogger.ActivityState.Success, TimeSpan.FromSeconds(2), null, "root", 1, 4, TimeSpan.FromMinutes(2.5), TimeSpan.FromMinutes(2.53333333333333)),
                new("zero", "Zero event", ConsoleActivityLogger.ActivityState.Success, TimeSpan.Zero, null, "root", 1, 5, TimeSpan.FromMinutes(2.51), TimeSpan.FromMinutes(2.51)),
            ]),
        "publish-summary-markdown-values" => new(
            "Markdown in pipeline summary values",
            [
                new("root", "Deploy to Azure", ConsoleActivityLogger.ActivityState.Success, TimeSpan.FromSeconds(30), null, null, 0, 1, TimeSpan.Zero, TimeSpan.FromSeconds(30)),
                new("provision", "Provision resources", ConsoleActivityLogger.ActivityState.Success, TimeSpan.FromSeconds(18), null, "root", 1, 2, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(20)),
                new("configure", "Configure endpoints", ConsoleActivityLogger.ActivityState.Success, TimeSpan.FromSeconds(8), null, "root", 1, 3, TimeSpan.FromSeconds(20), TimeSpan.FromSeconds(28)),
            ],
            PipelineSummary:
            [
                new() { Key = "Endpoint", Value = "Application deployed to [https://myapp.azurecontainerapps.io](https://myapp.azurecontainerapps.io)", EnableMarkdown = true },
                new() { Key = "Dashboard", Value = "View resources at [Azure Portal](https://portal.azure.com/#view/resource/123)", EnableMarkdown = true },
                new() { Key = "API Docs", Value = "See the **API reference** at [docs](https://learn.microsoft.com/aspire) for more info", EnableMarkdown = true },
                new() { Key = "Connection String", Value = "`Server=tcp:myserver.database.windows.net;Database=mydb`", EnableMarkdown = true },
                new() { Key = "Region", Value = "East US 2", EnableMarkdown = false },
                new() { Key = "Resource Group", Value = "rg-myapp-prod", EnableMarkdown = false },
                new() { Key = "Next Steps", Value = "Run `aspire publish --env staging` to deploy to **staging**, or visit [the docs](https://learn.microsoft.com/aspire/deployment) for more options.", EnableMarkdown = true },
            ]),
        _ => throw new InvalidOperationException($"Unknown publish summary scenario '{scenarioKey}'.")
    };
 
    private sealed record PublishSummaryRenderScenario(
        string Title,
        IReadOnlyList<ConsoleActivityLogger.StepDurationRecord> Records,
        bool Succeeded = true,
        IReadOnlyList<BackchannelPipelineSummaryItem>? PipelineSummary = null);
 
    private TestPipelineCommand CreateTestPipelineCommand() => new(
        _serviceProvider.GetRequiredService<IDotNetCliRunner>(),
        _serviceProvider.GetRequiredService<IProjectLocator>(),
        _serviceProvider.GetRequiredService<IFeatures>(),
        _hostEnvironment,
        _serviceProvider.GetRequiredService<IAppHostProjectFactory>(),
        _serviceProvider.GetRequiredService<IConfiguration>(),
        _serviceProvider.GetRequiredService<ILogger<RenderCommand>>(),
        _ansiConsole,
        _serviceProvider.GetRequiredService<CommonCommandServices>());
 
    private async Task<int> RenderDebugActivitiesAsync(CancellationToken cancellationToken)
    {
        var command = CreateTestPipelineCommand();
        var activities = CreateFakePublishingActivities(cancellationToken);
        var succeeded = await command.ProcessPublishingActivitiesDebugAsync(activities, backchannel: null!, cancellationToken);
        InteractionService.DisplayEmptyLine();
        InteractionService.DisplaySubtleMessage($"ProcessPublishingActivitiesDebugAsync returned succeeded={succeeded}", allowMarkup: false);
        return CliExitCodes.Success;
    }
 
    private async Task<int> RenderPipelineActivitiesAsync(CancellationToken cancellationToken)
    {
        var command = CreateTestPipelineCommand();
        var activities = CreateFakePublishingActivities(cancellationToken);
        var succeeded = await command.ProcessAndDisplayPublishingActivitiesAsync(activities, backchannel: null!, isDebugOrTraceLoggingEnabled: true, cancellationToken);
        InteractionService.DisplayEmptyLine();
        InteractionService.DisplaySubtleMessage($"ProcessAndDisplayPublishingActivitiesAsync returned succeeded={succeeded}", allowMarkup: false);
        return CliExitCodes.Success;
    }
 
#pragma warning disable IDE0060 // Remove unused parameter — cancellationToken is used by the generated async iterator via [EnumeratorCancellation]
    private static async IAsyncEnumerable<PublishingActivity> CreateFakePublishingActivities([EnumeratorCancellation] CancellationToken cancellationToken = default)
#pragma warning restore IDE0060
    {
        await Task.CompletedTask; // Async iterator
 
        // Step 1: Provision (markdown)
        yield return new PublishingActivity
        {
            Type = PublishingActivityTypes.Step,
            Data = new PublishingActivityData
            {
                Id = "provision",
                StatusText = "Provision **Azure** resources for [myapp](https://portal.azure.com)",
                EnableMarkdown = true,
            }
        };
 
        // Step 2: Build (plain text)
        yield return new PublishingActivity
        {
            Type = PublishingActivityTypes.Step,
            Data = new PublishingActivityData
            {
                Id = "build",
                StatusText = "Build container images",
                EnableMarkdown = false,
            }
        };
 
        // Task under provision – in progress (markdown)
        yield return new PublishingActivity
        {
            Type = PublishingActivityTypes.Task,
            Data = new PublishingActivityData
            {
                Id = "deploy-web",
                StepId = "provision",
                StatusText = "Deploying [webfrontend](https://myapp.azurecontainerapps.io)...",
                EnableMarkdown = true,
            }
        };
 
        // Log: INF with markdown
        yield return new PublishingActivity
        {
            Type = PublishingActivityTypes.Log,
            Data = new PublishingActivityData
            {
                Id = "log-1",
                StepId = "provision",
                StatusText = "Deploying **webfrontend** to [Azure Container Apps](https://learn.microsoft.com/azure/container-apps/)",
                LogLevel = "Information",
                Timestamp = new DateTimeOffset(2026, 4, 23, 10, 30, 1, TimeSpan.Zero),
                EnableMarkdown = true,
            }
        };
 
        // Log: DBG with markdown (dim prefix)
        yield return new PublishingActivity
        {
            Type = PublishingActivityTypes.Log,
            Data = new PublishingActivityData
            {
                Id = "log-2",
                StepId = "provision",
                StatusText = "Checking health at `https://myapp.azurecontainerapps.io/health`",
                LogLevel = "Debug",
                Timestamp = new DateTimeOffset(2026, 4, 23, 10, 30, 5, TimeSpan.Zero),
                EnableMarkdown = true,
            }
        };
 
        // Log: INF plain text
        yield return new PublishingActivity
        {
            Type = PublishingActivityTypes.Log,
            Data = new PublishingActivityData
            {
                Id = "log-3",
                StepId = "build",
                StatusText = "Uploading manifest for apiservice",
                LogLevel = "Information",
                Timestamp = new DateTimeOffset(2026, 4, 23, 10, 30, 2, TimeSpan.Zero),
                EnableMarkdown = false,
            }
        };
 
        // Log: WRN with markdown
        yield return new PublishingActivity
        {
            Type = PublishingActivityTypes.Log,
            Data = new PublishingActivityData
            {
                Id = "log-4",
                StepId = "provision",
                StatusText = "Scaling *down* to **0** instances — see [docs](https://learn.microsoft.com/azure/container-apps/scale)",
                LogLevel = "Warning",
                Timestamp = new DateTimeOffset(2026, 4, 23, 10, 30, 8, TimeSpan.Zero),
                EnableMarkdown = true,
            }
        };
 
        // Task completed – markdown (success)
        yield return new PublishingActivity
        {
            Type = PublishingActivityTypes.Task,
            Data = new PublishingActivityData
            {
                Id = "deploy-web",
                StepId = "provision",
                StatusText = "Deployed [webfrontend](https://myapp.azurecontainerapps.io) successfully",
                CompletionState = CompletionStates.Completed,
                CompletionMessage = "Deployed to [https://myapp.azurecontainerapps.io](https://myapp.azurecontainerapps.io)",
                EnableMarkdown = true,
            }
        };
 
        // Task under build – in progress then completed (plain text)
        yield return new PublishingActivity
        {
            Type = PublishingActivityTypes.Task,
            Data = new PublishingActivityData
            {
                Id = "build-img",
                StepId = "build",
                StatusText = "Building image myapp/web:latest",
                EnableMarkdown = false,
            }
        };
        yield return new PublishingActivity
        {
            Type = PublishingActivityTypes.Task,
            Data = new PublishingActivityData
            {
                Id = "build-img",
                StepId = "build",
                StatusText = "Built image myapp/web:latest",
                CompletionState = CompletionStates.Completed,
                CompletionMessage = "Built image myapp/web:latest (12.4s)",
                EnableMarkdown = false,
            }
        };
 
        // Task failed – markdown
        yield return new PublishingActivity
        {
            Type = PublishingActivityTypes.Task,
            Data = new PublishingActivityData
            {
                Id = "deploy-api",
                StepId = "provision",
                StatusText = "Deploying **apiservice**...",
                EnableMarkdown = true,
            }
        };
        yield return new PublishingActivity
        {
            Type = PublishingActivityTypes.Task,
            Data = new PublishingActivityData
            {
                Id = "deploy-api",
                StepId = "provision",
                StatusText = "Failed to deploy **apiservice**: timeout after `300s`",
                CompletionState = CompletionStates.CompletedWithError,
                CompletionMessage = "See [troubleshooting guide](https://learn.microsoft.com/aspire/troubleshoot) for help.",
                EnableMarkdown = true,
            }
        };
 
        // Step completions
        yield return new PublishingActivity
        {
            Type = PublishingActivityTypes.Step,
            Data = new PublishingActivityData
            {
                Id = "provision",
                StatusText = "Provisioning completed with errors",
                CompletionState = CompletionStates.CompletedWithError,
                EnableMarkdown = true,
            }
        };
        yield return new PublishingActivity
        {
            Type = PublishingActivityTypes.Step,
            Data = new PublishingActivityData
            {
                Id = "build",
                StatusText = "Build completed",
                CompletionState = CompletionStates.Completed,
                EnableMarkdown = false,
            }
        };
 
        // Publish complete with pipeline summary
        yield return new PublishingActivity
        {
            Type = PublishingActivityTypes.PublishComplete,
            Data = new PublishingActivityData
            {
                Id = "publish-complete",
                StatusText = "Publish completed with errors. See [troubleshooting](https://learn.microsoft.com/aspire/troubleshoot).",
                CompletionState = CompletionStates.CompletedWithError,
                EnableMarkdown = true,
                PipelineSummary =
                [
                    new() { Key = "Endpoint", Value = "Application deployed to [https://myapp.azurecontainerapps.io](https://myapp.azurecontainerapps.io)", EnableMarkdown = true },
                    new() { Key = "Region", Value = "East US 2", EnableMarkdown = false },
                    new() { Key = "Next Steps", Value = "Run `aspire publish --env staging` to deploy to **staging**.", EnableMarkdown = true },
                ],
            }
        };
    }
 
    /// <summary>
    /// Minimal concrete PipelineCommandBase subclass for exercising rendering methods.
    /// </summary>
    private sealed class TestPipelineCommand(
        IDotNetCliRunner runner,
        IProjectLocator projectLocator,
        IFeatures features,
        ICliHostEnvironment hostEnvironment,
        IAppHostProjectFactory projectFactory,
        IConfiguration configuration,
        ILogger logger,
        IAnsiConsole ansiConsole,
        CommonCommandServices services)
        : PipelineCommandBase("test-render", "Test rendering", runner, projectLocator, features, hostEnvironment, projectFactory, configuration, logger, ansiConsole, services)
    {
        protected override string OperationCompletedPrefix => "Publish";
        protected override string OperationFailedPrefix => "Publish failed";
        protected override string GetOutputPathDescription() => "Test output path";
        protected override Task<string[]> GetRunArgumentsAsync(string? fullyQualifiedOutputPath, string[] unmatchedTokens, string? targetStep, ParseResult parseResult, CancellationToken cancellationToken) => Task.FromResult(Array.Empty<string>());
        protected override string GetCanceledMessage() => "Test canceled";
        protected override string GetProgressMessage(ParseResult parseResult) => "Test progress";
    }
 
    private static string MarkdownShowcase => LoadMarkdownShowcase();
 
    private static string LoadMarkdownShowcase()
    {
        // File uses .txt extension instead of .md to avoid markdown linters
        using var stream = typeof(RenderCommand).Assembly.GetManifestResourceStream("MarkdownShowcase.txt")
            ?? throw new InvalidOperationException("MarkdownShowcase.txt embedded resource not found.");
        using var reader = new StreamReader(stream);
        return reader.ReadToEnd();
    }
 
    private int TestMarkdownRenderInteractive()
    {
        InteractionService.DisplayMarkdown(MarkdownShowcase);
        return CliExitCodes.Success;
    }
 
    private int TestMarkdownRenderPlainText()
    {
        var plainText = MarkdownToSpectreConverter.ConvertToPlainText(MarkdownShowcase);
        InteractionService.DisplayRawText(plainText);
        return CliExitCodes.Success;
    }
 
    private int TestMarkdownRenderRenderable()
    {
        var renderable = MarkdownToSpectreConverter.ConvertToRenderable(MarkdownShowcase);
 
        var writer = new StringWriter();
        var console = AnsiConsole.Create(new AnsiConsoleSettings
        {
            Ansi = AnsiSupport.No,
            Out = new AnsiConsoleOutput(writer),
        });
 
        console.Write(renderable);
 
        InteractionService.DisplayRawText(writer.ToString());
        return CliExitCodes.Success;
    }
}
 
#endif