File: Commands\DescribeCommandTests.cs
Web Access
Project: src\tests\Aspire.Cli.Tests\Aspire.Cli.Tests.csproj (Aspire.Cli.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Runtime.CompilerServices;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Commands;
using Aspire.Cli.Resources;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Aspire.Dashboard.Model;
using Aspire.Shared.Model.Serialization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.AspNetCore.InternalTesting;
 
namespace Aspire.Cli.Tests.Commands;
 
public class DescribeCommandTests(ITestOutputHelper outputHelper)
{
    [Fact]
    public async Task DescribeCommand_Help_Works()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("describe --help");
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
    }
 
    [Fact]
    public async Task DescribeCommand_WhenNoAppHostRunning_ReturnsSuccess()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("describe");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        // Should succeed - no running AppHost is not an error (like Unix ps with no processes)
        Assert.Equal(CliExitCodes.Success, exitCode);
    }
 
    [Theory]
    [InlineData("json")]
    [InlineData("Json")]
    [InlineData("JSON")]
    public async Task DescribeCommand_FormatOption_IsCaseInsensitive(string format)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse($"describe --format {format} --help");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
    }
 
    [Theory]
    [InlineData("table")]
    [InlineData("Table")]
    [InlineData("TABLE")]
    public async Task DescribeCommand_FormatOption_AcceptsTable(string format)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse($"describe --format {format} --help");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
    }
 
    [Fact]
    public async Task DescribeCommand_FormatOption_RejectsInvalidValue()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("describe --format invalid");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.NotEqual(CliExitCodes.Success, exitCode);
    }
 
    [Fact]
    public async Task DescribeCommand_FollowOption_CanBeParsed()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("describe --follow --help");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
    }
 
    [Fact]
    public async Task DescribeCommand_LegacyWatchOption_StillWorks()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("describe --watch --help");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
    }
 
    [Fact]
    public async Task DescribeCommand_LegacyResourcesAlias_StillWorks()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("resources --help");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
    }
 
    [Fact]
    public async Task DescribeCommand_FollowAndFormat_CanBeCombined()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("describe --follow --format json --help");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
    }
 
    [Fact]
    public async Task DescribeCommand_ResourceNameArgument_CanBeParsed()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("describe myresource --help");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
    }
 
    [Fact]
    public async Task DescribeCommand_AllOptions_CanBeCombined()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("describe myresource --follow --format json --help");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
    }
 
    [Fact]
    public void DescribeCommand_NdjsonFormat_OutputsOneObjectPerLine()
    {
        // Arrange - create resource JSON objects
        var resources = new[]
        {
            new ResourceJson { Name = "frontend", DisplayName = "frontend", ResourceType = "Project", State = "Running" },
            new ResourceJson { Name = "postgres", DisplayName = "postgres", ResourceType = "Container", State = "Running" },
            new ResourceJson { Name = "redis", DisplayName = "redis", ResourceType = "Container", State = "Starting" }
        };
 
        // Act - serialize each resource separately (simulating NDJSON streaming output for --follow)
        var ndjsonLines = resources
            .Select(r => JsonSerializer.Serialize(r, ResourcesCommandJsonContext.Ndjson.ResourceJson))
            .ToList();
 
        // Assert - each line is a complete, valid JSON object with no internal newlines
        foreach (var line in ndjsonLines)
        {
            // Verify no newlines within the JSON (compact format)
            Assert.DoesNotContain('\n', line);
            Assert.DoesNotContain('\r', line);
 
            // Verify it's valid JSON that can be deserialized
            var deserialized = JsonSerializer.Deserialize(line, ResourcesCommandJsonContext.Ndjson.ResourceJson);
            Assert.NotNull(deserialized);
        }
 
        // Verify NDJSON format: joining with newlines creates parseable multi-line output
        var ndjsonOutput = string.Join('\n', ndjsonLines);
        var parsedLines = ndjsonOutput.Split('\n')
            .Select(line => JsonSerializer.Deserialize(line, ResourcesCommandJsonContext.Ndjson.ResourceJson))
            .ToList();
 
        Assert.Equal(3, parsedLines.Count);
        Assert.Equal("frontend", parsedLines[0]!.Name);
        Assert.Equal("postgres", parsedLines[1]!.Name);
        Assert.Equal("Starting", parsedLines[2]!.State);
    }
 
    [Fact]
    public void DescribeCommand_SnapshotFormat_OutputsWrappedJsonArray()
    {
        // Arrange - resources output for snapshot
        var resourcesOutput = new ResourcesOutput
        {
            Resources =
            [
                new ResourceJson { Name = "frontend", DisplayName = "frontend", ResourceType = "Project", State = "Running" },
                new ResourceJson { Name = "postgres", DisplayName = "postgres", ResourceType = "Container", State = "Running" }
            ]
        };
 
        // Act - serialize as snapshot (wrapped JSON)
        var json = JsonSerializer.Serialize(resourcesOutput, ResourcesCommandJsonContext.RelaxedEscaping.ResourcesOutput);
 
        // Assert - it's a single JSON object with "resources" array
        Assert.Contains("\"resources\"", json);
        Assert.StartsWith("{", json.TrimStart());
        Assert.EndsWith("}", json.TrimEnd());
 
        // Verify it can be deserialized back
        var deserialized = JsonSerializer.Deserialize(json, ResourcesCommandJsonContext.RelaxedEscaping.ResourcesOutput);
        Assert.NotNull(deserialized);
        Assert.Equal(2, deserialized.Resources.Length);
        Assert.Equal("frontend", deserialized.Resources[0].Name);
    }
 
    [Fact]
    public async Task DescribeCommand_Follow_JsonFormat_DeduplicatesIdenticalSnapshots()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var initialSnapshotDisplayed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var outputWriter = new TestOutputTextWriter(outputHelper, line =>
        {
            if (line.Contains("\"state\":\"Running\"", StringComparison.Ordinal))
            {
                initialSnapshotDisplayed.TrySetResult();
            }
        });
        using var provider = CreateDescribeTestServices(
            workspace,
            outputWriter,
            [
                new ResourceSnapshot { Name = "redis", DisplayName = "redis", ResourceType = "Container", State = "Running" },
            ],
            configureConnection: connection =>
            {
                connection.WatchResourceSnapshotsHandler = (_, cancellationToken) => YieldResourceSnapshotsAfter(
                    initialSnapshotDisplayed.Task,
                    [
                        // Duplicate of the initial snapshot - should be suppressed
                        new ResourceSnapshot { Name = "redis", DisplayName = "redis", ResourceType = "Container", State = "Running" },
                        // Changed state - should be emitted
                        new ResourceSnapshot { Name = "redis", DisplayName = "redis", ResourceType = "Container", State = "Stopping" },
                        // Duplicate of the changed state - should be suppressed
                        new ResourceSnapshot { Name = "redis", DisplayName = "redis", ResourceType = "Container", State = "Stopping" },
                    ],
                    cancellationToken);
            });
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("describe --follow --format json");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
 
        // Parse all JSON lines from output
        var jsonLines = outputWriter.Logs
            .Where(l => l.TrimStart().StartsWith("{", StringComparison.Ordinal))
            .ToList();
 
        // Should only have 2 lines: the initial "Running" snapshot and the "Stopping" change.
        // The duplicate "Running" and duplicate "Stopping" snapshots should be suppressed.
        Assert.Equal(2, jsonLines.Count);
 
        var first = JsonSerializer.Deserialize(jsonLines[0], ResourcesCommandJsonContext.Ndjson.ResourceJson);
        Assert.NotNull(first);
        Assert.Equal("redis", first.Name);
        Assert.Equal("Container", first.ResourceType);
        Assert.Equal("Running", first.State);
 
        var second = JsonSerializer.Deserialize(jsonLines[1], ResourcesCommandJsonContext.Ndjson.ResourceJson);
        Assert.NotNull(second);
        Assert.Equal("redis", second.Name);
        Assert.Equal("Container", second.ResourceType);
        Assert.Equal("Stopping", second.State);
    }
 
    [Fact]
    public async Task DescribeCommand_Follow_JsonFormat_ResyncEmitsOnlyChangedSnapshots()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var resourceCount = ResourceSnapshotWatcher.UpdateBufferCapacity + 1;
        var initialSnapshots = Enumerable.Range(0, resourceCount)
            .Select(index => new ResourceSnapshot
            {
                Name = $"resource-{index}",
                DisplayName = $"resource-{index}",
                ResourceType = "Container",
                State = "Starting"
            })
            .ToList();
        var changedSnapshots = new[]
        {
            new ResourceSnapshot
            {
                Name = initialSnapshots[0].Name,
                DisplayName = initialSnapshots[0].DisplayName,
                ResourceType = "Container",
                State = "Running"
            },
            new ResourceSnapshot
            {
                Name = initialSnapshots[^1].Name,
                DisplayName = initialSnapshots[^1].DisplayName,
                ResourceType = "Container",
                State = "Running"
            }
        };
        var initialSnapshotsDisplayed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var displayedSnapshotCount = 0;
        var outputWriter = new TestOutputTextWriter(outputHelper, line =>
        {
            if (line.TrimStart().StartsWith("{", StringComparison.Ordinal) &&
                Interlocked.Increment(ref displayedSnapshotCount) == resourceCount)
            {
                initialSnapshotsDisplayed.TrySetResult();
            }
        });
        using var provider = CreateDescribeTestServices(
            workspace,
            outputWriter,
            initialSnapshots,
            configureConnection: connection =>
            {
                connection.WatchResourceSnapshotsHandler = (_, cancellationToken) =>
                    YieldResourceSnapshotsImmediatelyAfter(
                        initialSnapshotsDisplayed.Task,
                        initialSnapshots.Concat(changedSnapshots),
                        cancellationToken);
            });
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("describe --follow --format json");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        var resourcesByName = outputWriter.Logs
            .Where(line => line.TrimStart().StartsWith("{", StringComparison.Ordinal))
            .Select(line => JsonSerializer.Deserialize(line, ResourcesCommandJsonContext.Ndjson.ResourceJson))
            .OfType<ResourceJson>()
            .GroupBy(resource => resource.Name!, StringComparers.ResourceName)
            .ToDictionary(group => group.Key, group => group.Select(resource => resource.State).ToList(), StringComparers.ResourceName);
 
        Assert.Equal(resourceCount, resourcesByName.Count);
        foreach (var snapshot in initialSnapshots)
        {
            var expectedStates = changedSnapshots.Any(changed => StringComparers.ResourceName.Equals(changed.Name, snapshot.Name))
                ? new[] { "Starting", "Running" }
                : ["Starting"];
            Assert.Equal(expectedStates, resourcesByName[snapshot.Name]);
        }
    }
 
    [Fact]
    public async Task DescribeCommand_Follow_JsonFormat_EmitsInitialSnapshotWithoutResourceChanges()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var outputWriter = new TestOutputTextWriter(outputHelper);
        using var provider = CreateDescribeTestServices(
            workspace,
            outputWriter,
            [
                new ResourceSnapshot { Name = "redis", DisplayName = "redis", ResourceType = "Container", State = "Running" },
            ],
            configureConnection: connection =>
            {
                connection.WatchResourceSnapshotsHandler = (_, cancellationToken) => EmptyResourceSnapshots(cancellationToken);
            });
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("describe --follow --format json");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        var jsonLine = Assert.Single(outputWriter.Logs, l => l.TrimStart().StartsWith("{", StringComparison.Ordinal));
        var resource = JsonSerializer.Deserialize(jsonLine, ResourcesCommandJsonContext.Ndjson.ResourceJson);
        Assert.NotNull(resource);
        Assert.Equal("redis", resource.Name);
        Assert.Equal("Running", resource.State);
    }
 
    [Fact]
    public async Task DescribeCommand_Follow_StopsInitialEmissionWhenCanceled()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        using var cts = new CancellationTokenSource();
        var outputWriter = new TestOutputTextWriter(outputHelper, line =>
        {
            if (line.TrimStart().StartsWith("{", StringComparison.Ordinal))
            {
                cts.Cancel();
            }
        });
        using var provider = CreateDescribeTestServices(
            workspace,
            outputWriter,
            [
                new ResourceSnapshot { Name = "redis", DisplayName = "redis", ResourceType = "Container", State = "Running" },
                new ResourceSnapshot { Name = "postgres", DisplayName = "postgres", ResourceType = "Container", State = "Running" },
                new ResourceSnapshot { Name = "frontend", DisplayName = "frontend", ResourceType = "Project", State = "Running" },
            ],
            configureConnection: connection =>
            {
                connection.WatchResourceSnapshotsHandler = (_, cancellationToken) => EmptyResourceSnapshots(cancellationToken);
            });
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("describe --follow --format json");
 
        var exitCode = await result.InvokeAsync(cancellationToken: cts.Token).DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.Single(outputWriter.Logs, l => l.TrimStart().StartsWith("{", StringComparison.Ordinal));
    }
 
    [Fact]
    public async Task DescribeCommand_Follow_JsonFormat_IncludesDisabledCommandsWhenRequested()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var outputWriter = new TestOutputTextWriter(outputHelper);
        using var provider = CreateDescribeTestServices(
            workspace,
            outputWriter,
            [
                new ResourceSnapshot
                {
                    Name = "redis",
                    DisplayName = "redis",
                    ResourceType = "Container",
                    State = "Running",
                    Commands =
                    [
                        new ResourceSnapshotCommand
                        {
                            Name = "restart",
                            DisplayName = "Restart",
                            State = KnownCommandState.Enabled,
                            Visibility = KnownCommandVisibility.UI,
                        },
                        new ResourceSnapshotCommand
                        {
                            Name = "repair",
                            DisplayName = "Repair",
                            State = KnownCommandState.Disabled,
                            Visibility = KnownCommandVisibility.UI,
                        },
                    ],
                },
            ],
            configureConnection: connection =>
            {
                connection.WatchResourceSnapshotsHandler = (_, cancellationToken) => EmptyResourceSnapshots(cancellationToken);
            });
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("describe --follow --format json --include-disabled-commands");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        var jsonLine = Assert.Single(outputWriter.Logs, l => l.TrimStart().StartsWith("{", StringComparison.Ordinal));
        var resource = JsonSerializer.Deserialize(jsonLine, ResourcesCommandJsonContext.Ndjson.ResourceJson);
        Assert.NotNull(resource);
        Assert.Equal(["repair", "restart"], resource.Commands!.Keys);
        Assert.Equal(KnownCommandState.Disabled, resource.Commands["repair"].State);
    }
 
    [Fact]
    public async Task DescribeCommand_Follow_JsonFormat_DoesNotMissResourceCreatedDuringInitialLoad()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var outputWriter = new TestOutputTextWriter(outputHelper);
        var watchStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var watchUpdateConsumed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var watchCallCount = 0;
        using var provider = CreateDescribeTestServices(
            workspace,
            outputWriter,
            [],
            configureConnection: connection =>
            {
                connection.SupportsResourceSnapshotVersionsV1 = true;
                connection.GetResourceSnapshotsHandler = async cancellationToken =>
                {
                    await watchStarted.Task.WaitAsync(cancellationToken);
                    await watchUpdateConsumed.Task.WaitAsync(cancellationToken);
                    // The watch has already published Running by the time this stale
                    // initial snapshot completes. The live change must win.
                    return
                    [
                        new ResourceSnapshot { Name = "redis", Version = 1, DisplayName = "redis", ResourceType = "Container", State = "Starting" },
                    ];
                };
                connection.WatchResourceSnapshotsHandler = (_, cancellationToken) =>
                    Interlocked.Increment(ref watchCallCount) == 1
                        ? YieldResourceAfterSignaling(watchStarted, watchUpdateConsumed, cancellationToken)
                        : EmptyResourceSnapshots(cancellationToken);
            });
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("describe --follow --format json");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.Equal(1, watchCallCount);
        var jsonLine = Assert.Single(outputWriter.Logs, l => l.TrimStart().StartsWith("{", StringComparison.Ordinal));
        var resource = JsonSerializer.Deserialize(jsonLine, ResourcesCommandJsonContext.Ndjson.ResourceJson);
        Assert.NotNull(resource);
        Assert.Equal("redis", resource.Name);
        Assert.Equal("Running", resource.State);
    }
 
    [Fact]
    public async Task DescribeCommand_Follow_TableFormat_DeduplicatesIdenticalSnapshots()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var initialSnapshotDisplayed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var outputWriter = new TestOutputTextWriter(outputHelper, line =>
        {
            if (line.Equals("[redis] Running", StringComparison.Ordinal))
            {
                initialSnapshotDisplayed.TrySetResult();
            }
        });
        using var provider = CreateDescribeTestServices(
            workspace,
            outputWriter,
            [
                new ResourceSnapshot { Name = "redis", DisplayName = "redis", ResourceType = "Container", State = "Running" },
            ],
            disableAnsi: true,
            configureConnection: connection =>
            {
                connection.WatchResourceSnapshotsHandler = (_, cancellationToken) => YieldResourceSnapshotsAfter(
                    initialSnapshotDisplayed.Task,
                    [
                        // Duplicate of the initial snapshot - should be suppressed
                        new ResourceSnapshot { Name = "redis", DisplayName = "redis", ResourceType = "Container", State = "Running" },
                        // Changed state - should be emitted
                        new ResourceSnapshot { Name = "redis", DisplayName = "redis", ResourceType = "Container", State = "Stopping" },
                        // Duplicate of the changed state - should be suppressed
                        new ResourceSnapshot { Name = "redis", DisplayName = "redis", ResourceType = "Container", State = "Stopping" },
                    ],
                    cancellationToken);
            });
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("describe --follow");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
 
        // Filter to lines containing the resource name indicator
        var resourceLines = outputWriter.Logs
            .Where(l => l.StartsWith("[redis]", StringComparison.Ordinal))
            .ToList();
 
        // Should only have 2 lines: one for "Running" and one for "Stopping".
        // Duplicate snapshots with the same state should be suppressed.
        Assert.Equal(2, resourceLines.Count);
        Assert.Equal("[redis] Running", resourceLines[0]);
        Assert.Equal("[redis] Stopping", resourceLines[1]);
    }
 
    [Fact]
    public async Task DescribeCommand_Follow_WhenBackchannelIsDisposed_ExitsSuccessfully()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var outputWriter = new TestOutputTextWriter(outputHelper);
        using var errorWriter = new StringWriter();
        using var provider = CreateDescribeTestServices(workspace, outputWriter, [
            new ResourceSnapshot { Name = "redis", DisplayName = "redis", ResourceType = "Container", State = "Running" },
        ], configureConnection: connection =>
        {
            connection.AppHostInfo = CreateAppHostInfo(workspace, Environment.ProcessId);
            connection.WatchResourceSnapshotsHandler = static (_, cancellationToken) => ThrowObjectDisposedAfterSnapshot(cancellationToken);
        }, errorTextWriter: errorWriter);
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("describe --follow --format json");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
 
        var jsonLines = outputWriter.Logs
            .Where(l => l.TrimStart().StartsWith("{", StringComparison.Ordinal))
            .ToList();
 
        Assert.Single(jsonLines);
        Assert.DoesNotContain(outputWriter.Logs, l => l.Contains("unexpected error occurred", StringComparison.OrdinalIgnoreCase));
        Assert.Contains(InteractionServiceStrings.AppHostConnectionLostGeneric, errorWriter.ToString(), StringComparison.Ordinal);
    }
 
    [Fact]
    public async Task DescribeCommand_Follow_WhenAppHostHasExited_WritesShutdownMessageToStderr()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var outputWriter = new TestOutputTextWriter(outputHelper);
        using var errorWriter = new StringWriter();
        using var provider = CreateDescribeTestServices(workspace, outputWriter, [
            new ResourceSnapshot { Name = "redis", DisplayName = "redis", ResourceType = "Container", State = "Running" },
        ], configureConnection: connection =>
        {
            connection.AppHostInfo = CreateAppHostInfo(workspace, int.MaxValue);
            connection.WatchResourceSnapshotsHandler = static (_, cancellationToken) => ThrowObjectDisposedAfterSnapshot(cancellationToken);
        }, errorTextWriter: errorWriter);
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("describe --follow --format json");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.Single(outputWriter.Logs, l => l.TrimStart().StartsWith("{", StringComparison.Ordinal));
        Assert.Contains(InteractionServiceStrings.AppHostShutDown, errorWriter.ToString(), StringComparison.Ordinal);
    }
 
    [Fact]
    public async Task DescribeCommand_Follow_WhenCanceledAndBackchannelIsDisposed_DoesNotWriteStatusToStderr()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var outputWriter = new TestOutputTextWriter(outputHelper);
        using var errorWriter = new StringWriter();
        using var provider = CreateDescribeTestServices(workspace, outputWriter, [
            new ResourceSnapshot { Name = "redis", DisplayName = "redis", ResourceType = "Container", State = "Running" },
        ], configureConnection: connection =>
        {
            connection.AppHostInfo = CreateAppHostInfo(workspace, Environment.ProcessId);
            connection.WatchResourceSnapshotsHandler = static (_, cancellationToken) => ThrowObjectDisposedAfterCancellationAsync(cancellationToken);
        }, errorTextWriter: errorWriter);
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("describe --follow --format json");
 
        using var cts = new CancellationTokenSource();
        var pendingRun = result.InvokeAsync(cancellationToken: cts.Token);
        await Task.Yield();
        cts.Cancel();
 
        var exitCode = await pendingRun.DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        Assert.DoesNotContain(InteractionServiceStrings.AppHostConnectionLostGeneric, errorWriter.ToString(), StringComparison.Ordinal);
        Assert.DoesNotContain(InteractionServiceStrings.AppHostShutDown, errorWriter.ToString(), StringComparison.Ordinal);
    }
 
    [Fact]
    public async Task DescribeCommand_JsonFormat_StripsLoginPathFromDashboardUrl()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var outputWriter = new TestOutputTextWriter(outputHelper);
        using var provider = CreateDescribeTestServices(workspace, outputWriter, [
            new ResourceSnapshot { Name = "redis", DisplayName = "redis", ResourceType = "Container", State = "Running" },
        ], dashboardUrlsState: new DashboardUrlsState
        {
            BaseUrlWithLoginToken = "http://localhost:18888/login?t=abcd1234"
        });
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("describe --format json");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
 
        var jsonOutput = string.Join("", outputWriter.Logs);
        var deserialized = JsonSerializer.Deserialize(jsonOutput, ResourcesCommandJsonContext.RelaxedEscaping.ResourcesOutput);
 
        Assert.NotNull(deserialized);
        Assert.Single(deserialized.Resources);
 
        Assert.Equal("http://localhost:18888/?resource=redis", deserialized.Resources[0].DashboardUrl);
    }
 
    [Fact]
    public async Task DescribeCommand_JsonFormat_PreservesObjectProperties()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var outputWriter = new TestOutputTextWriter(outputHelper);
        using var provider = CreateDescribeTestServices(workspace, outputWriter, [
            new ResourceSnapshot
            {
                Name = "postgres",
                DisplayName = "postgres",
                ResourceType = "Container",
                State = "Running",
                Properties = new Dictionary<string, JsonNode?>
                {
                    [KnownProperties.Resource.ConnectionProperties] = new JsonObject
                    {
                        ["Host"] = "localhost",
                        ["DatabaseName"] = "catalogdb"
                    }
                }
            },
        ]);
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("describe --format json");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
 
        var jsonOutput = string.Join("", outputWriter.Logs);
        using var document = JsonDocument.Parse(jsonOutput);
        var connectionProperties = document.RootElement
            .GetProperty("resources")[0]
            .GetProperty("properties")
            .GetProperty(KnownProperties.Resource.ConnectionProperties);
 
        Assert.Equal(JsonValueKind.Object, connectionProperties.ValueKind);
        Assert.Equal("localhost", connectionProperties.GetProperty("Host").GetString());
        Assert.Equal("catalogdb", connectionProperties.GetProperty("DatabaseName").GetString());
    }
 
    [Fact]
    public async Task DescribeCommand_Follow_JsonFormat_StripsLoginPathFromDashboardUrl()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var outputWriter = new TestOutputTextWriter(outputHelper);
        using var provider = CreateDescribeTestServices(workspace, outputWriter, [
            new ResourceSnapshot { Name = "redis", DisplayName = "redis", ResourceType = "Container", State = "Running" },
        ], dashboardUrlsState: new DashboardUrlsState
        {
            BaseUrlWithLoginToken = "http://localhost:18888/login?t=abcd1234"
        });
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("describe --follow --format json");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
 
        var jsonLines = outputWriter.Logs
            .Where(l => l.TrimStart().StartsWith("{", StringComparison.Ordinal))
            .ToList();
 
        Assert.NotEmpty(jsonLines);
 
        var resource = JsonSerializer.Deserialize(jsonLines[0], ResourcesCommandJsonContext.Ndjson.ResourceJson);
        Assert.NotNull(resource);
 
        Assert.Equal("http://localhost:18888/?resource=redis", resource.DashboardUrl);
    }
 
    [Fact]
    public async Task DescribeCommand_HiddenResources_AreExcludedByDefault()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var outputWriter = new TestOutputTextWriter(outputHelper);
        using var provider = CreateDescribeTestServices(workspace, outputWriter, [
            new ResourceSnapshot { Name = "redis", DisplayName = "redis", ResourceType = "Container", State = "Running" },
            new ResourceSnapshot { Name = "aspire-dashboard", DisplayName = "aspire-dashboard", ResourceType = "Executable", State = "Hidden" },
            new ResourceSnapshot { Name = "hidden-svc", DisplayName = "hidden-svc", ResourceType = "Project", State = "Running", IsHidden = true },
        ]);
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("describe --format json");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
 
        var jsonOutput = string.Join("", outputWriter.Logs);
        var deserialized = JsonSerializer.Deserialize(jsonOutput, ResourcesCommandJsonContext.RelaxedEscaping.ResourcesOutput);
 
        Assert.NotNull(deserialized);
        Assert.Single(deserialized.Resources);
        Assert.Equal("redis", deserialized.Resources[0].Name);
    }
 
    [Fact]
    public async Task DescribeCommand_IncludeHidden_ShowsHiddenResources()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var outputWriter = new TestOutputTextWriter(outputHelper);
        using var provider = CreateDescribeTestServices(workspace, outputWriter, [
            new ResourceSnapshot { Name = "redis", DisplayName = "redis", ResourceType = "Container", State = "Running" },
            new ResourceSnapshot { Name = "aspire-dashboard", DisplayName = "aspire-dashboard", ResourceType = "Executable", State = "Hidden" },
            new ResourceSnapshot { Name = "hidden-svc", DisplayName = "hidden-svc", ResourceType = "Project", State = "Running", IsHidden = true },
        ]);
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("describe --format json --include-hidden");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
 
        var jsonOutput = string.Join("", outputWriter.Logs);
        var deserialized = JsonSerializer.Deserialize(jsonOutput, ResourcesCommandJsonContext.RelaxedEscaping.ResourcesOutput);
 
        Assert.NotNull(deserialized);
        Assert.Equal(3, deserialized.Resources.Length);
        Assert.Contains(deserialized.Resources, r => r.Name == "redis");
        Assert.Contains(deserialized.Resources, r => r.Name == "aspire-dashboard");
        Assert.Contains(deserialized.Resources, r => r.Name == "hidden-svc");
    }
 
    [Fact]
    public async Task DescribeCommand_SpecificResource_IncludesHiddenWithoutFlag()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var outputWriter = new TestOutputTextWriter(outputHelper);
        using var provider = CreateDescribeTestServices(workspace, outputWriter, [
            new ResourceSnapshot { Name = "redis", DisplayName = "redis", ResourceType = "Container", State = "Running" },
            new ResourceSnapshot { Name = "aspire-dashboard", DisplayName = "aspire-dashboard", ResourceType = "Executable", State = "Hidden" },
        ]);
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("describe aspire-dashboard --format json");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
 
        var jsonOutput = string.Join("", outputWriter.Logs);
        var deserialized = JsonSerializer.Deserialize(jsonOutput, ResourcesCommandJsonContext.RelaxedEscaping.ResourcesOutput);
 
        Assert.NotNull(deserialized);
        Assert.Single(deserialized.Resources);
        Assert.Equal("aspire-dashboard", deserialized.Resources[0].Name);
    }
 
    [Fact]
    public async Task DescribeCommand_Follow_HiddenResources_AreExcludedByDefault()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var outputWriter = new TestOutputTextWriter(outputHelper);
        using var provider = CreateDescribeTestServices(workspace, outputWriter, [
            new ResourceSnapshot { Name = "redis", DisplayName = "redis", ResourceType = "Container", State = "Running" },
            new ResourceSnapshot { Name = "aspire-dashboard", DisplayName = "aspire-dashboard", ResourceType = "Executable", State = "Hidden" },
        ]);
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("describe --follow --format json");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
 
        var jsonLines = outputWriter.Logs
            .Where(l => l.TrimStart().StartsWith("{", StringComparison.Ordinal))
            .ToList();
 
        Assert.Single(jsonLines);
 
        var resource = JsonSerializer.Deserialize(jsonLines[0], ResourcesCommandJsonContext.Ndjson.ResourceJson);
        Assert.NotNull(resource);
        Assert.Equal("redis", resource.Name);
    }
 
    [Fact]
    public async Task DescribeCommand_Follow_IncludeHidden_ShowsHiddenResources()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var outputWriter = new TestOutputTextWriter(outputHelper);
        using var provider = CreateDescribeTestServices(workspace, outputWriter, [
            new ResourceSnapshot { Name = "redis", DisplayName = "redis", ResourceType = "Container", State = "Running" },
            new ResourceSnapshot { Name = "aspire-dashboard", DisplayName = "aspire-dashboard", ResourceType = "Executable", State = "Hidden" },
        ]);
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("describe --follow --format json --include-hidden");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
 
        var jsonLines = outputWriter.Logs
            .Where(l => l.TrimStart().StartsWith("{", StringComparison.Ordinal))
            .ToList();
 
        Assert.Equal(2, jsonLines.Count);
        Assert.Contains(jsonLines, l => l.Contains("redis"));
        Assert.Contains(jsonLines, l => l.Contains("aspire-dashboard"));
    }
 
    private ServiceProvider CreateDescribeTestServices(
        TemporaryWorkspace workspace,
        TestOutputTextWriter outputWriter,
        List<ResourceSnapshot> resourceSnapshots,
        bool disableAnsi = false,
        DashboardUrlsState? dashboardUrlsState = null,
        Action<TestAppHostAuxiliaryBackchannel>? configureConnection = null,
        StringWriter? errorTextWriter = null)
    {
        var monitor = new TestAuxiliaryBackchannelMonitor();
        var connection = new TestAppHostAuxiliaryBackchannel
        {
            IsInScope = true,
            AppHostInfo = new AppHostInformation
            {
                AppHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"),
                ProcessId = 1234
            },
            ResourceSnapshots = resourceSnapshots,
            DashboardUrlsState = dashboardUrlsState
        };
        configureConnection?.Invoke(connection);
        monitor.AddConnection("hash1", "socket.hash1", connection);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.AuxiliaryBackchannelMonitorFactory = _ => monitor;
            options.OutputTextWriter = outputWriter;
            options.ErrorTextWriter = errorTextWriter;
            options.DisableAnsi = disableAnsi;
        });
 
        return services.BuildServiceProvider();
    }
 
    private static AppHostInformation CreateAppHostInfo(TemporaryWorkspace workspace, int processId)
    {
        return new AppHostInformation
        {
            AppHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"),
            ProcessId = processId
        };
    }
 
    private static async IAsyncEnumerable<ResourceSnapshot> ThrowObjectDisposedAfterSnapshot([EnumeratorCancellation] CancellationToken cancellationToken = default)
    {
        yield return new ResourceSnapshot
        {
            Name = "redis",
            DisplayName = "redis",
            ResourceType = "Container",
            State = "Running"
        };
 
        await Task.Yield();
        cancellationToken.ThrowIfCancellationRequested();
 
        throw new ObjectDisposedException("StreamJsonRpc.JsonRpc");
    }
 
    private static async IAsyncEnumerable<ResourceSnapshot> EmptyResourceSnapshots([EnumeratorCancellation] CancellationToken cancellationToken = default)
    {
        await Task.Yield();
        cancellationToken.ThrowIfCancellationRequested();
        yield break;
    }
 
    private static async IAsyncEnumerable<ResourceSnapshot> YieldResourceSnapshots(
        IEnumerable<ResourceSnapshot> snapshots,
        [EnumeratorCancellation] CancellationToken cancellationToken = default)
    {
        foreach (var snapshot in snapshots)
        {
            await Task.Yield();
            cancellationToken.ThrowIfCancellationRequested();
            yield return snapshot;
        }
    }
 
    private static async IAsyncEnumerable<ResourceSnapshot> YieldResourceSnapshotsAfter(
        Task prerequisite,
        IEnumerable<ResourceSnapshot> snapshots,
        [EnumeratorCancellation] CancellationToken cancellationToken = default)
    {
        await prerequisite.WaitAsync(cancellationToken);
        await foreach (var snapshot in YieldResourceSnapshots(snapshots, cancellationToken))
        {
            yield return snapshot;
        }
    }
 
    private static async IAsyncEnumerable<ResourceSnapshot> YieldResourceSnapshotsImmediatelyAfter(
        Task prerequisite,
        IEnumerable<ResourceSnapshot> snapshots,
        [EnumeratorCancellation] CancellationToken cancellationToken = default)
    {
        await prerequisite.WaitAsync(cancellationToken);
        foreach (var snapshot in snapshots)
        {
            cancellationToken.ThrowIfCancellationRequested();
            yield return snapshot;
        }
    }
 
    private static async IAsyncEnumerable<ResourceSnapshot> YieldResourceAfterSignaling(
        TaskCompletionSource watchStarted,
        TaskCompletionSource watchUpdateConsumed,
        [EnumeratorCancellation] CancellationToken cancellationToken = default)
    {
        watchStarted.TrySetResult();
        await Task.Yield();
        cancellationToken.ThrowIfCancellationRequested();
        yield return new ResourceSnapshot
        {
            Name = "redis",
            DisplayName = "redis",
            ResourceType = "Container",
            State = "Running",
            Version = 2,
        };
        watchUpdateConsumed.TrySetResult();
    }
 
    private static async IAsyncEnumerable<ResourceSnapshot> ThrowObjectDisposedAfterCancellationAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
    {
        yield return new ResourceSnapshot
        {
            Name = "redis",
            DisplayName = "redis",
            ResourceType = "Container",
            State = "Running"
        };
 
        var waitForCancellation = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        using var registration = cancellationToken.Register(() => waitForCancellation.TrySetResult());
        await waitForCancellation.Task;
 
        throw new ObjectDisposedException("StreamJsonRpc.JsonRpc");
    }
}