File: TerminalCommandTests.cs
Web Access
Project: src\tests\Aspire.Cli.EndToEnd.Tests\Aspire.Cli.EndToEnd.Tests.csproj (Aspire.Cli.EndToEnd.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.EndToEnd.Tests.Helpers;
using Hex1b.Automation;
using Hex1b.Input;
using Xunit;
 
namespace Aspire.Cli.EndToEnd.Tests;
 
/// <summary>
/// End-to-end tests for Aspire terminal commands.
/// </summary>
public sealed class TerminalCommandTests(ITestOutputHelper output)
{
    [Fact]
    [CaptureWorkspaceOnFailure]
    public async Task TerminalReplicaInstanceId_RoutesAttachAndTapeInputToSelectedProcess()
    {
        var repoRoot = CliE2ETestHelpers.GetRepoRoot();
        var strategy = CliInstallStrategy.Detect(output.WriteLine);
        var workspace = TemporaryWorkspace.Create(output);
 
        using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace);
        var counter = new SequenceCounter();
        var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
        await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken);
 
        await auto.PrepareDockerEnvironmentAsync(counter, workspace);
        await auto.InstallAspireCliAsync(strategy, counter);
        await auto.AspireNewAsync("TerminalReplicas", counter, template: AspireTemplate.EmptyAppHost);
        await auto.RunCommandAsync("cd TerminalReplicas", counter);
        await auto.RunCommandAsync("dotnet new console --output TerminalProbe --framework net10.0", counter);
 
        var projectDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, "TerminalReplicas");
        var appHostPath = Path.Combine(projectDirectory, "apphost.cs");
        var appHostSource = File.ReadAllText(appHostPath);
        const string builderDeclaration = "var builder = DistributedApplication.CreateBuilder(args);";
        Assert.Contains(builderDeclaration, appHostSource);
        var header = appHostSource[..appHostSource.IndexOf(builderDeclaration, StringComparison.Ordinal)];
        File.WriteAllText(appHostPath, header + """
            #pragma warning disable ASPIRETERMINAL001
 
            var builder = DistributedApplication.CreateBuilder(args);
            builder.AddProject("replicated", Path.Combine(builder.AppHostDirectory, "TerminalProbe", "TerminalProbe.csproj"))
                .WithEnvironment("PROBE_DIRECTORY", Path.Combine(builder.AppHostDirectory, "received"))
                .WithReplicas(2)
                .WithTerminal();
            builder.Build().Run();
            """);
        File.WriteAllText(Path.Combine(projectDirectory, "TerminalProbe", "Program.cs"), """
            var directory = Environment.GetEnvironmentVariable("PROBE_DIRECTORY")!;
            Directory.CreateDirectory(directory);
            // DCP reports the "dotnet run" launcher's PID, not this console child's PID.
            // Linux /proc/self/status has a "PPid:\t123" field; use it to correlate
            // the actual input recipient with the independently discovered resource.
            var launcherPid = File.ReadLines("/proc/self/status")
                .Single(line => line.StartsWith("PPid:", StringComparison.Ordinal))["PPid:".Length..].Trim();
            var inputPath = Path.Combine(directory, $"{launcherPid}.input");
            File.WriteAllText(inputPath, "");
            Console.WriteLine($"READY:LAUNCHER:{launcherPid}|PID:{Environment.ProcessId}");
            while (Console.ReadLine() is { } line)
            {
                File.AppendAllText(inputPath, line + Environment.NewLine);
                Console.WriteLine($"ACK:{line}|LAUNCHER:{launcherPid}|PID:{Environment.ProcessId}");
            }
            """);
        File.WriteAllText(Path.Combine(projectDirectory, "probe.tape"), "");
 
        // Avoid concurrent first builds of the same project from the two "dotnet run" replicas.
        await auto.RunCommandAsync("dotnet build TerminalProbe/TerminalProbe.csproj", counter);
        await auto.AspireStartAsync(counter, isolated: true);
        const string replicasDiscovered = """
            [.resources[] | select(.displayName == "replicated" and .properties["terminal.replicaIndex"] != null and .properties["executable.pid"] != null)] | length == 2
            """;
        await auto.RunCommandAsync(
            $"for attempt in $(seq 1 120); do aspire describe --format json > resources.json && jq -e '{replicasDiscovered}' resources.json > /dev/null && break; sleep 1; done; jq -e '{replicasDiscovered}' resources.json > /dev/null",
            counter, TimeSpan.FromMinutes(3));
 
        using var discovery = JsonDocument.Parse(File.ReadAllText(Path.Combine(projectDirectory, "resources.json")));
        var replicas = discovery.RootElement.GetProperty("resources").EnumerateArray()
            .Where(resource => resource.GetProperty("displayName").GetString() == "replicated")
            .OrderBy(resource => resource.GetProperty("properties").GetProperty("terminal.replicaIndex").GetString())
            .ToArray();
        Assert.Equal(2, replicas.Length);
        Assert.Equal("0", replicas[0].GetProperty("properties").GetProperty("terminal.replicaIndex").GetString());
        Assert.Equal("1", replicas[1].GetProperty("properties").GetProperty("terminal.replicaIndex").GetString());
        var instanceIds = replicas.Select(resource => resource.GetProperty("name").GetString()!).ToArray();
        foreach (var instanceId in instanceIds)
        {
            await auto.RunCommandAsync($"aspire wait {instanceId} --status up --timeout 120", counter, TimeSpan.FromMinutes(3));
        }
        var otherLauncherPid = replicas[0].GetProperty("properties").GetProperty("executable.pid").GetInt32();
        var selectedLauncherPid = replicas[1].GetProperty("properties").GetProperty("executable.pid").GetInt32();
        Assert.NotEqual(otherLauncherPid, selectedLauncherPid);
        await auto.RunCommandAsync(
            $"timeout 120s bash -c 'until test -f received/{otherLauncherPid}.input && test -f received/{selectedLauncherPid}.input; do sleep 1; done'",
            counter, TimeSpan.FromMinutes(3));
        var availableIds = string.Join(", ", instanceIds.Order(StringComparer.OrdinalIgnoreCase));
 
        foreach (var options in new[] { "", " --non-interactive" })
        {
            // timeout makes an accidental replica picker fail instead of hanging the test.
            // Neither command receives input; only the expected exit code 1 passes.
            await auto.RunCommandAsync(
                $"timeout 15s aspire terminal attach replicated{options} > attach-ambiguity.log 2>&1; code=$?; cat attach-ambiguity.log; test \"$code\" -eq 1",
                counter, TimeSpan.FromSeconds(30));
            Assert.Contains($"Available instance IDs: {availableIds}.",
                File.ReadAllText(Path.Combine(projectDirectory, "attach-ambiguity.log")));
            await auto.RunCommandAsync(
                $"timeout 15s aspire terminal tape play replicated --tape-file probe.tape{options} > tape-ambiguity.log 2>&1; code=$?; cat tape-ambiguity.log; test \"$code\" -eq 1",
                counter, TimeSpan.FromSeconds(30));
            Assert.Contains($"Available instance IDs: {availableIds}.",
                File.ReadAllText(Path.Combine(projectDirectory, "tape-ambiguity.log")));
        }
 
        await auto.ClearScreenAsync(counter);
        await auto.TypeAsync($"aspire terminal attach {instanceIds[1]}");
        await auto.EnterAsync();
        await auto.WaitUntilTextAsync($"READY:LAUNCHER:{selectedLauncherPid}|", timeout: TimeSpan.FromSeconds(30));
        await auto.TypeAsync("attach-probe");
        await auto.EnterAsync();
        await auto.WaitUntilTextAsync($"ACK:attach-probe|LAUNCHER:{selectedLauncherPid}|", timeout: TimeSpan.FromSeconds(30));
        // Match the existing terminal E2E's interactive cadence so the viewer
        // processes the chord prefix before receiving the suffix.
        await auto.Ctrl().KeyAsync(Hex1bKey.B);
        await auto.WaitAsync(1000);
        await auto.KeyAsync(Hex1bKey.D);
        await auto.WaitForSuccessPromptAsync(counter, timeout: TimeSpan.FromSeconds(30));
 
        var selectedInput = Path.Combine(projectDirectory, "received", $"{selectedLauncherPid}.input");
        var otherInput = Path.Combine(projectDirectory, "received", $"{otherLauncherPid}.input");
        Assert.Equal(["attach-probe"], File.ReadAllLines(selectedInput));
        Assert.Empty(File.ReadAllLines(otherInput));
 
        File.WriteAllText(Path.Combine(projectDirectory, "probe.tape"), $$"""
            Set TypingSpeed 0
            Set WaitTimeout 20s
            Type "tape-probe"
            Enter
            Wait+Screen /ACK:tape-probe[|]LAUNCHER:{{selectedLauncherPid}}[|]/
            """);
        await auto.RunCommandAsync(
            $"aspire terminal tape play {instanceIds[1]} --tape-file probe.tape --timeout 30",
            counter, TimeSpan.FromSeconds(45));
 
        Assert.Equal(["attach-probe", "tape-probe"], File.ReadAllLines(selectedInput));
        Assert.Empty(File.ReadAllLines(otherInput));
 
        await auto.AspireStopAsync(counter);
    }
 
    [Fact]
    [CaptureWorkspaceOnFailure]
    public async Task TerminalAttachFrontend_ShowsViteHelpAndDetaches()
    {
        var repoRoot = CliE2ETestHelpers.GetRepoRoot();
        var strategy = CliInstallStrategy.Detect(output.WriteLine);
        var workspace = TemporaryWorkspace.Create(output);
 
        using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace);
        var counter = new SequenceCounter();
        var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
        await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken);
 
        await auto.PrepareDockerEnvironmentAsync(counter, workspace);
        await auto.InstallAspireCliAsync(strategy, counter);
 
        await auto.AspireNewAsync("TerminalSupportApp", counter, template: AspireTemplate.ExpressReact);
 
        var appHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "TerminalSupportApp", "apphost.mts");
        var appHostContent = File.ReadAllText(appHostPath);
        var oldFrontendLine = ".waitFor(app);";
        var newFrontendLine = """
            .waitFor(app)
                .withTerminal();
            """;
 
        if (!appHostContent.Contains(oldFrontendLine, StringComparison.Ordinal))
        {
            throw new InvalidOperationException($"Could not find '{oldFrontendLine}' in {appHostPath}.");
        }
 
        File.WriteAllText(appHostPath, appHostContent.Replace(oldFrontendLine, newFrontendLine, StringComparison.Ordinal));
 
        await auto.TypeAsync("cd TerminalSupportApp");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter);
 
        await auto.AspireStartAsync(counter);
 
        // Keep calling `aspire terminal ps` until the frontend row is reported alive.
        await auto.RunCommandAsync(
            "for i in $(seq 1 120); do aspire terminal ps | grep -Eq 'frontend.*alive' && break; sleep 1; done; aspire terminal ps | grep -Eq 'frontend.*alive'",
            counter,
            TimeSpan.FromMinutes(3));
 
        await auto.ClearScreenAsync(counter);
 
        await auto.TypeAsync("aspire terminal attach frontend");
        await auto.EnterAsync();
        await auto.WaitUntilTextAsync("Ctrl+B D", timeout: TimeSpan.FromSeconds(30));
        await auto.WaitAsync(3000);
 
        await auto.TypeAsync("h");
        await auto.EnterAsync();
        await auto.WaitUntilAsync(
            s => s.ContainsText("Shortcuts") || s.ContainsText("press r + enter to restart the server"),
            timeout: TimeSpan.FromSeconds(30),
            description: "waiting for Vite help shortcuts");
 
        // Keep an explicit pause between chord keys to mirror interactive input cadence.
        await auto.Ctrl().KeyAsync(Hex1bKey.B);
        await auto.WaitAsync(1000);
        await auto.KeyAsync(Hex1bKey.D);
        await auto.WaitForSuccessPromptAsync(counter, timeout: TimeSpan.FromSeconds(30));
 
        await auto.AspireStopAsync(counter);
    }
}