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.Model;
using Aspire.Dashboard.Tests.Shared;
using Aspire.DashboardService.Proto.V1;
using Aspire.Tests.Shared.DashboardModel;
using Bunit;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.FluentUI.AspNetCore.Components;
using Microsoft.JSInterop;
using Xunit;
using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons;
using IconVariant = Microsoft.FluentUI.AspNetCore.Components.IconVariant;
 
namespace Aspire.Dashboard.Components.Tests.Pages;
 
public class TerminalWindowTests : DashboardTestContext
{
    [Theory]
    [InlineData("shell")]
    [InlineData("shell-instance")]
    public void InitialResources_WaitsBeforeResolvingResourceWithoutSubscription(string resourceName)
    {
        var ready = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var resources = new List<ResourceViewModel>();
        var client = new TestDashboardClient(isEnabled: true, initialResources: resources, whenResourcesReady: ready.Task);
        TerminalSetupHelpers.SetupTerminalComponents(this, client);
        var cut = Render<TerminalWindow>(builder => builder.Add(p => p.ResourceName, resourceName));
 
        Assert.True(client.WhenConnected.IsCompleted);
        Assert.Empty(cut.FindComponents<TerminalView>());
        Assert.Empty(cut.FindAll(".terminal-window-ended"));
        Assert.Equal(0, client.GetResourceCallCount);
 
        resources.Add(TerminalSetupHelpers.CreateTerminalResource("shell-instance", displayName: "shell"));
        ready.SetResult();
        cut.WaitForAssertion(() =>
        {
            Assert.Equal("shell-instance", cut.FindComponent<TerminalView>().Instance.ResourceName);
            AssertResourceIcon(cut, "Box", IconVariant.Filled);
        });
        cut.Render();
        Assert.Equal(1, client.GetResourceCallCount);
        Assert.Equal(resourceName == "shell" ? 1 : 0, client.GetResourcesCallCount);
        Assert.Equal(0, client.ResourceSubscriptionCount);
        TerminalSetupHelpers.AssertSingleTerminalConnection(this, "ws://localhost/api/terminal?resource=shell-instance");
    }
 
    [Fact]
    public void InitialResources_EmptySnapshotShowsEndedOnlyAfterReadiness()
    {
        var ready = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var client = new TestDashboardClient(isEnabled: true, whenResourcesReady: ready.Task);
        TerminalSetupHelpers.SetupTerminalComponents(this, client);
        var cut = Render<TerminalWindow>(builder => builder.Add(p => p.ResourceName, "shell"));
 
        Assert.Empty(cut.FindAll(".terminal-window-ended"));
        ready.SetResult();
        cut.WaitForAssertion(() => Assert.Equal(Resources.TerminalStrings.TerminalWindowEnded,
            cut.Find(".terminal-window-ended").TextContent));
        Assert.Empty(cut.FindComponents<TerminalView>());
        Assert.Equal(0, client.ResourceSubscriptionCount);
    }
 
    [Fact]
    public void RerenderDuringInitialResources_ResolvesResourceOnlyOnce()
    {
        var ready = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var client = new TestDashboardClient(isEnabled: true, whenResourcesReady: ready.Task,
            initialResources:
            [
                TerminalSetupHelpers.CreateTerminalResource("shell")
            ]);
        TerminalSetupHelpers.SetupTerminalComponents(this, client);
        var cut = Render<TerminalWindow>(builder => builder.Add(p => p.ResourceName, "shell"));
        cut.Render();
 
        Assert.Empty(cut.FindComponents<TerminalView>());
        Assert.Equal(0, client.GetResourceCallCount);
        ready.SetResult();
        cut.WaitForAssertion(() => Assert.Equal("shell",
            cut.FindComponent<TerminalView>().Instance.ResourceName));
        Assert.Equal(1, client.GetResourceCallCount);
        Assert.Equal(0, client.ResourceSubscriptionCount);
    }
 
    [Fact]
    public void AppHostInitialization_DoesNotWaitForResourceReadiness()
    {
        var ready = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var client = new TestDashboardClient(isEnabled: true, whenResourcesReady: ready.Task,
            terminalChannelProvider: () => Channel.CreateUnbounded<WatchTerminalsUpdate>());
        TerminalSetupHelpers.SetupTerminalComponents(this, client);
        var cut = Render<TerminalWindow>(builder => builder.Add(p => p.TerminalId, "dock"));
 
        Assert.False(ready.Task.IsCompleted);
        Assert.Equal("api/apphost-terminal?terminalId=dock", cut.FindComponent<TerminalView>().Instance.EndpointPathAndQuery);
        Assert.Equal(0, client.GetResourceCallCount);
        Assert.Equal(0, client.ResourceSubscriptionCount);
    }
 
