File: Commands\TerminalCommandTests.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 Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.DependencyInjection;
 
namespace Aspire.Cli.Tests.Commands;
 
public class TerminalCommandTests(ITestOutputHelper outputHelper)
{
    [Fact]
    public async Task TerminalCommand_Help_Works()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, o => o.EnabledFeatures = [KnownFeatures.TerminalCommandsEnabled]);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("terminal --help");
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
    }
 
    [Fact]
    public async Task TerminalCommand_WhenNoSubcommand_PrintsHelpAndFails()
    {
        // The 'terminal' parent command is non-runnable; it prints help when invoked
        // alone and returns InvalidCommand to mirror the DashboardCommand pattern.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, o => o.EnabledFeatures = [KnownFeatures.TerminalCommandsEnabled]);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("terminal");
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.NotEqual(CliExitCodes.Success, exitCode);
    }
 
    [Fact]
    public async Task TerminalAttachCommand_WhenNoResourceArgument_FailsParsing()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, o => o.EnabledFeatures = [KnownFeatures.TerminalCommandsEnabled]);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("terminal attach");
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.NotEqual(CliExitCodes.Success, exitCode);
    }
 
    [Fact]
    public async Task TerminalCommand_WhenNoAppHostRunning_ReturnsSuccess()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, o => o.EnabledFeatures = [KnownFeatures.TerminalCommandsEnabled]);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("terminal attach myresource");
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        // Mirrors the LogsCommand behavior: no running AppHost is informational, not an error.
        Assert.Equal(CliExitCodes.Success, exitCode);
    }
 
    [Fact]
    public async Task TerminalCommand_WhenAppHostLacksTerminalsV1Capability_ReturnsAppHostIncompatible()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var (provider, _) = CreateProviderWithBackchannel(
            workspace,
            backchannel =>
            {
                backchannel.SupportsTerminalsV1 = false;
            });
        using (provider)
        {
            var command = provider.GetRequiredService<RootCommand>();
            var result = command.Parse("terminal attach myresource");
            var exitCode = await result.InvokeAsync().DefaultTimeout();
 
            Assert.Equal(CliExitCodes.AppHostIncompatible, exitCode);
        }
    }
 
    [Fact]
    public async Task TerminalCommand_WhenResourceNotFound_ReturnsInvalidCommand()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var (provider, _) = CreateProviderWithBackchannel(
            workspace,
            backchannel =>
            {
                backchannel.ResourceSnapshots = [];
            });
        using (provider)
        {
            var command = provider.GetRequiredService<RootCommand>();
            var result = command.Parse("terminal attach does-not-exist");
            var exitCode = await result.InvokeAsync().DefaultTimeout();
 
            Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
        }
    }
 
    [Fact]
    public async Task TerminalCommand_WhenTerminalNotAvailable_ReturnsInvalidCommand()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var (provider, _) = CreateProviderWithBackchannel(
            workspace,
            backchannel =>
            {
                backchannel.ResourceSnapshots = [CreateSnapshot("myresource")];
                backchannel.TerminalInfoResponse = new GetTerminalInfoResponse
                {
                    IsAvailable = false,
                    Replicas = null
                };
            });
        using (provider)
        {
            var command = provider.GetRequiredService<RootCommand>();
            var result = command.Parse("terminal attach myresource");
            var exitCode = await result.InvokeAsync().DefaultTimeout();
 
            Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
        }
    }
 
    [Fact]
    public async Task TerminalCommand_WhenReplicasArrayEmpty_ReturnsInvalidCommand()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var (provider, _) = CreateProviderWithBackchannel(
            workspace,
            backchannel =>
            {
                backchannel.ResourceSnapshots = [CreateSnapshot("myresource")];
                backchannel.TerminalInfoResponse = new GetTerminalInfoResponse
                {
                    IsAvailable = true,
                    Replicas = []
                };
            });
        using (provider)
        {
            var command = provider.GetRequiredService<RootCommand>();
            var result = command.Parse("terminal attach myresource");
            var exitCode = await result.InvokeAsync().DefaultTimeout();
 
            Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
        }
    }
 
    [Fact]
    public async Task TerminalCommand_WhenReplicaIndexOutOfRange_ReturnsInvalidCommand()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var (provider, _) = CreateProviderWithBackchannel(
            workspace,
            backchannel =>
            {
                backchannel.ResourceSnapshots = [CreateSnapshot("myresource")];
                backchannel.TerminalInfoResponse = new GetTerminalInfoResponse
                {
                    IsAvailable = true,
                    Replicas =
                    [
                        new TerminalReplicaInfo
                        {
                            ReplicaIndex = 0,
                            Label = "myresource-0",
                            ConsumerUdsPath = "/tmp/does-not-exist-0.sock",
                            IsAlive = true
                        },
                        new TerminalReplicaInfo
                        {
                            ReplicaIndex = 1,
                            Label = "myresource-1",
                            ConsumerUdsPath = "/tmp/does-not-exist-1.sock",
                            IsAlive = true
                        }
                    ]
                };
            });
        using (provider)
        {
            var command = provider.GetRequiredService<RootCommand>();
            var result = command.Parse("terminal attach myresource --replica 99");
            var exitCode = await result.InvokeAsync().DefaultTimeout();
 
            Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
        }
    }
 
    [Fact]
    public async Task TerminalCommand_DisplayNameMatchesParentResource()
    {
        // Replicated resources share a DisplayName equal to the parent resource that
        // carries the TerminalAnnotation. Passing the parent name on the CLI must
        // resolve to the same canonical name when looking up terminal info.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        string? capturedResourceName = null;
 
        var (provider, backchannel) = CreateProviderWithBackchannel(
            workspace,
            bc =>
            {
                bc.ResourceSnapshots =
                [
                    CreateSnapshot("myresource-0", displayName: "myresource"),
                    CreateSnapshot("myresource-1", displayName: "myresource")
                ];
                bc.TerminalInfoResponse = new GetTerminalInfoResponse
                {
                    IsAvailable = false
                };
            });
        using (provider)
        {
            // Wrap the test backchannel's terminal info call to capture the canonical name.
            var monitor = (TestAuxiliaryBackchannelMonitor)provider.GetRequiredService<IAuxiliaryBackchannelMonitor>();
            var capturing = new CapturingTerminalAppHostBackchannel(backchannel, name => capturedResourceName = name);
            monitor.ClearConnections();
            monitor.AddConnection("hash1", "socket.hash1", capturing);
 
            var command = provider.GetRequiredService<RootCommand>();
            var result = command.Parse("terminal attach myresource");
            var exitCode = await result.InvokeAsync().DefaultTimeout();
 
            // IsAvailable=false → InvalidCommand, but we should see the canonical
            // parent name "myresource" passed to GetTerminalInfoAsync, not "myresource-0".
            Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
            Assert.Equal("myresource", capturedResourceName);
        }
    }
 
    [Fact]
    public async Task TerminalCommand_NonInteractiveMultiReplicaWithoutFlag_ReturnsInvalidCommand()
    {
        // When stdin or stdout is redirected and the resource has more than one replica,
        // the command must require --replica explicitly (rather than try to prompt).
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var (provider, _) = CreateProviderWithBackchannel(
            workspace,
            backchannel =>
            {
                backchannel.ResourceSnapshots = [CreateSnapshot("myresource")];
                backchannel.TerminalInfoResponse = new GetTerminalInfoResponse
                {
                    IsAvailable = true,
                    Replicas =
                    [
                        new TerminalReplicaInfo
                        {
                            ReplicaIndex = 0,
                            Label = "myresource-0",
                            ConsumerUdsPath = "/tmp/does-not-exist-0.sock",
                            IsAlive = true
                        },
                        new TerminalReplicaInfo
                        {
                            ReplicaIndex = 1,
                            Label = "myresource-1",
                            ConsumerUdsPath = "/tmp/does-not-exist-1.sock",
                            IsAlive = true
                        }
                    ]
                };
            });
        using (provider)
        {
            var command = provider.GetRequiredService<RootCommand>();
            var result = command.Parse("terminal attach myresource");
            // Tests run with both stdout and stdin redirected (xUnit pipes them), so
            // Console.IsInputRedirected and Console.IsOutputRedirected are both true.
            var exitCode = await result.InvokeAsync().DefaultTimeout();
 
            Assert.Equal(CliExitCodes.InvalidCommand, exitCode);
        }
    }
 
    [Fact]
    public async Task TerminalPsCommand_WhenNoAppHostRunning_ReturnsSuccess()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, o => o.EnabledFeatures = [KnownFeatures.TerminalCommandsEnabled]);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<RootCommand>();
        var result = command.Parse("terminal ps");
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        // Mirrors TerminalAttachCommand: no running AppHost is informational, not an error.
        Assert.Equal(CliExitCodes.Success, exitCode);
    }
 
    [Fact]
    public async Task TerminalPsCommand_WhenAppHostLacksTerminalsV1Capability_ReturnsAppHostIncompatible()
    {
        // Older AppHosts that pre-date the 'terminals.v1' capability return
        // SupportsTerminalsV1=false; the command must surface that explicitly rather
        // than misleadingly listing nothing.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var (provider, _) = CreateProviderWithBackchannel(
            workspace,
            backchannel =>
            {
                backchannel.SupportsTerminalsV1 = false;
            });
        using (provider)
        {
            var command = provider.GetRequiredService<RootCommand>();
            var result = command.Parse("terminal ps");
            var exitCode = await result.InvokeAsync().DefaultTimeout();
 
            Assert.Equal(CliExitCodes.AppHostIncompatible, exitCode);
        }
    }
 
    [Fact]
    public async Task TerminalPsCommand_WhenNoTerminalsRegistered_ReturnsSuccess()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var (provider, _) = CreateProviderWithBackchannel(
            workspace,
            backchannel =>
            {
                backchannel.ListTerminalsResponse = new ListTerminalsResponse
                {
                    Terminals = Array.Empty<TerminalSummary>()
                };
            });
        using (provider)
        {
            var command = provider.GetRequiredService<RootCommand>();
            var result = command.Parse("terminal ps");
            var exitCode = await result.InvokeAsync().DefaultTimeout();
 
            Assert.Equal(CliExitCodes.Success, exitCode);
        }
    }
 
    [Fact]
    public async Task TerminalPsCommand_WhenTerminalsPresent_RendersTableSuccessfully()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var (provider, _) = CreateProviderWithBackchannel(
            workspace,
            backchannel =>
            {
                backchannel.ListTerminalsResponse = new ListTerminalsResponse
                {
                    Terminals =
                    [
                        new TerminalSummary
                        {
                            ResourceName = "myresource",
                            DisplayName = "myresource",
                            ConfiguredColumns = 120,
                            ConfiguredRows = 30,
                            IsHostReachable = true,
                            Replicas =
                            [
                                new TerminalReplicaInfo
                                {
                                    ReplicaIndex = 0,
                                    Label = "myresource-0",
                                    ConsumerUdsPath = "/tmp/r0.sock",
                                    IsAlive = true,
                                    CurrentColumns = 130,
                                    CurrentRows = 32,
                                    AttachedPeerCount = 2
                                }
                            ]
                        }
                    ]
                };
            });
        using (provider)
        {
            var command = provider.GetRequiredService<RootCommand>();
            var result = command.Parse("terminal ps");
            var exitCode = await result.InvokeAsync().DefaultTimeout();
 
            Assert.Equal(CliExitCodes.Success, exitCode);
        }
    }
 
    [Fact]
    public async Task TerminalPsCommand_JsonFormat_WhenEmpty_EmitsEmptyArray()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        // Capture stdout so we can verify the actual JSON contract — exit code
        // alone passes vacuously even if no JSON was written, the formatter
        // silently fell back to the text table, or the output isn't valid JSON.
        // DisableAnsi keeps Spectre.Console from injecting escape codes into
        // the captured stream.
        var capturedOutput = new TestOutputTextWriter(outputHelper);
        var (provider, _) = CreateProviderWithBackchannel(
            workspace,
            backchannel =>
            {
                backchannel.ListTerminalsResponse = new ListTerminalsResponse
                {
                    Terminals = Array.Empty<TerminalSummary>()
                };
            },
            options =>
            {
                options.OutputTextWriter = capturedOutput;
                options.DisableAnsi = true;
            });
        using (provider)
        {
            var command = provider.GetRequiredService<RootCommand>();
            var result = command.Parse("terminal ps --format json");
            var exitCode = await result.InvokeAsync().DefaultTimeout();
 
            Assert.Equal(CliExitCodes.Success, exitCode);
 
            // EmitJson is the only producer of stdout in this code path, so the
            // captured output is the serialised JSON document. Trim trailing
            // newlines added by DisplayRawText / the writer.
            var stdout = string.Join("\n", capturedOutput.Logs).Trim();
            Assert.Equal("[]", stdout);
 
            // Belt-and-braces: prove it parses as valid JSON and round-trips
            // to an empty TerminalPsJsonEntry collection.
            var parsed = JsonSerializer.Deserialize(stdout, TerminalPsJsonContext.Default.ListTerminalPsJsonEntry);
            Assert.NotNull(parsed);
            Assert.Empty(parsed);
        }
    }
 
    [Fact]
    public async Task TerminalPsCommand_JsonFormat_Verbose_WhenPopulated_RoundTripsContract()
    {
        // Locks down the public --format json --verbose schema (TerminalPsJsonEntry /
        // TerminalPsJsonReplica / TerminalPsJsonPeer). Scripts piping `aspire terminal
        // ps --format json` to jq depend on field names + casing + verbose-only Peers
        // visibility staying stable; any rename, drop, or accidental verbosity
        // regression here breaks consumers silently.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var capturedOutput = new TestOutputTextWriter(outputHelper);
 
        // Two-resource shape covers the interesting matrix:
        //   - resource A: reachable host, two replicas, second replica has peers.
        //   - resource B: unreachable host (degraded), Replicas still populated
        //                 with AppHost-known shape (per #6 fix).
        var (provider, _) = CreateProviderWithBackchannel(
            workspace,
            backchannel =>
            {
                backchannel.ListTerminalsResponse = new ListTerminalsResponse
                {
                    Terminals =
                    [
                        new TerminalSummary
                        {
                            ResourceName = "frontend",
                            DisplayName = "Frontend",
                            ConfiguredColumns = 120,
                            ConfiguredRows = 30,
                            IsHostReachable = true,
                            Replicas =
                            [
                                new TerminalReplicaInfo
                                {
                                    ReplicaIndex = 0,
                                    Label = "frontend-0",
                                    ConsumerUdsPath = "/tmp/frontend-0.host.sock",
                                    IsAlive = true,
                                    ExitCode = null,
                                    ProducerConnected = true,
                                    RestartCount = 0,
                                    CurrentColumns = 130,
                                    CurrentRows = 32,
                                    AttachedPeerCount = 0,
                                    Peers = Array.Empty<TerminalPeerInfo>(),
                                },
                                new TerminalReplicaInfo
                                {
                                    ReplicaIndex = 1,
                                    Label = "frontend-1",
                                    ConsumerUdsPath = "/tmp/frontend-1.host.sock",
                                    IsAlive = true,
                                    ExitCode = null,
                                    ProducerConnected = true,
                                    RestartCount = 2,
                                    CurrentColumns = 200,
                                    CurrentRows = 50,
                                    AttachedPeerCount = 2,
                                    Peers =
                                    [
                                        new TerminalPeerInfo { PeerId = "peer-a", DisplayName = "aspire-cli:1234" },
                                        new TerminalPeerInfo { PeerId = "peer-b", DisplayName = null },
                                    ],
                                }
                            ]
                        },
                        new TerminalSummary
                        {
                            ResourceName = "backend",
                            DisplayName = "Backend",
                            ConfiguredColumns = 100,
                            ConfiguredRows = 24,
                            IsHostReachable = false,
                            Replicas =
                            [
                                new TerminalReplicaInfo
                                {
                                    ReplicaIndex = 0,
                                    Label = "backend-0",
                                    ConsumerUdsPath = "/tmp/backend-0.host.sock",
                                    IsAlive = false,
                                    ExitCode = null,
                                    ProducerConnected = false,
                                    RestartCount = 0,
                                    CurrentColumns = null,
                                    CurrentRows = null,
                                    AttachedPeerCount = null,
                                    Peers = null,
                                }
                            ]
                        }
                    ]
                };
            },
            options =>
            {
                options.OutputTextWriter = capturedOutput;
                options.DisableAnsi = true;
            });
 
        using (provider)
        {
            var command = provider.GetRequiredService<RootCommand>();
            var result = command.Parse("terminal ps --format json --verbose");
            var exitCode = await result.InvokeAsync().DefaultTimeout();
 
            Assert.Equal(CliExitCodes.Success, exitCode);
 
            var stdout = string.Join("\n", capturedOutput.Logs).Trim();
            var entries = JsonSerializer.Deserialize(stdout, TerminalPsJsonContext.Default.ListTerminalPsJsonEntry);
            Assert.NotNull(entries);
            Assert.Equal(2, entries.Count);
 
            var frontend = entries[0];
            Assert.Equal("frontend", frontend.ResourceName);
            Assert.Equal("Frontend", frontend.DisplayName);
            Assert.Equal(120, frontend.ConfiguredColumns);
            Assert.Equal(30, frontend.ConfiguredRows);
            Assert.True(frontend.IsHostReachable);
            Assert.Equal(2, frontend.Replicas.Length);
 
            var replica0 = frontend.Replicas[0];
            Assert.Equal(0, replica0.ReplicaIndex);
            Assert.True(replica0.IsAlive);
            Assert.True(replica0.ProducerConnected);
            Assert.Equal(0, replica0.RestartCount);
            Assert.Equal(130, replica0.CurrentColumns);
            Assert.Equal(32, replica0.CurrentRows);
            Assert.Equal(0, replica0.AttachedPeerCount);
            // Peers should be present (empty array) under --verbose. We accept
            // either null or empty here because the JSON DTO omits empty arrays
            // via JsonIgnoreCondition.WhenWritingNull only for null — empty
            // arrays still serialise as `[]`.
            Assert.NotNull(replica0.Peers);
            Assert.Empty(replica0.Peers);
 
            var replica1 = frontend.Replicas[1];
            Assert.Equal(1, replica1.ReplicaIndex);
            Assert.Equal(2, replica1.RestartCount);
            Assert.Equal(200, replica1.CurrentColumns);
            Assert.Equal(50, replica1.CurrentRows);
            Assert.Equal(2, replica1.AttachedPeerCount);
            Assert.NotNull(replica1.Peers);
            Assert.Equal(2, replica1.Peers.Length);
            Assert.Equal("peer-a", replica1.Peers[0].PeerId);
            Assert.Equal("aspire-cli:1234", replica1.Peers[0].DisplayName);
            Assert.Equal("peer-b", replica1.Peers[1].PeerId);
            Assert.Null(replica1.Peers[1].DisplayName);
 
            var backend = entries[1];
            Assert.Equal("backend", backend.ResourceName);
            Assert.False(backend.IsHostReachable);
            // Per #6 fix: degraded shape still surfaces Replicas so operators
            // can diagnose which replicas the AppHost expected.
            Assert.Single(backend.Replicas);
            Assert.False(backend.Replicas[0].IsAlive);
            Assert.Null(backend.Replicas[0].CurrentColumns);
            Assert.Null(backend.Replicas[0].AttachedPeerCount);
 
            // Verify camelCase field names are present in the raw output
            // (JsonNamingPolicy.CamelCase is part of the contract — a script
            // doing `jq '.[0].resourceName'` must keep working).
            Assert.Contains("\"resourceName\"", stdout);
            Assert.Contains("\"isHostReachable\"", stdout);
            Assert.Contains("\"replicas\"", stdout);
            Assert.Contains("\"peers\"", stdout);
        }
    }
 
    private (ServiceProvider Provider, TestAppHostAuxiliaryBackchannel Backchannel) CreateProviderWithBackchannel(
        TemporaryWorkspace workspace,
        Action<TestAppHostAuxiliaryBackchannel> configure,
        Action<CliServiceCollectionTestOptions>? configureOptions = null)
    {
        var monitor = new TestAuxiliaryBackchannelMonitor();
        var backchannel = new TestAppHostAuxiliaryBackchannel
        {
            IsInScope = true,
            AppHostInfo = new AppHostInformation
            {
                AppHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"),
                ProcessId = 1234
            },
            SupportsTerminalsV1 = true
        };
        configure(backchannel);
        monitor.AddConnection("hash1", "socket.hash1", backchannel);
 
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.EnabledFeatures = [KnownFeatures.TerminalCommandsEnabled];
            options.AuxiliaryBackchannelMonitorFactory = _ => monitor;
            configureOptions?.Invoke(options);
        });
 
        return (services.BuildServiceProvider(), backchannel);
    }
 
    private static ResourceSnapshot CreateSnapshot(string name, string? displayName = null)
    {
        return new ResourceSnapshot
        {
            Name = name,
            DisplayName = displayName,
            ResourceType = "Project",
            State = "Running"
        };
    }
 
    /// <summary>
    /// Wraps an inner backchannel and captures the resource name passed to
    /// <see cref="IAppHostAuxiliaryBackchannel.GetTerminalInfoAsync"/>. All other calls
    /// delegate to the inner instance.
    /// </summary>
    private sealed class CapturingTerminalAppHostBackchannel : IAppHostAuxiliaryBackchannel
    {
        private readonly TestAppHostAuxiliaryBackchannel _inner;
        private readonly Action<string> _onGetTerminalInfo;
 
        public CapturingTerminalAppHostBackchannel(TestAppHostAuxiliaryBackchannel inner, Action<string> onGetTerminalInfo)
        {
            _inner = inner;
            _onGetTerminalInfo = onGetTerminalInfo;
        }
 
        public string Hash => _inner.Hash;
        public string SocketPath => _inner.SocketPath;
        public AppHostInformation? AppHostInfo => _inner.AppHostInfo;
        public bool IsInScope => _inner.IsInScope;
        public DateTimeOffset ConnectedAt => _inner.ConnectedAt;
        public bool SupportsV2 => _inner.SupportsV2;
        public bool SupportsV3 => _inner.SupportsV3;
        public bool SupportsTerminalsV1 => _inner.SupportsTerminalsV1;
 
        public Task<GetTerminalInfoResponse> GetTerminalInfoAsync(string resourceName, CancellationToken cancellationToken = default)
        {
            _onGetTerminalInfo(resourceName);
            return _inner.GetTerminalInfoAsync(resourceName, cancellationToken);
        }
 
        public Task<ListTerminalsResponse> ListTerminalsAsync(CancellationToken cancellationToken = default)
        {
            return _inner.ListTerminalsAsync(cancellationToken);
        }
 
        public Task<global::Aspire.Cli.Backchannel.DashboardUrlsState?> GetDashboardUrlsAsync(CancellationToken cancellationToken = default)
            => _inner.GetDashboardUrlsAsync(cancellationToken);
        public Task<List<ResourceSnapshot>> GetResourceSnapshotsAsync(bool includeHidden, CancellationToken cancellationToken = default)
            => _inner.GetResourceSnapshotsAsync(includeHidden, cancellationToken);
        public IAsyncEnumerable<ResourceSnapshot> WatchResourceSnapshotsAsync(bool includeHidden, CancellationToken cancellationToken = default)
            => _inner.WatchResourceSnapshotsAsync(includeHidden, cancellationToken);
        public IAsyncEnumerable<ResourceLogLine> GetResourceLogsAsync(string? resourceName = null, bool follow = false, CancellationToken cancellationToken = default)
            => _inner.GetResourceLogsAsync(resourceName, follow, cancellationToken);
        public Task<bool> StopAppHostAsync(CancellationToken cancellationToken = default)
            => _inner.StopAppHostAsync(cancellationToken);
        public Task<ExecuteResourceCommandResponse> ExecuteResourceCommandAsync(string resourceName, string commandName, ExecuteResourceCommandOptions? options = null, CancellationToken cancellationToken = default)
            => _inner.ExecuteResourceCommandAsync(resourceName, commandName, options, cancellationToken);
        public Task<WaitForResourceResponse> WaitForResourceAsync(string resourceName, string status, int timeoutSeconds, CancellationToken cancellationToken = default)
            => _inner.WaitForResourceAsync(resourceName, status, timeoutSeconds, cancellationToken);
        public Task<global::ModelContextProtocol.Protocol.CallToolResult> CallResourceMcpToolAsync(string resourceName, string toolName, IReadOnlyDictionary<string, global::System.Text.Json.JsonElement>? arguments, CancellationToken cancellationToken = default)
            => _inner.CallResourceMcpToolAsync(resourceName, toolName, arguments, cancellationToken);
        public Task<GetDashboardInfoResponse?> GetDashboardInfoV2Async(CancellationToken cancellationToken = default)
            => _inner.GetDashboardInfoV2Async(cancellationToken);
 
        public Task<GetAppHostInfoResponse?> GetAppHostInfoV2Async(CancellationToken cancellationToken = default)
            => _inner.GetAppHostInfoV2Async(cancellationToken);
 
        public Task<WaitForAppHostReadyResponse?> WaitForAppHostReadyAsync(CancellationToken cancellationToken = default)
            => _inner.WaitForAppHostReadyAsync(cancellationToken);
 
        public IAsyncEnumerable<ResourceLogLine> GetConsoleLogsAsync(GetConsoleLogsRequest request, CancellationToken cancellationToken = default)
            => _inner.GetConsoleLogsAsync(request, cancellationToken);
 
        public IAsyncEnumerable<ResourceLogBatch> GetConsoleLogBatchesAsync(GetConsoleLogsRequest request, CancellationToken cancellationToken = default)
            => _inner.GetConsoleLogBatchesAsync(request, cancellationToken);
 
        public void Dispose() => _inner.Dispose();
    }
}