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 Aspire.Cli.Backchannel;
using Aspire.Cli.Commands;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
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.Create(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("describe --help");
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(ExitCodeConstants.Success, exitCode);
    }
 
    [Fact]
    public async Task DescribeCommand_WhenNoAppHostRunning_ReturnsSuccess()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        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(ExitCodeConstants.Success, exitCode);
    }
 
    [Theory]
    [InlineData("json")]
    [InlineData("Json")]
    [InlineData("JSON")]
    public async Task DescribeCommand_FormatOption_IsCaseInsensitive(string format)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        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(ExitCodeConstants.Success, exitCode);
    }
 
    [Theory]
    [InlineData("table")]
    [InlineData("Table")]
    [InlineData("TABLE")]
    public async Task DescribeCommand_FormatOption_AcceptsTable(string format)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        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(ExitCodeConstants.Success, exitCode);
    }
 
    [Fact]
    public async Task DescribeCommand_FormatOption_RejectsInvalidValue()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("describe --format invalid");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.NotEqual(ExitCodeConstants.Success, exitCode);
    }
 
    [Fact]
    public async Task DescribeCommand_FollowOption_CanBeParsed()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("describe --follow --help");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(ExitCodeConstants.Success, exitCode);
    }
 
    [Fact]
    public async Task DescribeCommand_LegacyWatchOption_StillWorks()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("describe --watch --help");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(ExitCodeConstants.Success, exitCode);
    }
 
    [Fact]
    public async Task DescribeCommand_LegacyResourcesAlias_StillWorks()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("resources --help");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(ExitCodeConstants.Success, exitCode);
    }
 
    [Fact]
    public async Task DescribeCommand_FollowAndFormat_CanBeCombined()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        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(ExitCodeConstants.Success, exitCode);
    }
 
    [Fact]
    public async Task DescribeCommand_ResourceNameArgument_CanBeParsed()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("describe myresource --help");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(ExitCodeConstants.Success, exitCode);
    }
 
    [Fact]
    public async Task DescribeCommand_AllOptions_CanBeCombined()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        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(ExitCodeConstants.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.Create(outputHelper);
        var outputWriter = new TestOutputTextWriter(outputHelper);
        var provider = CreateDescribeTestServices(workspace, outputWriter, [
            new ResourceSnapshot { Name = "redis", DisplayName = "redis", ResourceType = "Container", State = "Running" },
            // Duplicate - identical to the first snapshot
            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" },
        ]);
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("describe --follow --format json");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(ExitCodeConstants.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_TableFormat_DeduplicatesIdenticalSnapshots()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var outputWriter = new TestOutputTextWriter(outputHelper);
        var provider = CreateDescribeTestServices(workspace, outputWriter, [
            new ResourceSnapshot { Name = "redis", DisplayName = "redis", ResourceType = "Container", State = "Running" },
            // Duplicate - identical to the first snapshot
            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" },
        ], disableAnsi: true);
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("describe --follow");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(ExitCodeConstants.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]);
    }
 
    private ServiceProvider CreateDescribeTestServices(
        TemporaryWorkspace workspace,
        TestOutputTextWriter outputWriter,
        List<ResourceSnapshot> resourceSnapshots,
        bool disableAnsi = false)
    {
        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
        };
        monitor.AddConnection("hash1", "socket.hash1", connection);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.AuxiliaryBackchannelMonitorFactory = _ => monitor;
            options.OutputTextWriter = outputWriter;
            options.DisableAnsi = disableAnsi;
        });
 
        return services.BuildServiceProvider();
    }
}