File: Terminal\DefaultTerminalConnectionResolverTests.cs
Web Access
Project: src\tests\Aspire.Dashboard.Tests\Aspire.Dashboard.Tests.csproj (Aspire.Dashboard.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.Sockets;
using Aspire.Dashboard.Model;
using Aspire.Dashboard.Terminal;
using Aspire.Dashboard.Tests.Integration.Playwright.Infrastructure;
using Aspire.DashboardService.Proto.V1;
using Aspire.Tests.Shared.DashboardModel;
using Xunit;
using Value = Google.Protobuf.WellKnownTypes.Value;
 
namespace Aspire.Dashboard.Tests.Terminal;
 
public class DefaultTerminalConnectionResolverTests
{
    [Fact]
    public async Task ConnectAsync_WhenClientNotEnabled_ReturnsNull()
    {
        var client = new DisabledDashboardClient();
        var resolver = new DefaultTerminalConnectionResolver(client);
 
        var stream = await resolver.ConnectAsync("anything", CancellationToken.None);
 
        Assert.Null(stream);
    }
 
    [Fact]
    public async Task ConnectAsync_WhenResourceNotFound_ReturnsNull()
    {
        var client = new MockDashboardClient(resources:
        [
            CreateTerminalResource("other-abc", displayName: "other", udsPath: "/tmp/other-r0.sock"),
        ]);
        var resolver = new DefaultTerminalConnectionResolver(client);
 
        var stream = await resolver.ConnectAsync("missing", CancellationToken.None);
 
        Assert.Null(stream);
    }
 
    [Theory]
    [InlineData("svc")]
    [InlineData("svc-missing")]
    public async Task ConnectAsync_WhenInstanceNameDoesNotMatch_ReturnsNull(string resourceName)
    {
        var client = new MockDashboardClient(resources:
        [
            CreateTerminalResource("svc-abc", displayName: "svc", udsPath: "/tmp/svc-r0.sock"),
            CreateTerminalResource("svc-def", displayName: "svc", udsPath: "/tmp/svc-r1.sock"),
        ]);
        var resolver = new DefaultTerminalConnectionResolver(client);
 
        var stream = await resolver.ConnectAsync(resourceName, CancellationToken.None);
 
        Assert.Null(stream);
    }
 
    [Theory]
    [InlineData("svc-abc")]
    [InlineData("svc-def")]
    public async Task ConnectAsync_UsesExactInstanceWithoutReplicaMetadata(string resourceName)
    {
        var directory = Directory.CreateTempSubdirectory("aspire-term-");
        try
        {
            var socketPath = Path.Combine(directory.FullName, "terminal.sock");
            using var listener = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
            listener.Bind(new UnixDomainSocketEndPoint(socketPath));
            listener.Listen(1);
            var client = new MockDashboardClient(resources:
            [
                CreateTerminalResource("svc-abc", displayName: "svc",
                    udsPath: resourceName == "svc-abc" ? socketPath : Path.Combine(directory.FullName, "other.sock")),
                CreateTerminalResource("svc-def", displayName: "svc",
                    udsPath: resourceName == "svc-def" ? socketPath : Path.Combine(directory.FullName, "other.sock"))
            ]);
            var resolver = new DefaultTerminalConnectionResolver(client);
            using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
 
            await using var stream = await resolver.ConnectAsync(resourceName, timeout.Token);
            Assert.NotNull(stream);
            using var connection = await listener.AcceptAsync(timeout.Token);
            await stream.WriteAsync(new byte[] { 42 }, timeout.Token);
            var buffer = new byte[1];
            Assert.Equal(1, await connection.ReceiveAsync(buffer, SocketFlags.None, timeout.Token));
            Assert.Equal(42, buffer[0]);
        }
        finally
        {
            directory.Delete(recursive: true);
        }
    }
 
    [Fact]
    public async Task ConnectAsync_WhenSnapshotMissingTerminalEnabledMarker_ReturnsNull()
    {
        var resource = ModelTestHelpers.CreateResource(
            resourceName: "svc-abc",
            displayName: "svc",
            properties: new Dictionary<string, ResourcePropertyViewModel>
            {
                [KnownProperties.Terminal.ReplicaIndex] = StringProperty(KnownProperties.Terminal.ReplicaIndex, "0"),
                [KnownProperties.Terminal.ReplicaCount] = StringProperty(KnownProperties.Terminal.ReplicaCount, "1"),
                [KnownProperties.Terminal.ConsumerUdsPath] = StringProperty(KnownProperties.Terminal.ConsumerUdsPath, "/tmp/svc.sock"),
            });
        var client = new MockDashboardClient(resources: [resource]);
        var resolver = new DefaultTerminalConnectionResolver(client);
 
        var stream = await resolver.ConnectAsync("svc-abc", CancellationToken.None);
 
        Assert.Null(stream);
    }
 
    [Fact]
    public async Task ConnectAsync_WhenSnapshotMissingConsumerUdsPath_ReturnsNull()
    {
        var resource = ModelTestHelpers.CreateResource(
            resourceName: "svc-abc",
            displayName: "svc",
            properties: new Dictionary<string, ResourcePropertyViewModel>
            {
                [KnownProperties.Terminal.Enabled] = StringProperty(KnownProperties.Terminal.Enabled, "true"),
                [KnownProperties.Terminal.ReplicaIndex] = StringProperty(KnownProperties.Terminal.ReplicaIndex, "0"),
                [KnownProperties.Terminal.ReplicaCount] = StringProperty(KnownProperties.Terminal.ReplicaCount, "1"),
            });
        var client = new MockDashboardClient(resources: [resource]);
        var resolver = new DefaultTerminalConnectionResolver(client);
 
        var stream = await resolver.ConnectAsync("svc-abc", CancellationToken.None);
 
        Assert.Null(stream);
    }
 
    [Fact]
    public async Task ConnectAsync_WhenUdsPathDoesNotExist_FailsToConnect()
    {
        // Resolver locates the snapshot and the path; the actual UDS connect throws
        // because the path is not a live socket. We just assert that the resolver
        // attempted the connection — transport errors bubble up to the WS proxy.
        var directory = Directory.CreateTempSubdirectory("aspire-term-");
        try
        {
            var resource = CreateTerminalResource(
                resourceName: "svc-abc",
                displayName: "svc",
                udsPath: Path.Combine(directory.FullName, "nonexistent.sock"));
            var client = new MockDashboardClient(resources: [resource]);
            var resolver = new DefaultTerminalConnectionResolver(client);
 
            await Assert.ThrowsAnyAsync<Exception>(() => resolver.ConnectAsync("svc-abc", CancellationToken.None));
        }
        finally
        {
            directory.Delete(recursive: true);
        }
    }
 
    private static ResourceViewModel CreateTerminalResource(string resourceName, string displayName, string udsPath)
    {
        return ModelTestHelpers.CreateResource(
            resourceName: resourceName,
            displayName: displayName,
            properties: new Dictionary<string, ResourcePropertyViewModel>
            {
                [KnownProperties.Terminal.Enabled] = StringProperty(KnownProperties.Terminal.Enabled, "true"),
                [KnownProperties.Terminal.ConsumerUdsPath] = StringProperty(KnownProperties.Terminal.ConsumerUdsPath, udsPath),
            });
    }
 
    private static ResourcePropertyViewModel StringProperty(string name, string value)
    {
        return new ResourcePropertyViewModel(
            name,
            new Value { StringValue = value },
            isValueSensitive: false,
            knownProperty: null,
            sortOrder: 0,
            displayName: null,
            isHighlighted: false);
    }
 
    private sealed class DisabledDashboardClient : IDashboardClient
    {
        public bool IsEnabled => false;
        public Task WhenConnected => Task.CompletedTask;
        public Task WhenResourcesReady => Task.CompletedTask;
        public string ApplicationName => "Disabled";
        public string? MinRequiredVersion => null;
        public DashboardConnectionState ConnectionState => DashboardConnectionState.Connected;
#pragma warning disable CS0067 // Event is never used - required by interface
        public event Action<DashboardConnectionState>? ConnectionStateChanged;
#pragma warning restore CS0067
        public Task ReconnectAsync() => Task.CompletedTask;
        public ValueTask DisposeAsync() => ValueTask.CompletedTask;
        public Task<ResourceCommandResponseViewModel> ExecuteResourceCommandAsync(string resourceName, string resourceType, CommandViewModel command, ExecuteResourceCommandOptions options, CancellationToken cancellationToken) => throw new NotImplementedException();
        public Task<string> UploadFileAsync(Stream fileStream, string fileName, long expectedSize, int interactionId, string inputName, CancellationToken cancellationToken) => throw new NotImplementedException();
        public Task<Stream> AttachTerminalAsync(string terminalId, CancellationToken cancellationToken) => throw new NotImplementedException();
        public IAsyncEnumerable<WatchTerminalsUpdate> SubscribeTerminalsAsync(CancellationToken cancellationToken) => throw new NotImplementedException();
        public Task CloseTerminalAsync(string terminalId, CancellationToken cancellationToken) => throw new NotImplementedException();
        public IAsyncEnumerable<IReadOnlyList<ResourceLogLine>> SubscribeConsoleLogs(string resourceName, CancellationToken cancellationToken) => throw new NotImplementedException();
        public IAsyncEnumerable<IReadOnlyList<ResourceLogLine>> GetConsoleLogs(string resourceName, CancellationToken cancellationToken) => throw new NotImplementedException();
        public Task ClearConsoleLogsAsync(IReadOnlyList<string> resourceNames, DateTime clearDate) => Task.CompletedTask;
        public Task<ResourceViewModelSubscription> SubscribeResourcesAsync(CancellationToken cancellationToken) => throw new NotImplementedException();
        public IAsyncEnumerable<WatchInteractionsResponseUpdate> SubscribeInteractionsAsync(CancellationToken cancellationToken) => throw new NotImplementedException();
        public Task SendInteractionRequestAsync(WatchInteractionsRequestUpdate request, CancellationToken cancellationToken) => throw new NotImplementedException();
        public ResourceViewModel? GetResource(string resourceName) => null;
        public IReadOnlyList<ResourceViewModel> GetResources() => [];
    }
}