    [Fact]
    public async Task DisposalDuringInitialResources_DoesNotResolveOrMountViewer()
    {
        var ready = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var client = new TestDashboardClient(isEnabled: true, whenResourcesReady: ready.Task,
            initialResources: [TerminalSetupHelpers.CreateTerminalResource("shell")]);
        TerminalSetupHelpers.SetupTerminalComponents(this, client);
        var cut = Render<TerminalWindow>(builder => builder.Add(p => p.ResourceName, "shell"));
        var renderCount = cut.RenderCount;
 
        await cut.InvokeAsync(() => cut.Instance.DisposeAsync().AsTask()).DefaultTimeout();
        ready.SetResult();
        cut.WaitForState(() => cut.RenderCount > renderCount);
        Assert.False(Renderer.UnhandledException.IsCompleted);
        Assert.Empty(cut.FindComponents<TerminalView>());
        Assert.Equal(0, client.GetResourceCallCount);
        Assert.Equal(0, client.ResourceSubscriptionCount);
    }
 
    [Theory]
    [InlineData(null, IconVariant.Filled, "Box")]
    [InlineData("Database", IconVariant.Regular, "Database")]
    [InlineData("Database", IconVariant.Filled, "Database")]
    [InlineData("UnknownIcon", IconVariant.Filled, "Box")]
    public void ResourceIcon_LoadsSelectedReplicaOnceWithoutSubscription(string? iconName, IconVariant iconVariant, string expectedIconName)
    {
        var resource = ModelTestHelpers.CreateResource("shell-second", displayName: "shell",
            properties: TerminalSetupHelpers.CreateTerminalResource("shell-second", replicaIndex: 1, replicaCount: 2).Properties.ToDictionary(),
            iconName: iconName, iconVariant: iconVariant);
        var client = new TestDashboardClient(isEnabled: true, initialResources:
        [
            TerminalSetupHelpers.CreateTerminalResource("shell-first", replicaIndex: 0, replicaCount: 2, displayName: "shell"),
            resource
        ]);
        TerminalSetupHelpers.SetupTerminalComponents(this, client);
        var cut = Render<TerminalWindow>(builder => builder.Add(p => p.ResourceName, "shell-second"));
 
        AssertResourceIcon(cut, expectedIconName, iconVariant);
        Assert.Equal("shell-second", cut.FindComponent<TerminalView>().Instance.ResourceName);
        Assert.Equal(1, client.GetResourceCallCount);
        Assert.Equal(0, client.GetResourcesCallCount);
        cut.Render();
        Assert.Equal(1, client.GetResourceCallCount);
        Assert.Equal(0, client.ResourceSubscriptionCount);
    }
 
    [Theory]
    [InlineData("shell-single")]
    [InlineData("shell-first")]
    [InlineData("shell-second")]
    public void ResourceName_UsesInstanceIdForTerminalConnection(string resourceName)
    {
        var client = new TestDashboardClient(isEnabled: true, initialResources:
        [
            TerminalSetupHelpers.CreateTerminalResource("shell-single", displayName: "shell"),
            TerminalSetupHelpers.CreateTerminalResource("shell-first", displayName: "shell", replicaCount: 8),
            TerminalSetupHelpers.CreateTerminalResource("shell-second", displayName: "shell", replicaIndex: 7, replicaCount: 8)
        ]);
        TerminalSetupHelpers.SetupTerminalComponents(this, client);
        var cut = Render<TerminalWindow>(builder => builder.Add(p => p.ResourceName, resourceName));
 
        Assert.Equal(resourceName, cut.FindComponent<TerminalView>().Instance.ResourceName);
        TerminalSetupHelpers.AssertSingleTerminalConnection(this,
            $"ws://localhost/api/terminal?resource={resourceName}");
        Assert.Equal(1, client.GetResourceCallCount);
        Assert.Equal(0, client.GetResourcesCallCount);
        Assert.Equal(0, client.ResourceSubscriptionCount);
    }
 
