| File: Terminals\ResourceAspireTerminalTests.cs | Web Access |
| Project: src\tests\Aspire.Hosting.Tests\Aspire.Hosting.Tests.csproj (Aspire.Hosting.Tests) |
// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Hosting.Tests.Utils; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.Logging.Abstractions; using System.Net.Sockets; #pragma warning disable ASPIRETERMINAL001 // Test consumer of the experimental AppHost terminal API. namespace Aspire.Hosting.Tests.Terminals; /// <summary> /// Drives <see cref="ResourceAspireTerminal"/> against a real HMP1 socket rather than a fake, because the /// behaviour worth guarding here only exists once a client actually attaches: the AppHost has no controlling /// terminal, so a client that tries to drive one fails at the presentation adapter with a native /// <c>tcgetattr</c> error that no in-memory substitute reproduces. /// </summary> /// <remarks> /// The stand-in for a replica's terminal host is an ordinary Hex1b terminal serving its own Unix domain /// socket, which is the same shape the real terminal host exposes as its consumer socket. /// </remarks> [Trait("Partition", "2")] public class ResourceAspireTerminalTests : IAsyncLifetime { private readonly string _socketDirectory = Directory.CreateTempSubdirectory("aspire-resource-terminal-tests-").FullName; [Fact] public async Task AutomationTypesIntoAndReadsBackFromATerminalHost() { Assert.SkipUnless(OperatingSystem.IsLinux() || OperatingSystem.IsMacOS(), "The workload is a POSIX shell."); // A shell is the workload because the round trip being proven is a human-shaped one: type a command, // have the workload run it, read the result off the replicated screen. await using var host = await TestResourceTerminalHost.StartAsync(CreateSocketPath()); await using var terminal = new ResourceAspireTerminal("resource:test:0", "test", host.SocketPath, NullLogger.Instance).Handle; // The shell echoes the command line before its output, so a fixed marker would match the echo of the // input rather than the result. Splitting the literal across a quote means the typed line and the // output line differ, and only the output line contains the marker. await terminal.SendTextAsync("echo apphost-was\"\"-here").DefaultTimeout(); await terminal.SendKeyAsync(AspireTerminalKey.Enter).DefaultTimeout(); await terminal.WaitForTextAsync("apphost-was-here", TimeSpan.FromSeconds(30)).DefaultTimeout(); Assert.Contains("apphost-was-here", terminal.GetScreenText()); } [Fact] public async Task WaitForTextThrowsTimeoutWhenTheTextNeverAppears() { Assert.SkipUnless(OperatingSystem.IsLinux() || OperatingSystem.IsMacOS(), "The workload is a POSIX shell."); await using var host = await TestResourceTerminalHost.StartAsync(CreateSocketPath()); await using var terminal = new ResourceAspireTerminal("resource:test:0", "test", host.SocketPath, NullLogger.Instance).Handle; // Establish the connection first so the timeout under test is the wait, not the handshake. await terminal.SendTextAsync("\r").DefaultTimeout(); await Assert.ThrowsAsync<TimeoutException>( () => terminal.WaitForTextAsync("text-the-workload-never-writes", TimeSpan.FromSeconds(1))).DefaultTimeout(); } [Fact] public async Task AutomationFailsWhenNoTerminalHostIsListening() { var missingSocket = Path.Combine(_socketDirectory, "not-listening.sock"); await using var terminal = new ResourceAspireTerminal("resource:test:0", "test", missingSocket, NullLogger.Instance).Handle; // A replica whose terminal host is gone must surface as a failed automation call rather than hanging // until the connect timeout expires on every subsequent call. await Assert.ThrowsAnyAsync<Exception>(() => terminal.SendTextAsync("hello")).DefaultTimeout(); } [Fact] public async Task DisposeIsSafeWhenNothingEverConnected() { var terminal = new ResourceAspireTerminal("resource:test:0", "test", Path.Combine(_socketDirectory, "unused.sock"), NullLogger.Instance).Handle; // Listing terminals hands out handles that are never automated, so disposing an unconnected handle is // the common case rather than an edge case. await terminal.DisposeAsync().AsTask().DefaultTimeout(); } [Fact] public async Task AutomationRetriesAfterTheTerminalHostStarts() { Assert.SkipUnless(OperatingSystem.IsLinux() || OperatingSystem.IsMacOS(), "The workload is a POSIX shell."); var socketPath = CreateSocketPath(); await using var terminal = new ResourceAspireTerminal("resource:test:0", "test", socketPath, NullLogger.Instance).Handle; await Assert.ThrowsAnyAsync<Exception>(() => terminal.SendTextAsync("not-delivered")).DefaultTimeout(); await using var host = await TestResourceTerminalHost.StartAsync(socketPath); await terminal.SendTextAsync("echo recovered\"\"-connection\r").DefaultTimeout(); await terminal.WaitForTextAsync("recovered-connection").DefaultTimeout(); Assert.Contains("recovered-connection", terminal.GetScreenText()); } [Fact] public async Task AutomationReconnectsAfterTheTerminalHostRestarts() { Assert.SkipUnless(OperatingSystem.IsLinux() || OperatingSystem.IsMacOS(), "The workload is a POSIX shell."); var socketPath = CreateSocketPath(); await using var firstHost = await TestResourceTerminalHost.StartAsync(socketPath); await using var terminal = new ResourceAspireTerminal("resource:test:0", "test", socketPath, NullLogger.Instance).Handle; await terminal.SendTextAsync("echo first\"\"-host\r").DefaultTimeout(); await terminal.WaitForTextAsync("first-host").DefaultTimeout(); await firstHost.DisposeAsync().AsTask().DefaultTimeout(); await AsyncTestHelpers.AssertIsTrueRetryAsync(() => terminal.GetScreenText() == string.Empty, "The old automation screen was not invalidated."); await using var secondHost = await TestResourceTerminalHost.StartAsync(socketPath); await terminal.SendTextAsync("echo replacement\"\"-host\r").DefaultTimeout(); await terminal.WaitForTextAsync("replacement-host").DefaultTimeout(); Assert.Contains("replacement-host", terminal.GetScreenText()); } [Fact] public async Task CancelingOneCallerDoesNotCancelTheSharedConnectionAttempt() { var socketPath = CreateSocketPath(); using var listener = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); listener.Bind(new UnixDomainSocketEndPoint(socketPath)); listener.Listen(); await using var terminal = new ResourceAspireTerminal("resource:test:0", "test", socketPath, NullLogger.Instance).Handle; using var cts = new CancellationTokenSource(); // Accept the transport but withhold the HMP1 handshake so cancellation occurs during connection setup. var canceledCall = terminal.SendTextAsync("first", cts.Token); using var peer = await listener.AcceptAsync().DefaultTimeout(); var otherCall = terminal.SendTextAsync("second"); await cts.CancelAsync(); await Assert.ThrowsAnyAsync<OperationCanceledException>(() => canceledCall).DefaultTimeout(); Assert.False(otherCall.IsCompleted); await terminal.DisposeAsync().AsTask().DefaultTimeout(); await Assert.ThrowsAnyAsync<OperationCanceledException>(() => otherCall).DefaultTimeout(); } private string CreateSocketPath() // Socket paths have a low length limit (around 104 bytes on macOS), so keep the file name short. => Path.Combine(_socketDirectory, $"{Guid.NewGuid().ToString("N")[..8]}.sock"); public ValueTask InitializeAsync() => ValueTask.CompletedTask; public ValueTask DisposeAsync() { Directory.Delete(_socketDirectory, recursive: true); return ValueTask.CompletedTask; } }