File: Backchannel\AppHostAuxiliaryBackchannelTests.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.Net;
using System.Net.Sockets;
using System.Runtime.CompilerServices;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Telemetry;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using StreamJsonRpc;
 
namespace Aspire.Cli.Tests.Backchannel;
 
public class AppHostAuxiliaryBackchannelTests
{
    [Fact]
    public async Task GetResourceSnapshotsAsync_SendsClientCapabilitiesWithV3()
    {
        using var server = TestAppHostBackchannelServer.Start();
        using var backchannel = await server.ConnectAsync().DefaultTimeout();
 
        var snapshots = await backchannel.GetResourceSnapshotsAsync(includeHidden: true).DefaultTimeout();
 
        var snapshot = Assert.Single(snapshots);
        Assert.Equal("api", snapshot.Name);
        Assert.NotNull(server.Target.GetResourcesRequest);
        Assert.Contains(AuxiliaryBackchannelCapabilities.V3, server.Target.GetResourcesRequest.ClientCapabilities);
    }
 
    [Fact]
    public async Task WatchResourceSnapshotsAsync_SendsClientCapabilitiesWithV3()
    {
        using var server = TestAppHostBackchannelServer.Start();
        using var backchannel = await server.ConnectAsync().DefaultTimeout();
 
        using var watchCancellation = new CancellationTokenSource();
        await using var enumerator = backchannel.WatchResourceSnapshotsAsync(includeHidden: true, watchCancellation.Token).GetAsyncEnumerator();
 
        Assert.True(await enumerator.MoveNextAsync().DefaultTimeout());
        await watchCancellation.CancelAsync();
 
        var resource = enumerator.Current;
        Assert.Equal("api", resource.Name);
        Assert.NotNull(server.Target.WatchResourcesRequest);
        Assert.Contains(AuxiliaryBackchannelCapabilities.V3, server.Target.WatchResourcesRequest.ClientCapabilities);
    }
 
    [Fact]
    public async Task GetTerminalInfoAsync_WhenTerminalsCapabilityMissing_ReturnsUnavailableWithoutCallingRpc()
    {
        // The server below advertises only [V1, V2] — no Terminals_V1. The
        // TestAppHostRpcTarget also deliberately exposes no GetTerminalInfoAsync
        // method, so if the production capability gate is ever removed the call
        // would route to JsonRpc and fail with RemoteMethodNotFoundException —
        // i.e. this test would fail loudly the right way.
        using var server = TestAppHostBackchannelServer.Start();
        using var backchannel = await server.ConnectAsync().DefaultTimeout();
 
        var response = await backchannel.GetTerminalInfoAsync("frontend").DefaultTimeout();
 
        Assert.False(response.IsAvailable);
        Assert.Null(response.Replicas);
    }
 
    [Fact]
    public async Task ListTerminalsAsync_WhenTerminalsCapabilityMissing_ReturnsEmptyWithoutCallingRpc()
    {
        // See GetTerminalInfoAsync_WhenTerminalsCapabilityMissing_*: the server
        // exposes no ListTerminalsAsync handler, so reaching the RPC would
        // throw RemoteMethodNotFoundException. The capability gate must
        // short-circuit before that happens.
        using var server = TestAppHostBackchannelServer.Start();
        using var backchannel = await server.ConnectAsync().DefaultTimeout();
 
        var response = await backchannel.ListTerminalsAsync().DefaultTimeout();
 
        Assert.NotNull(response.Terminals);
        Assert.Empty(response.Terminals);
    }
 
    private sealed class TestAppHostBackchannelServer : IDisposable
    {
        private readonly TcpListener _listener;
        private readonly List<IDisposable> _disposables = [];
 
        private TestAppHostBackchannelServer()
        {
            _listener = new TcpListener(IPAddress.Loopback, 0);
            Target = new TestAppHostRpcTarget();
        }
 
        public TestAppHostRpcTarget Target { get; }
 
        public static TestAppHostBackchannelServer Start()
        {
            var server = new TestAppHostBackchannelServer();
            server._listener.Start();
 
            return server;
        }
 
        public async Task<AppHostAuxiliaryBackchannel> ConnectAsync()
        {
            var clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
            var acceptTask = _listener.AcceptSocketAsync();
            await clientSocket.ConnectAsync((IPEndPoint)_listener.LocalEndpoint).DefaultTimeout();
            var serverSocket = await acceptTask.DefaultTimeout();
            var serverStream = new NetworkStream(serverSocket, ownsSocket: true);
            var messageHandler = new HeaderDelimitedMessageHandler(serverStream, serverStream, BackchannelJsonSerializerContext.CreateRpcMessageFormatter());
            var rpc = new JsonRpc(messageHandler, Target);
            rpc.StartListening();
            _disposables.Add(rpc);
            _disposables.Add(messageHandler);
            _disposables.Add(serverStream);
 
            return await AppHostAuxiliaryBackchannel.CreateFromSocketAsync("hash1", "socket.hash1", isInScope: true, NullLogger.Instance, new ProfilingTelemetry(new ConfigurationBuilder().Build()), clientSocket, CancellationToken.None).DefaultTimeout();
        }
 
        public void Dispose()
        {
            foreach (var disposable in _disposables)
            {
                disposable.Dispose();
            }
 
            _listener.Stop();
        }
    }
 
    private sealed class TestAppHostRpcTarget
    {
        private readonly int _processId = Environment.ProcessId;
        private readonly string[] _capabilities =
        [
            AuxiliaryBackchannelCapabilities.V1,
            AuxiliaryBackchannelCapabilities.V2
        ];
 
        public GetResourcesRequest? GetResourcesRequest { get; private set; }
 
        public WatchResourcesRequest? WatchResourcesRequest { get; private set; }
 
        public Task<AppHostInformation> GetAppHostInformationAsync(CancellationToken cancellationToken = default)
        {
            _ = cancellationToken;
 
            return Task.FromResult(new AppHostInformation
            {
                AppHostPath = "/path/to/AppHost.csproj",
                ProcessId = _processId
            });
        }
 
        public Task<GetCapabilitiesResponse> GetCapabilitiesAsync(GetCapabilitiesRequest? request = null, CancellationToken cancellationToken = default)
        {
            _ = request;
            _ = cancellationToken;
 
            return Task.FromResult(new GetCapabilitiesResponse
            {
                Capabilities = _capabilities
            });
        }
 
        public Task<GetResourcesResponse> GetResourcesAsync(GetResourcesRequest? request = null, CancellationToken cancellationToken = default)
        {
            _ = cancellationToken;
            GetResourcesRequest = request;
 
            return Task.FromResult(new GetResourcesResponse
            {
                Resources = [CreateResourceSnapshot()]
            });
        }
 
        public async IAsyncEnumerable<ResourceSnapshot> WatchResourcesAsync(WatchResourcesRequest? request = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
        {
            WatchResourcesRequest = request;
            yield return CreateResourceSnapshot();
            await Task.CompletedTask.WaitAsync(cancellationToken).ConfigureAwait(false);
        }
 
        private static ResourceSnapshot CreateResourceSnapshot() =>
            new()
            {
                Name = "api",
                ResourceType = "Project"
            };
    }
}