    [Fact]
    public void ResourceWithoutReplicaMetadata_ConnectsByInstanceName()
    {
        var resource = TerminalSetupHelpers.CreateTerminalResource("shell-instance", displayName: "shell");
        resource = ModelTestHelpers.CreateResource("shell-instance", displayName: "shell",
            properties: resource.Properties
                .Where(p => p.Key is not KnownProperties.Terminal.ReplicaIndex and not KnownProperties.Terminal.ReplicaCount)
                .ToDictionary());
        var client = new TestDashboardClient(isEnabled: true, initialResources: [resource]);
        TerminalSetupHelpers.SetupTerminalComponents(this, client);
        var cut = Render<TerminalWindow>(builder => builder.Add(p => p.ResourceName, "shell-instance"));
 
        Assert.Equal("shell-instance", cut.FindComponent<TerminalView>().Instance.ResourceName);
        Assert.Equal("shell-instance", cut.Find(".terminal-title").TextContent);
        TerminalSetupHelpers.AssertSingleTerminalConnection(this, "ws://localhost/api/terminal?resource=shell-instance");
        Assert.Equal(1, client.GetResourceCallCount);
        Assert.Equal(0, client.GetResourcesCallCount);
        Assert.Equal(0, client.ResourceSubscriptionCount);
    }
 
    [Theory]
    [InlineData("shell")]
    [InlineData("shell #1/?%+")]
    public void SingletonDisplayName_ResolvesCurrentInstanceOnceWithoutSubscription(string displayName)
    {
        var resource = TerminalSetupHelpers.CreateTerminalResource("shell-instance", displayName: displayName);
        var client = new TestDashboardClient(isEnabled: true, initialResources: [resource]);
        TerminalSetupHelpers.SetupTerminalComponents(this, client);
        var cut = Render<TerminalWindow>(builder => builder.Add(p => p.ResourceName, displayName));
 
        Assert.Equal("shell-instance", cut.FindComponent<TerminalView>().Instance.ResourceName);
        Assert.Equal(displayName, cut.Find(".terminal-title").TextContent);
        TerminalSetupHelpers.AssertSingleTerminalConnection(this, "ws://localhost/api/terminal?resource=shell-instance");
        Assert.Equal(1, client.GetResourceCallCount);
        Assert.Equal(1, client.GetResourcesCallCount);
        cut.Render();
        Assert.Equal(1, client.GetResourceCallCount);
        Assert.Equal(1, client.GetResourcesCallCount);
        Assert.Equal(0, client.ResourceSubscriptionCount);
    }
 
    [Fact]
    public void SingletonDisplayName_ReloadResolvesNewInstanceAfterRestart()
    {
        var resources = new List<ResourceViewModel>
        {
            TerminalSetupHelpers.CreateTerminalResource("shell-before", displayName: "shell")
        };
        var client = new TestDashboardClient(isEnabled: true, initialResources: resources);
        TerminalSetupHelpers.SetupTerminalComponents(this, client);
        var navigation = Services.GetRequiredService<NavigationManager>();
        navigation.NavigateTo("terminal-window/resource/shell");
        var cut = Render<TerminalWindow>(builder => builder.Add(p => p.ResourceName, "shell"));
        Assert.Equal("shell-before", cut.FindComponent<TerminalView>().Instance.ResourceName);
 
        resources[0] = TerminalSetupHelpers.CreateTerminalResource("shell-after", displayName: "shell");
        cut.Render();
        Assert.Equal("shell-before", cut.FindComponent<TerminalView>().Instance.ResourceName);
        cut.Dispose();
        var reloaded = Render<TerminalWindow>(builder => builder.Add(p => p.ResourceName, "shell"));
 
        Assert.Equal("http://localhost/terminal-window/resource/shell", navigation.Uri);
        Assert.Equal("shell-after", reloaded.FindComponent<TerminalView>().Instance.ResourceName);
        Assert.Collection(JSInterop.Invocations.Where(i => i.Identifier == "initTerminal"),
            i => TerminalSetupHelpers.AssertBoundEndpoint("ws://localhost/api/terminal?resource=shell-before", i.Arguments[1]),
            i => TerminalSetupHelpers.AssertBoundEndpoint("ws://localhost/api/terminal?resource=shell-after", i.Arguments[1]));
        Assert.Equal(2, client.GetResourceCallCount);
        Assert.Equal(2, client.GetResourcesCallCount);
        Assert.Equal(0, client.ResourceSubscriptionCount);
    }
 
