File: Pages\TerminalWindowTests.cs
Web Access
Project: src\tests\Aspire.Dashboard.Components.Tests\Aspire.Dashboard.Components.Tests.csproj (Aspire.Dashboard.Components.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.Threading.Channels;
using Aspire.Dashboard.Components.Controls;
using Aspire.Dashboard.Components.Pages;
using Aspire.Dashboard.Components.Tests.Shared;
using Aspire.Dashboard.Tests.Shared;
using Aspire.DashboardService.Proto.V1;
using Bunit;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.JSInterop;
using Xunit;
 
namespace Aspire.Dashboard.Components.Tests.Pages;
 
public class TerminalWindowTests : DashboardTestContext
{
    [Theory]
    [InlineData(true)]
    [InlineData(false)]
    public async Task CoordinatedWindow_ChecksGenerationBeforeMountingAndDoesNotRevokeOnDisposal(bool stillDetached)
    {
        TerminalSetupHelpers.SetupTerminalComponents(this, new TestDashboardClient());
        var module = TerminalSetupHelpers.SetupTerminalWindows(this);
        var registration = module.Setup<bool>("registerDetachedTerminalWindow", _ => true);
        Services.GetRequiredService<NavigationManager>().NavigateTo(
            "terminal-window/apphost/terminal?fontSize=23&windowOwner=owner&windowGeneration=generation");
        var cut = Render<TerminalWindow>(builder => builder.Add(p => p.TerminalId, "terminal"));
        Assert.Empty(cut.FindComponents<TerminalView>());
        Assert.Equal([], JSInterop.Invocations.Where(i => i.Identifier == "initTerminal"));
        Assert.Single(registration.Invocations);
 
        registration.SetResult(stillDetached);
        cut.WaitForAssertion(() =>
        {
            if (stillDetached)
            {
                Assert.Equal(23, cut.FindComponent<TerminalView>().Instance.InitialFontSize);
            }
            else
            {
                Assert.Empty(cut.FindComponents<TerminalView>());
                Assert.Single(cut.FindAll(".terminal-window-ended"));
            }
        });
        await cut.InvokeAsync(() => cut.Instance.DisposeAsync().AsTask()).DefaultTimeout();
        Assert.Single(module.Invocations, i => i.Identifier ==
            (stillDetached ? "unregisterDetachedTerminalWindow" : "releaseDetachedTerminalWindow"));
        Assert.Equal([], JSInterop.Invocations.Where(i => i.Identifier == "closeTerminalWindow"));
    }
 
    [Fact]
    public async Task CoordinatedWindow_RevocationRejectsStaleRegistrationAndRemovesCurrentViewer()
    {
        TerminalSetupHelpers.SetupTerminalComponents(this, new TestDashboardClient());
        Services.GetRequiredService<NavigationManager>().NavigateTo(
            "terminal-window/apphost/terminal?windowOwner=owner&windowGeneration=generation");
        var cut = Render<TerminalWindow>(builder => builder.Add(p => p.TerminalId, "terminal"));
        cut.WaitForAssertion(() => Assert.Single(cut.FindComponents<TerminalView>()));
        var registration = Assert.Single(JSInterop.Invocations, i => i.Identifier == "registerDetachedTerminalWindow");
        var id = Assert.IsType<string>(registration.Arguments[0]);
        await cut.InvokeAsync(() => cut.Instance.OnDetachedTerminalWindowRevokedAsync("obsolete-registration"));
        Assert.Single(cut.FindComponents<TerminalView>());
        await cut.InvokeAsync(() => cut.Instance.OnDetachedTerminalWindowRevokedAsync(id));
        cut.WaitForAssertion(() => Assert.Empty(cut.FindComponents<TerminalView>()));
        Assert.Single(JSInterop.Invocations, i => i.Identifier == "releaseDetachedTerminalWindow");
    }
 
    [Fact]
    public void CoordinatedWindow_StorageFailureDoesNotMountViewer()
    {
        TerminalSetupHelpers.SetupTerminalComponents(this, new TestDashboardClient());
        var module = TerminalSetupHelpers.SetupTerminalWindows(this);
        module.Setup<bool>("registerDetachedTerminalWindow", _ => true).SetException(new JSException("Storage denied"));
        Services.GetRequiredService<NavigationManager>().NavigateTo(
            "terminal-window/apphost/terminal?windowOwner=owner&windowGeneration=generation");
        var cut = Render<TerminalWindow>(builder => builder.Add(p => p.TerminalId, "terminal"));
        cut.WaitForAssertion(() => Assert.Equal(Resources.TerminalStrings.TerminalWindowTrackingFailed,
            cut.Find(".terminal-window-ended").TextContent));
        Assert.Empty(cut.FindComponents<TerminalView>());
        Assert.Equal([], JSInterop.Invocations.Where(i => i.Identifier == "initTerminal"));
    }
 
    [Theory]
    [InlineData("", "terminal", "terminal")]
    [InlineData("/aspire/nested", "terminal", "terminal")]
    [InlineData("", "terminal #1/?%+", "terminal%20%231%2F%3F%25%2B")]
    [InlineData("/aspire/nested", "terminal #1/?%+", "terminal%20%231%2F%3F%25%2B")]
    public void AppHostTerminalEndpoint_UsesDashboardBaseUri(string pathBase, string terminalId, string escapedTerminalId)
    {
        var updates = Channel.CreateUnbounded<WatchTerminalsUpdate>();
        Services.AddSingleton<NavigationManager>(new TestNavigationManager($"https://dashboard.example{pathBase}/"));
        TerminalSetupHelpers.SetupTerminalComponents(this, new TestDashboardClient(terminalChannelProvider: () => updates), pathBase);
        Services.GetRequiredService<NavigationManager>().NavigateTo($"terminal-window/apphost/{escapedTerminalId}");
 
        var cut = Render<TerminalWindow>(builder => builder.Add(p => p.TerminalId, terminalId));
 
        cut.WaitForAssertion(() => TerminalSetupHelpers.AssertSingleTerminalConnection(this,
            $"wss://dashboard.example{pathBase}/api/apphost-terminal?terminalId={escapedTerminalId}"));
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public void OpeningWindow_AutoFitsUsingFontFromQuery(bool appHost)
    {
        var updates = Channel.CreateUnbounded<WatchTerminalsUpdate>();
        TerminalSetupHelpers.SetupTerminalComponents(this, new TestDashboardClient(terminalChannelProvider: () => updates));
        var path = appHost ? "/terminal-window/apphost/terminal" : "/terminal-window/resource/shell/2";
        Services.GetRequiredService<NavigationManager>().NavigateTo($"{path}?fontSize=19");
        var cut = Render<TerminalWindow>(builder => builder
            .Add(p => p.TerminalId, appHost ? "terminal" : null)
            .Add(p => p.ResourceName, appHost ? null : "shell")
            .Add(p => p.ReplicaIndex, appHost ? 0 : 2));
 
        var terminal = cut.FindComponent<TerminalView>().Instance;
        Assert.True(terminal.AutoFit);
        Assert.True(terminal.Chromeless);
        Assert.True(terminal.ShowDimensionsPicker);
        Assert.Equal(19, terminal.InitialFontSize);
        var options = Assert.IsType<TerminalViewOptions>(
            Assert.Single(JSInterop.Invocations, i => i.Identifier == "initTerminal").Arguments[3]);
        Assert.True(options.AutoFit);
        Assert.Equal(19, options.InitialFontSize);
    }
 
    [Fact]
    public async Task SameRoute_PreservesTitleEndedStateAndSubscription()
    {
        var updates = Channel.CreateUnbounded<WatchTerminalsUpdate>();
        var client = new TestDashboardClient(terminalChannelProvider: () => updates);
        TerminalSetupHelpers.SetupTerminalComponents(this, client);
        var head = Render<HeadOutlet>();
        var cut = Render<TerminalWindow>(builder => builder.Add(p => p.TerminalId, "terminal"));
        await updates.Writer.WriteAsync(TerminalSetupHelpers.Change(TerminalChangeType.Retitled, "terminal", "Shell"));
        head.WaitForAssertion(() => Assert.Equal("Shell", head.Find("title").TextContent));
 
        cut.Render(builder => builder
            .Add(p => p.TerminalId, "terminal")
            .Add(p => p.ResourceName, "unused")
            .Add(p => p.ReplicaIndex, 3));
        Assert.Equal("Shell", head.Find("title").TextContent);
        Assert.Equal(1, client.TerminalSubscriptionCount);
 
        await updates.Writer.WriteAsync(TerminalSetupHelpers.Change(TerminalChangeType.Removed, "terminal"));
        cut.WaitForAssertion(() => Assert.Equal("This terminal has ended.", cut.Find(".terminal-window-ended").TextContent));
        cut.Render(builder => builder.Add(p => p.TerminalId, "terminal"));
        Assert.Equal("This terminal has ended.", cut.Find(".terminal-window-ended").TextContent);
        Assert.Equal(1, client.ActiveTerminalSubscriptionCount);
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task AppHostRouteChange_ReplacesWatchAndResetsEndedState(bool firstTerminalEnded)
    {
        var firstUpdates = Channel.CreateUnbounded<WatchTerminalsUpdate>();
        var secondUpdates = Channel.CreateUnbounded<WatchTerminalsUpdate>();
        var subscriptions = 0;
        var client = new TestDashboardClient(terminalChannelProvider: () =>
            Interlocked.Increment(ref subscriptions) == 1 ? firstUpdates : secondUpdates);
        TerminalSetupHelpers.SetupTerminalComponents(this, client);
        var head = Render<HeadOutlet>();
        var cut = Render<TerminalWindow>(builder => builder.Add(p => p.TerminalId, "first"));
        await firstUpdates.Writer.WriteAsync(TerminalSetupHelpers.Change(TerminalChangeType.Retitled, "first", "First shell"));
        head.WaitForAssertion(() => Assert.Equal("First shell", head.Find("title").TextContent));
        if (firstTerminalEnded)
        {
            await firstUpdates.Writer.WriteAsync(TerminalSetupHelpers.Change(TerminalChangeType.Removed, "first"));
            cut.WaitForAssertion(() => Assert.Single(cut.FindAll(".terminal-window-ended")));
        }
 
        await SetTerminalAsync(cut, "second").DefaultTimeout();
        // Starting the background watch does not render, so observe its counters independently.
        await AsyncTestHelpers.AssertIsTrueRetryAsync(
            () => client.TerminalSubscriptionCount == 2 && client.ActiveTerminalSubscriptionCount == 1,
            "The replacement terminal subscription did not start.");
        cut.WaitForAssertion(() =>
        {
            Assert.Equal(2, client.TerminalSubscriptionCount);
            Assert.Equal(1, client.ActiveTerminalSubscriptionCount);
            Assert.Equal("api/apphost-terminal?terminalId=second", cut.FindComponent<TerminalView>().Instance.EndpointPathAndQuery);
            Assert.Equal("second", head.Find("title").TextContent);
            Assert.Empty(cut.FindAll(".terminal-window-ended"));
        });
        await secondUpdates.Writer.WriteAsync(TerminalSetupHelpers.Change(TerminalChangeType.Retitled, "second", "Second shell"));
        head.WaitForAssertion(() => Assert.Equal("Second shell", head.Find("title").TextContent));
 
        await cut.InvokeAsync(() => cut.Instance.DisposeAsync().AsTask()).DefaultTimeout();
        Assert.Equal(0, client.ActiveTerminalSubscriptionCount);
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task ResourceRoute_CancelsAppHostWatchAndResetsRouteState(bool firstTerminalEnded)
    {
        var updates = Channel.CreateUnbounded<WatchTerminalsUpdate>();
        var client = new TestDashboardClient(terminalChannelProvider: () => updates);
        TerminalSetupHelpers.SetupTerminalComponents(this, client);
        var head = Render<HeadOutlet>();
        var cut = Render<TerminalWindow>(builder => builder.Add(p => p.TerminalId, "terminal"));
        await updates.Writer.WriteAsync(TerminalSetupHelpers.Change(TerminalChangeType.Retitled, "terminal", "Shell"));
        head.WaitForAssertion(() => Assert.Equal("Shell", head.Find("title").TextContent));
        if (firstTerminalEnded)
        {
            await updates.Writer.WriteAsync(TerminalSetupHelpers.Change(TerminalChangeType.Removed, "terminal"));
            cut.WaitForAssertion(() => Assert.Single(cut.FindAll(".terminal-window-ended")));
        }
 
        cut.Render(builder => builder
            .Add(p => p.TerminalId, null)
            .Add(p => p.ResourceName, "resource")
            .Add(p => p.ReplicaIndex, 2));
        cut.WaitForAssertion(() =>
        {
            Assert.Equal(0, client.ActiveTerminalSubscriptionCount);
            Assert.Empty(cut.FindAll(".terminal-window-ended"));
            var terminal = cut.FindComponent<TerminalView>().Instance;
            Assert.Null(terminal.EndpointPathAndQuery);
            Assert.Equal("resource", terminal.ResourceName);
            Assert.Equal(2, terminal.ReplicaIndex);
            Assert.Equal("resource #2", head.Find("title").TextContent);
        });
 
        cut.Render(builder => builder.Add(p => p.ReplicaIndex, 3));
        Assert.Equal("resource #3", head.Find("title").TextContent);
        Assert.Equal(3, cut.FindComponent<TerminalView>().Instance.ReplicaIndex);
        Assert.Equal(1, client.TerminalSubscriptionCount);
 
        await SetTerminalAsync(cut, "next").DefaultTimeout();
        await AsyncTestHelpers.AssertIsTrueRetryAsync(
            () => client.TerminalSubscriptionCount == 2,
            "The terminal subscription did not restart after leaving the resource route.");
        Assert.Equal(2, client.TerminalSubscriptionCount);
        await updates.Writer.WriteAsync(TerminalSetupHelpers.Change(TerminalChangeType.Retitled, "next", "Next shell"));
        head.WaitForAssertion(() => Assert.Equal("Next shell", head.Find("title").TextContent));
        Assert.Equal("api/apphost-terminal?terminalId=next", cut.FindComponent<TerminalView>().Instance.EndpointPathAndQuery);
    }
 
    [Theory]
    [InlineData(false, TerminalChangeType.Removed)]
    [InlineData(false, TerminalChangeType.Retitled)]
    [InlineData(true, TerminalChangeType.Removed)]
    [InlineData(true, TerminalChangeType.Retitled)]
    public async Task RapidRouteChanges_IgnoreOldUpdatesAndOnlyWatchLatest(bool returnToFirst, TerminalChangeType changeType)
    {
        var firstUpdates = Channel.CreateUnbounded<WatchTerminalsUpdate>();
        var latestUpdates = Channel.CreateUnbounded<WatchTerminalsUpdate>();
        var subscriptions = 0;
        var delayedUpdate = TerminalSetupHelpers.Change(changeType, "first", "Stale title");
        var updateReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var releaseUpdate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var client = new TestDashboardClient(terminalChannelProvider: () =>
            Interlocked.Increment(ref subscriptions) == 1 ? firstUpdates : latestUpdates)
        {
            BeforeTerminalUpdateAsync = update =>
            {
                if (ReferenceEquals(update, delayedUpdate))
                {
                    updateReceived.TrySetResult();
                    return releaseUpdate.Task;
                }
 
                return Task.CompletedTask;
            }
        };
        TerminalSetupHelpers.SetupTerminalComponents(this, client);
        var head = Render<HeadOutlet>();
        var cut = Render<TerminalWindow>(builder => builder.Add(p => p.TerminalId, "first"));
 
        try
        {
            // Hold an already-received update across cancellation, including navigation back to the same ID.
            // This deterministically exercises stale delivery without sleeps or thread-pool timing assumptions.
            await firstUpdates.Writer.WriteAsync(delayedUpdate);
            await updateReceived.Task.DefaultTimeout();
            var intermediateRoute = SetTerminalAsync(cut, "intermediate");
            cut.WaitForAssertion(() => Assert.Equal("api/apphost-terminal?terminalId=intermediate",
                cut.FindComponent<TerminalView>().Instance.EndpointPathAndQuery));
            var latestId = returnToFirst ? "first" : "latest";
            var latestRoute = SetTerminalAsync(cut, latestId);
            cut.WaitForAssertion(() => Assert.Equal($"api/apphost-terminal?terminalId={latestId}",
                cut.FindComponent<TerminalView>().Instance.EndpointPathAndQuery));
            Assert.False(intermediateRoute.IsCompleted);
            Assert.False(latestRoute.IsCompleted);
            Assert.Equal(1, client.TerminalSubscriptionCount);
            Assert.Equal(latestId, head.Find("title").TextContent);
 
            releaseUpdate.TrySetResult();
            await Task.WhenAll(intermediateRoute, latestRoute).DefaultTimeout();
            // The replacement watch starts on a worker and doesn't render until it receives an update.
            // Observe that update before asserting subscription counts, which don't trigger a render themselves.
            await latestUpdates.Writer.WriteAsync(TerminalSetupHelpers.Change(TerminalChangeType.Retitled, latestId, "Latest title"));
            head.WaitForAssertion(() => Assert.Equal("Latest title", head.Find("title").TextContent));
            cut.WaitForAssertion(() =>
            {
                Assert.Equal(2, client.TerminalSubscriptionCount);
                Assert.Equal(1, client.ActiveTerminalSubscriptionCount);
                Assert.Single(cut.FindComponents<TerminalView>());
            });
        }
        finally
        {
            releaseUpdate.TrySetResult();
        }
    }
 
    [Fact]
    public async Task DisposeDuringRouteChange_JoinsOldWatchWithoutStartingReplacement()
    {
        var updates = Channel.CreateUnbounded<WatchTerminalsUpdate>();
        var updateReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var releaseUpdate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var client = new TestDashboardClient(terminalChannelProvider: () => updates)
        {
            BeforeTerminalUpdateAsync = _ =>
            {
                updateReceived.TrySetResult();
                return releaseUpdate.Task;
            }
        };
        TerminalSetupHelpers.SetupTerminalComponents(this, client);
        var cut = Render<TerminalWindow>(builder => builder.Add(p => p.TerminalId, "first"));
 
        try
        {
            await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot());
            await updateReceived.Task.DefaultTimeout();
            var routeChange = SetTerminalAsync(cut, "next");
            cut.WaitForAssertion(() => Assert.Equal("api/apphost-terminal?terminalId=next",
                cut.FindComponent<TerminalView>().Instance.EndpointPathAndQuery));
            var disposal = cut.InvokeAsync(() => cut.Instance.DisposeAsync().AsTask());
            Assert.False(disposal.IsCompleted);
 
            releaseUpdate.TrySetResult();
            await Task.WhenAll(routeChange, disposal).DefaultTimeout();
            Assert.Equal(0, client.ActiveTerminalSubscriptionCount);
            Assert.Equal(1, client.TerminalSubscriptionCount);
        }
        finally
        {
            releaseUpdate.TrySetResult();
        }
    }
 
    [Fact]
    public async Task RecoverySnapshot_RemovesMissingTerminalAndDisposalCancelsWatch()
    {
        var updates = Channel.CreateUnbounded<WatchTerminalsUpdate>();
        var client = new TestDashboardClient(terminalChannelProvider: () => updates);
        TerminalSetupHelpers.SetupTerminalComponents(this, client);
        var cut = Render<TerminalWindow>(builder => builder.Add(p => p.TerminalId, "terminal"));
        await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("terminal"));
        cut.WaitForAssertion(() => Assert.Single(cut.FindComponents<TerminalView>()));
 
        await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot());
        cut.WaitForAssertion(() =>
        {
            Assert.Empty(cut.FindComponents<TerminalView>());
            Assert.Single(cut.FindAll(".terminal-window-ended"));
        });
 
        await cut.InvokeAsync(() => cut.Instance.DisposeAsync().AsTask()).DefaultTimeout();
        Assert.Equal(0, client.ActiveTerminalSubscriptionCount);
    }
 
    private static Task SetTerminalAsync(IRenderedComponent<TerminalWindow> component, string terminalId)
        => component.InvokeAsync(() => component.Instance.SetParametersAsync(ParameterView.FromDictionary(
            new Dictionary<string, object?> { [nameof(TerminalWindow.TerminalId)] = terminalId })));
}