    [Theory]
    [InlineData(true)]
    [InlineData(false)]
    public void AmbiguousDisplayName_DoesNotAttachToTerminal(bool otherHasTerminal)
    {
        var client = new TestDashboardClient(isEnabled: true, initialResources:
        [
            TerminalSetupHelpers.CreateTerminalResource("shell-first", displayName: "shell"),
            otherHasTerminal
                ? TerminalSetupHelpers.CreateTerminalResource("shell-second", displayName: "shell")
                : ModelTestHelpers.CreateResource("shell-second", displayName: "shell")
        ]);
        TerminalSetupHelpers.SetupTerminalComponents(this, client);
        var cut = Render<TerminalWindow>(builder => builder.Add(p => p.ResourceName, "shell"));
 
        Assert.Equal(Resources.TerminalStrings.TerminalWindowEnded, cut.Find(".terminal-window-ended").TextContent);
        Assert.Empty(cut.FindComponents<TerminalView>());
        Assert.Equal([], JSInterop.Invocations.Where(i => i.Identifier == "initTerminal"));
        Assert.Equal(1, client.GetResourcesCallCount);
        Assert.Equal(0, client.ResourceSubscriptionCount);
    }
 
    [Theory]
    [InlineData("shell-deleted")]
    [InlineData("shell-unavailable")]
    public void UnavailableInstanceId_DoesNotAttachToAnotherReplica(string resourceName)
    {
        TerminalSetupHelpers.SetupTerminalComponents(this, new TestDashboardClient(isEnabled: true,
            initialResources:
            [
                TerminalSetupHelpers.CreateTerminalResource("shell-first", displayName: "shell"),
                ModelTestHelpers.CreateResource("shell-unavailable")
            ]));
        var cut = Render<TerminalWindow>(builder => builder.Add(p => p.ResourceName, resourceName));
 
        Assert.Equal(Resources.TerminalStrings.TerminalWindowEnded, cut.Find(".terminal-window-ended").TextContent);
        Assert.Empty(cut.FindComponents<TerminalView>());
        Assert.Equal([], JSInterop.Invocations.Where(i => i.Identifier == "initTerminal"));
    }
 
    [Theory]
    [InlineData(false, false)]
    [InlineData(true, true)]
    public void AppHostOrDisabledClient_DoesNotReadResources(bool appHost, bool isEnabled)
    {
        var terminals = Channel.CreateUnbounded<WatchTerminalsUpdate>();
        var client = new TestDashboardClient(isEnabled: isEnabled, terminalChannelProvider: () => terminals,
            initialResources: [TerminalSetupHelpers.CreateTerminalResource("shell")]);
        TerminalSetupHelpers.SetupTerminalComponents(this, client);
        var cut = Render<TerminalWindow>(builder => builder
            .Add(p => p.TerminalId, appHost ? "terminal" : null)
            .Add(p => p.ResourceName, "shell"));
 
        if (appHost)
        {
            Assert.IsType<Icons.Regular.Size20.WindowConsole>(
                cut.FindComponent<TerminalTitle>().FindComponent<FluentIcon<Icon>>().Instance.Value);
        }
        else
        {
            Assert.Equal(Resources.TerminalStrings.TerminalWindowEnded, cut.Find(".terminal-window-ended").TextContent);
            Assert.Empty(cut.FindComponents<TerminalView>());
        }
        Assert.Equal(0, client.GetResourceCallCount);
        Assert.Equal(0, client.GetResourcesCallCount);
        Assert.Equal(0, client.ResourceSubscriptionCount);
    }
 
    [Fact]
    public void ResourceIcon_RemainsSnapshotUntilReload()
    {
        var resources = new List<ResourceViewModel> { TerminalSetupHelpers.CreateTerminalResource("shell") };
        var client = new TestDashboardClient(isEnabled: true, initialResources: resources);
        TerminalSetupHelpers.SetupTerminalComponents(this, client);
        var cut = Render<TerminalWindow>(builder => builder.Add(p => p.ResourceName, "shell"));
        AssertResourceIcon(cut, "Box", IconVariant.Filled);
        Assert.Equal(1, client.GetResourceCallCount);
 
        resources[0] = ModelTestHelpers.CreateResource("shell",
            properties: resources[0].Properties.ToDictionary(), iconName: "Database", iconVariant: IconVariant.Regular);
        cut.Render();
        AssertResourceIcon(cut, "Box", IconVariant.Filled);
        Assert.Equal(1, client.GetResourceCallCount);
 
        cut.Dispose();
        var reloaded = Render<TerminalWindow>(builder => builder.Add(p => p.ResourceName, "shell"));
        AssertResourceIcon(reloaded, "Database", IconVariant.Regular);
        Assert.Equal(2, client.GetResourceCallCount);
 
        Assert.Equal(0, client.GetResourcesCallCount);
        Assert.Equal(0, client.ResourceSubscriptionCount);
    }
 
    [Fact]
    public async Task WorkloadMetadata_UpdatesWindowTitleAndTitlebar()
    {
        TerminalSetupHelpers.SetupTerminalComponents(this, new TestDashboardClient(isEnabled: true,
            initialResources: [TerminalSetupHelpers.CreateTerminalResource("shell")]));
        var head = Render<HeadOutlet>();
        var cut = Render<TerminalWindow>(builder => builder.Add(p => p.ResourceName, "shell"));
        var terminal = cut.FindComponent<TerminalView>().Instance;
        var state = new TerminalToolbarState
        {
            TerminalId = 1, Generation = 1, Connected = true,
            Title = "build", WorkingDirectory = "/work/app", ProgressState = "indeterminate"
        };
        await cut.InvokeAsync(() => terminal.OnTerminalStateChanged(state));
        Assert.Equal("build", head.Find("title").TextContent);
        Assert.Equal("build", cut.Find(".terminal-window-titlebar .terminal-title").TextContent);
        Assert.Equal("/work/app", cut.Find(".terminal-window-titlebar .terminal-directory").GetAttribute("data-text"));
        Assert.Single(cut.FindAll(".terminal-window-titlebar [role=progressbar]"));
 
        await cut.InvokeAsync(() => terminal.OnTerminalStateChanged(state with { Title = "", ProgressState = "none" }));
        Assert.Equal("shell", head.Find("title").TextContent);
        Assert.Equal("shell", cut.Find(".terminal-title").TextContent);
        Assert.Empty(cut.FindAll("[role=progressbar]"));
    }
 
    [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(isEnabled: true,
            initialResources: [TerminalSetupHelpers.CreateTerminalResource("shell-second", displayName: "shell", replicaIndex: 2, replicaCount: 3)],
            terminalChannelProvider: () => updates));
        var path = appHost ? "/terminal-window/apphost/terminal" : "/terminal-window/resource/shell-second";
        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-second"));
 
        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 Rerender_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();
        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();
        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 NewWindow_UsesOwnTerminalAndWatch(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 cut.InvokeAsync(() => cut.Instance.DisposeAsync().AsTask()).DefaultTimeout();
        cut.Dispose();
        cut = Render<TerminalWindow>(builder => builder.Add(p => p.TerminalId, "second"));
        await secondUpdates.Writer.WriteAsync(TerminalSetupHelpers.Change(TerminalChangeType.Retitled, "second", "Second shell"));
        head.WaitForAssertion(() => Assert.Equal("Second shell", head.Find("title").TextContent));
        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 shell", head.Find("title").TextContent);
            Assert.Empty(cut.FindAll(".terminal-window-ended"));
        });
        await cut.InvokeAsync(() => cut.Instance.DisposeAsync().AsTask()).DefaultTimeout();
        Assert.Equal(0, client.ActiveTerminalSubscriptionCount);
        Assert.Empty(client.ClosedTerminals);
    }
 
    [Fact]
    public async Task DisposalDuringPendingUpdate_JoinsWatchWithoutApplyingUpdate()
    {
        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 head = Render<HeadOutlet>();
        var cut = Render<TerminalWindow>(builder => builder.Add(p => p.TerminalId, "first"));
 
        try
        {
            await updates.Writer.WriteAsync(TerminalSetupHelpers.Change(TerminalChangeType.Retitled, "first", "Stale title"));
            await updateReceived.Task.DefaultTimeout();
            var disposal = cut.InvokeAsync(() => cut.Instance.DisposeAsync().AsTask());
            Assert.False(disposal.IsCompleted);
 
            releaseUpdate.TrySetResult();
            await disposal.DefaultTimeout();
            Assert.Equal(0, client.ActiveTerminalSubscriptionCount);
            Assert.Equal(1, client.TerminalSubscriptionCount);
            Assert.Equal("first", head.Find("title").TextContent);
            Assert.Empty(client.ClosedTerminals);
        }
        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 void AssertResourceIcon(IRenderedComponent<TerminalWindow> component, string name, IconVariant variant)
    {
        var icon = component.FindComponent<TerminalTitle>().FindComponent<FluentIcon<Icon>>().Instance.Value;
        Assert.Equal((name, IconSize.Size16, variant), (icon.Name, icon.Size, icon.Variant));
    }
 
}