File: Controls\TerminalViewTests.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 Aspire.Dashboard.Components.Controls;
using Aspire.Dashboard.Components.Tests.Shared;
using Aspire.Dashboard.Terminal;
using Bunit;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Localization;
using Microsoft.FluentUI.AspNetCore.Components;
using Microsoft.JSInterop;
using Xunit;
 
namespace Aspire.Dashboard.Components.Tests.Controls;
 
[UseCulture("en-US")]
public class TerminalViewTests : DashboardTestContext
{
    public TerminalViewTests()
    {
        FluentUISetupHelpers.AddCommonDashboardServices(this);
        FluentUISetupHelpers.SetupFluentUIComponents(this);
        FluentUISetupHelpers.SetupFluentButton(this);
    }
 
    [Fact]
    public void Initialization_ProvidesLocalizedCopyAndInputControlsWithoutHostSubscriber()
    {
        var module = TerminalSetupHelpers.SetupTerminalViewModule(this, "/Components/Controls/TerminalView.razor.js");
        var initialization = module.Setup<int>("initTerminal", _ => true);
        initialization.SetResult(1);
        var cut = Render<TerminalView>(builder => builder
            .Add(p => p.ResourceName, "shell")
            .Add(p => p.SizeMemoryKey, "dock:shell"));
        var loc = Services.GetRequiredService<IStringLocalizer<Resources.TerminalStrings>>();
        var controlsLoc = Services.GetRequiredService<IStringLocalizer<Resources.ControlsStrings>>();
 
        var button = cut.Find("div[hidden] .terminal-selection-copy");
        Assert.Equal(controlsLoc[nameof(Resources.ControlsStrings.GridValueCopyToClipboard)].Value, button.GetAttribute("aria-label"));
        Assert.Equal(controlsLoc[nameof(Resources.ControlsStrings.GridValueCopyToClipboard)].Value, button.GetAttribute("title"));
        Assert.Single(button.QuerySelectorAll("svg"));
        var invocation = Assert.Single(initialization.Invocations);
        Assert.IsType<DotNetObjectReference<TerminalView>>(invocation.Arguments[2]);
        var options = Assert.IsType<TerminalViewOptions>(invocation.Arguments[3]);
        Assert.Equal("dock:shell", options.SizeMemoryKey);
        Assert.Equal(loc[nameof(Resources.TerminalStrings.TerminalInputLabel)].Value, options.Label);
        Assert.IsType<ElementReference>(invocation.Arguments[4]);
        Assert.IsType<ElementReference>(invocation.Arguments[5]);
        var registry = Services.GetRequiredService<TerminalViewSessionRegistry>();
        var viewId = TerminalSetupHelpers.GetTerminalViewId(invocation.Arguments[1]);
        Assert.True(registry.TryGet(viewId, new Uri(Assert.IsType<string>(invocation.Arguments[1])).PathAndQuery, out _));
    }
 
    [Theory]
    [InlineData(false, true)]
    [InlineData(true, true)]
    [InlineData(true, false)]
    public void ChromeAndFooter_RespectSurfaceParameters(bool chromeless, bool showDimensions)
    {
        TerminalSetupHelpers.SetupTerminalView(this);
        var cut = Render<TerminalView>(builder => builder
            .Add(p => p.ResourceName, "shell")
            .Add(p => p.Chromeless, chromeless)
            .Add(p => p.ShowDimensionsPicker, showDimensions));
        var loc = Services.GetRequiredService<IStringLocalizer<Resources.TerminalStrings>>();
 
        Assert.Equal(chromeless, cut.Find(".terminal-view").ClassList.Contains("terminal-chromeless"));
        Assert.Equal(chromeless ? 0 : 1, cut.FindAll(".terminal-titlebar").Count);
        Assert.Equal(showDimensions ? 1 : 0, cut.FindAll(".terminal-size-select").Count);
        Assert.Equal(showDimensions ? 2 : 1, cut.FindAll(".terminal-select-label").Count);
        foreach (var label in cut.FindAll(".terminal-select-label"))
        {
            Assert.NotNull(cut.Find($"#{label.GetAttribute("for")}"));
        }
        Assert.Equal(showDimensions ? 1 : 0, cut.FindAll(".terminal-fit").Count);
        Assert.Single(cut.FindAll(".terminal-font-minus"));
        Assert.Single(cut.FindAll(".terminal-font-plus"));
        var focusHint = cut.Find(".terminal-focus-hint");
        Assert.Equal($"F6: {loc[nameof(Resources.TerminalStrings.TerminalFocusControlsDescription)].Value}", focusHint.TextContent);
        Assert.Equal("F6", focusHint.QuerySelector("kbd")?.TextContent);
        Assert.Equal(loc[nameof(Resources.TerminalStrings.TerminalToolbarDecreaseFontSize)].Value, cut.Find(".terminal-font-minus").GetAttribute("aria-label"));
        Assert.Equal(loc[nameof(Resources.TerminalStrings.TerminalToolbarIncreaseFontSize)].Value, cut.Find(".terminal-font-plus").GetAttribute("aria-label"));
        foreach (var button in cut.FindAll(".terminal-controls fluent-button"))
        {
            Assert.Single(button.QuerySelectorAll("svg"));
            Assert.False(string.IsNullOrEmpty(button.GetAttribute("aria-label")));
            Assert.Equal(button.GetAttribute("aria-label"), button.GetAttribute("title"));
        }
    }
 
    [Theory]
    [InlineData(false, true)]
    [InlineData(true, false)]
    public async Task PaletteSelector_UsesSavedStateAndRemainsAvailableWithoutResizeAuthority(bool readOnly, bool showDimensions)
    {
        var module = TerminalSetupHelpers.SetupTerminalViewModule(this, "/Components/Controls/TerminalView.razor.js");
        module.Setup<int>("initTerminal", _ => true).SetResult(1);
        var save = module.Setup<bool>("setPaletteFromHost", 1, "light");
        save.SetResult(true);
        var cut = Render<TerminalView>(builder => builder
            .Add(p => p.ResourceName, "shell")
            .Add(p => p.ReadOnly, readOnly)
            .Add(p => p.ShowDimensionsPicker, showDimensions));
        var state = new TerminalToolbarState { TerminalId = 1, Generation = 1, Palette = "dark" };
        await cut.InvokeAsync(() => cut.Instance.OnTerminalStateChanged(state));
        var select = cut.FindComponent<FluentSelect<string, string>>();
        Assert.Equal("dark", select.Instance.Value);
        Assert.False(select.Instance.Disabled);
        Assert.Equal(Resources.TerminalStrings.TerminalPalette, select.Instance.AriaLabel);
        Assert.Equal(["Light", "Dark"], select.Instance.Items!.Select(select.Instance.OptionText!));
        Assert.Equal("terminal-palette-select aspire-input", cut.Find(".terminal-controls").LastElementChild!.ClassName);
 
        await cut.InvokeAsync(() => select.Instance.ValueChanged.InvokeAsync("light"));
        Assert.Single(save.Invocations);
        await cut.InvokeAsync(() => cut.Instance.OnTerminalStateChanged(state with { Palette = "light" }));
        Assert.Equal("light", select.Instance.Value);
 
        await cut.InvokeAsync(() => cut.Instance.OnTerminalStateChanged(state with { Error = "palette-failed" }));
        Assert.Equal("dark", select.Instance.Value);
        Assert.Equal(Resources.TerminalStrings.TerminalPaletteSaveFailed, cut.Find("[role=alert]").TextContent);
        Assert.Equal(Resources.TerminalStrings.TerminalDismissError, cut.Find(".terminal-error fluent-button").TextContent.Trim());
        var failedSave = module.Setup<bool>("setPaletteFromHost", 1, "light");
        failedSave.SetResult(false);
        var originalSelect = select.Instance;
        await cut.InvokeAsync(() => select.Instance.ValueChanged.InvokeAsync("light"));
        var restoredSelect = cut.FindComponent<FluentSelect<string, string>>();
        Assert.NotSame(originalSelect, restoredSelect.Instance);
        Assert.Equal("dark", restoredSelect.Instance.Value);
    }
 
    [Fact]
    public void Footer_UsesLocalizedLabelsForTooltipsAndAccessibleNames()
    {
        TerminalSetupHelpers.SetupTerminalView(this);
        var cut = Render<TerminalView>();
        var loc = Services.GetRequiredService<IStringLocalizer<Resources.TerminalStrings>>();
 
        Assert.Collection(cut.FindAll(".terminal-controls fluent-button"),
            button =>
            {
                Assert.Equal(loc[nameof(Resources.TerminalStrings.TerminalToolbarDecreaseFontSize)].Value, button.GetAttribute("title"));
                Assert.Equal(loc[nameof(Resources.TerminalStrings.TerminalToolbarDecreaseFontSize)].Value, button.GetAttribute("aria-label"));
            },
            button =>
            {
                Assert.Equal(loc[nameof(Resources.TerminalStrings.TerminalToolbarIncreaseFontSize)].Value, button.GetAttribute("title"));
                Assert.Equal(loc[nameof(Resources.TerminalStrings.TerminalToolbarIncreaseFontSize)].Value, button.GetAttribute("aria-label"));
            },
            button =>
            {
                Assert.Equal(loc[nameof(Resources.TerminalStrings.TerminalToolbarGridSizeAuto)].Value, button.GetAttribute("title"));
                Assert.Equal(loc[nameof(Resources.TerminalStrings.TerminalToolbarGridSizeAuto)].Value, button.GetAttribute("aria-label"));
            });
        var dimensionsSelect = cut.FindComponent<FluentSelect<TerminalSizePreset, string>>();
        Assert.Equal(loc[nameof(Resources.TerminalStrings.TerminalToolbarGridSize)].Value, dimensionsSelect.Instance.Placeholder);
        Assert.Equal(loc[nameof(Resources.TerminalStrings.TerminalToolbarGridSize)].Value, dimensionsSelect.Instance.AriaLabel);
    }
 
    [Theory]
    [InlineData(false, false, false)]
    [InlineData(false, true, true)]
    [InlineData(true, false, false)]
    [InlineData(true, true, false)]
    public void NewWindowAction_IsOnlyOfferedInAnEnabledResourceTitlebar(bool chromeless, bool showOpenInWindow, bool expected)
    {
        TerminalSetupHelpers.SetupTerminalView(this);
        TerminalSetupHelpers.SetupTerminalWindows(this);
        var cut = Render<TerminalView>(builder => builder
            .Add(p => p.ResourceName, "shell")
            .Add(p => p.Chromeless, chromeless)
            .Add(p => p.ShowOpenInWindow, showOpenInWindow));
        Assert.Equal(expected ? 1 : 0, cut.FindAll(".terminal-titlebar .terminal-open-window").Count);
        if (expected)
        {
            Assert.True(cut.Find(".terminal-open-window").HasAttribute("disabled"));
        }
        cut.Render(builder => builder.Add(p => p.ResourceName, null));
        Assert.Empty(cut.FindAll(".terminal-open-window"));
        if (!chromeless)
        {
            Assert.Equal(Resources.TerminalStrings.TerminalTitle, cut.Find(".terminal-title").TextContent);
        }
    }
 
    [Theory]
    [InlineData(null, "shell-instance%20%231%2F%3F%25%2B", "resource:shell-instance #1/?%+")]
    [InlineData("shell #1/?%+", "shell%20%231%2F%3F%25%2B", "resource:shell #1/?%+")]
    public async Task NewWindowAction_PassesResourceIdentityAndFontWithoutIconInLaunchUrl(
        string? windowResourceName, string encodedWindowResourceName, string windowKey)
    {
        TerminalSetupHelpers.SetupTerminalView(this);
        TerminalSetupHelpers.SetupTerminalWindows(this);
        var cut = Render<TerminalView>(builder => builder
            .Add(p => p.ResourceName, "shell-instance #1/?%+")
            .Add(p => p.WindowResourceName, windowResourceName)
            .Add(p => p.InitialFontSize, 19)
            .Add(p => p.ShowOpenInWindow, true)
            .Add(p => p.ResourceIcon, new Microsoft.FluentUI.AspNetCore.Components.Icons.Filled.Size16.Database()));
 
        var expectedUrl = $"http://localhost/terminal-window/resource/{encodedWindowResourceName}?fontSize=19";
        Assert.Equal(expectedUrl,
            cut.Find(".terminal-open-window").GetAttribute("data-terminal-window-url"));
        Assert.Equal(windowKey,
            cut.Find(".terminal-open-window").GetAttribute("data-terminal-window-key"));
        Assert.Equal(windowResourceName ?? "shell-instance #1/?%+", cut.Find(".terminal-title").TextContent);
        TerminalSetupHelpers.AssertSingleTerminalConnection(this,
            "ws://localhost/api/terminal?resource=shell-instance%20%231%2F%3F%25%2B");
 
        cut.Render(builder => builder.Add(p => p.ResourceIcon, new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Database()));
        Assert.Equal(expectedUrl,
            cut.Find(".terminal-open-window").GetAttribute("data-terminal-window-url"));
 
        cut.Render(builder => builder.Add(p => p.ResourceIcon, null));
        Assert.Equal(expectedUrl,
            cut.Find(".terminal-open-window").GetAttribute("data-terminal-window-url"));
 
        await cut.InvokeAsync(() => cut.Instance.OnTerminalStateChanged(new TerminalToolbarState
        {
            TerminalId = 1, Generation = 1, Connected = true, Title = "renamed shell"
        }));
        Assert.Equal(expectedUrl,
            cut.Find(".terminal-open-window").GetAttribute("data-terminal-window-url"));
        Assert.Equal("renamed shell", cut.Find(".terminal-title").TextContent);
    }
 
    [Fact]
    public async Task Titlebar_UsesWorkloadMetadataAndHidesDisconnectedProgress()
    {
        var module = TerminalSetupHelpers.SetupTerminalViewModule(this, "/Components/Controls/TerminalView.razor.js");
        module.Setup<int>("initTerminal", _ => true).SetResult(1);
        var cut = Render<TerminalView>(builder => builder.Add(p => p.ResourceName, "shell"));
        var state = new TerminalToolbarState
        {
            TerminalId = 1, Generation = 2, Connected = true,
            Title = "building", WorkingDirectory = "/work/app",
            WorkingDirectoryUri = "file:///work/app", ProgressState = "normal", ProgressPercentage = 42
        };
        await cut.InvokeAsync(() => cut.Instance.OnTerminalStateChanged(state));
        Assert.Equal("building", cut.Find(".terminal-titlebar .terminal-title").TextContent);
        Assert.Equal("/work/app", cut.Find(".terminal-titlebar .terminal-directory").GetAttribute("data-text"));
        Assert.Equal("42", cut.Find(".terminal-titlebar [role=progressbar]").GetAttribute("aria-valuenow"));
        await cut.InvokeAsync(() => cut.Instance.OnTerminalStateChanged(state with { Generation = 1, Title = "stale" }));
        Assert.Equal("building", cut.Find(".terminal-title").TextContent);
        await cut.InvokeAsync(() => cut.Instance.OnTerminalStateChanged(state with { Connected = false }));
        Assert.Empty(cut.FindAll("[role=progressbar]"));
        Assert.Equal("building", cut.Find(".terminal-title").TextContent);
    }
 
    [Fact]
    public async Task InitialFontSize_SeedsMountWithoutResettingCurrentFont()
    {
        var module = TerminalSetupHelpers.SetupTerminalViewModule(this, "/Components/Controls/TerminalView.razor.js");
        module.Setup<int>("initTerminal", _ => true).SetResult(1);
        var cut = Render<TerminalView>(builder => builder
            .Add(p => p.ResourceName, "shell")
            .Add(p => p.InitialFontSize, 19));
        Assert.Equal(19, cut.Instance.FontSize);
        var options = Assert.IsType<TerminalViewOptions>(
            Assert.Single(module.Invocations, i => i.Identifier == "initTerminal").Arguments[3]);
        Assert.Equal(19, options.InitialFontSize);
 
        await cut.InvokeAsync(() => cut.Instance.OnTerminalStateChanged(new TerminalToolbarState
        {
            TerminalId = 1, Generation = 1, Connected = true, FontPx = 21
        }));
        cut.Render(builder => builder.Add(p => p.InitialFontSize, 17));
        Assert.Equal(21, cut.Instance.FontSize);
        Assert.Single(module.Invocations, i => i.Identifier == "initTerminal");
    }
 
    [Fact]
    public async Task FitButton_UsesCurrentStateAndKeepsThePickerForDimensionsOnly()
    {
        var module = TerminalSetupHelpers.SetupTerminalViewModule(this, "/Components/Controls/TerminalView.razor.js");
        module.Setup<int>("initTerminal", _ => true).SetResult(1);
        var cut = Render<TerminalView>(builder => builder.Add(p => p.ResourceName, "shell"));
        Assert.True(cut.Find(".terminal-fit").HasAttribute("disabled"));
 
        await cut.InvokeAsync(() => cut.Instance.OnTerminalStateChanged(new TerminalToolbarState
        {
            TerminalId = 1, Generation = 1, Connected = true, FitEnabled = true,
            Cols = 97, Rows = 38, SizeKey = "97x38", SizeSelectEnabled = true
        }));
        Assert.False(cut.Find(".terminal-fit").HasAttribute("disabled"));
        Assert.Equal(Resources.TerminalStrings.TerminalToolbarGridSizeAuto, cut.Find(".terminal-fit").GetAttribute("title"));
        var items = cut.FindComponent<FluentSelect<TerminalSizePreset, string>>().Instance.Items;
        Assert.NotNull(items);
        Assert.Equal([new("97x38", "97\u00d738", 97, 38), new TerminalSizePreset("80x24", "80\u00d724", 80, 24)], items);
        cut.Find(".terminal-fit").Click();
        Assert.Equal(new object?[] { 1 }, Assert.Single(module.Invocations, i => i.Identifier == "fitToContainer").Arguments);
 
        await cut.InvokeAsync(() => cut.Instance.OnTerminalStateChanged(new TerminalToolbarState
        {
            TerminalId = 1, Generation = 1, Connected = true, FitEnabled = false,
            Cols = 97, Rows = 38, SizeKey = "97x38"
        }));
        Assert.True(cut.Find(".terminal-fit").HasAttribute("disabled"));
    }
 
    [Fact]
    public void AutoFit_ChangesDuringInitializationApplyWithoutReconnecting()
    {
        var module = TerminalSetupHelpers.SetupTerminalViewModule(this, "/Components/Controls/TerminalView.razor.js");
        var init = module.Setup<int>("initTerminal", _ => true);
        var cut = Render<TerminalView>(builder => builder
            .Add(p => p.ResourceName, "shell").Add(p => p.AutoFit, true));
        Assert.True(Assert.IsType<TerminalViewOptions>(Assert.Single(init.Invocations).Arguments[3]).AutoFit);
        cut.Render(builder => builder.Add(p => p.AutoFit, false));
        init.SetResult(1);
        cut.WaitForAssertion(() => Assert.Equal(new object?[] { 1, false },
            Assert.Single(module.Invocations, i => i.Identifier == "setAutoFit").Arguments));
        cut.Render(builder => builder.Add(p => p.AutoFit, true));
        Assert.Equal(["initTerminal", "getSizePresets", "setAutoFit", "setAutoFit"], module.Invocations.Select(i => i.Identifier));
    }
 
    [Fact]
    public async Task TerminalFooter_UsesCurrentDimensionsAndIgnoresStaleCallbacks()
    {
        var module = TerminalSetupHelpers.SetupTerminalViewModule(this, "/Components/Controls/TerminalView.razor.js");
        var initialization = module.Setup<int>("initTerminal", _ => true);
        initialization.SetResult(1);
        var cut = Render<TerminalView>(builder => builder.Add(p => p.ResourceName, "shell <worker>"));
        Assert.Equal("shell <worker>", cut.Find(".terminal-title").TextContent);
        Assert.Empty(cut.FindAll(".terminal-dimensions"));
 
        await cut.InvokeAsync(() => cut.Instance.OnTerminalStateChanged(new TerminalToolbarState
        {
            TerminalId = 1, Generation = 2, Cols = 120, Rows = 30, SizeKey = "120x30", Connected = true
        }));
        Assert.Equal("120x30", cut.FindComponent<FluentSelect<TerminalSizePreset, string>>().Instance.Value);
        Assert.Empty(cut.FindAll(".terminal-dimensions"));
 
        foreach (var state in new[]
        {
            new TerminalToolbarState { TerminalId = 1, Generation = 1, Cols = 80, Rows = 24, SizeKey = "80x24" },
            new TerminalToolbarState { TerminalId = 2, Generation = 3, Cols = 80, Rows = 24, SizeKey = "80x24" }
        })
        {
            await cut.InvokeAsync(() => cut.Instance.OnTerminalStateChanged(state));
            Assert.Equal("120x30", cut.FindComponent<FluentSelect<TerminalSizePreset, string>>().Instance.Value);
        }
 
        await cut.InvokeAsync(() => cut.Instance.OnTerminalStateChanged(new TerminalToolbarState
        {
            TerminalId = 1, Generation = 2, Cols = 132, Rows = 50, SizeKey = "132x50", Connected = true
        }));
        Assert.Equal("132x50", cut.FindComponent<FluentSelect<TerminalSizePreset, string>>().Instance.Value);
        Assert.Single(initialization.Invocations);
    }
 
    [Theory]
    [InlineData("mount-failed", nameof(Resources.TerminalStrings.TerminalMountFailed))]
    [InlineData("disconnected", nameof(Resources.TerminalStrings.TerminalDisconnected))]
    [InlineData("input-failed", nameof(Resources.TerminalStrings.TerminalInputFailed))]
    [InlineData("sizing-failed", nameof(Resources.TerminalStrings.TerminalSizingFailed))]
    public async Task TerminalError_DisplaysLocalizedAlert(string error, string resourceKey)
    {
        TerminalSetupHelpers.SetupTerminalView(this);
        var cut = Render<TerminalView>(builder => builder.Add(p => p.ResourceName, "app"));
        await cut.InvokeAsync(() => cut.Instance.OnTerminalStateChanged(new TerminalToolbarState
        {
            TerminalId = 1, Generation = 1, Error = error
        }));
        var loc = Services.GetRequiredService<IStringLocalizer<Resources.TerminalStrings>>();
        Assert.Equal(loc[resourceKey].Value, cut.Find("[role=alert]").TextContent);
        var button = Assert.Single(cut.FindAll(".terminal-error fluent-button"));
        Assert.Equal(error is "input-failed" or "sizing-failed"
            ? Resources.TerminalStrings.TerminalDismissError
            : Resources.TerminalStrings.TerminalRetry, button.TextContent.Trim());
    }
 
    [Theory]
    [InlineData("input-failed")]
    [InlineData("sizing-failed")]
    public async Task DismissActionError_PreservesTheConnection(string error)
    {
        var module = TerminalSetupHelpers.SetupTerminalViewModule(this, "/Components/Controls/TerminalView.razor.js");
        module.Setup<int>("initTerminal", _ => true).SetResult(1);
        var cut = Render<TerminalView>(builder => builder.Add(p => p.ResourceName, "shell"));
        await cut.InvokeAsync(() => cut.Instance.OnTerminalStateChanged(new TerminalToolbarState
        {
            TerminalId = 1, Generation = 1, Connected = true, Error = error
        }));
 
        cut.Find(".terminal-error fluent-button").Click();
 
        Assert.Equal(new object?[] { 1 }, Assert.Single(module.Invocations, i => i.Identifier == "dismissError").Arguments);
        Assert.Equal(["initTerminal", "getSizePresets", "dismissError"], module.Invocations.Select(i => i.Identifier));
 
        await cut.InvokeAsync(() => cut.Instance.OnTerminalStateChanged(new TerminalToolbarState
        {
            TerminalId = 1, Generation = 1, Connected = true
        }));
        Assert.Empty(cut.FindAll(".terminal-error"));
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public void ReadOnly_InitialAndUpdatedStatePreservesConnection(bool initialReadOnly)
    {
        var module = TerminalSetupHelpers.SetupTerminalViewModule(this, "/Components/Controls/TerminalView.razor.js");
        var init = module.Setup<int>("initTerminal", _ => true);
        init.SetResult(1);
        var update = module.SetupVoid("setReadOnly", _ => true);
        update.SetVoidResult();
        var cut = Render<TerminalView>(builder => builder
            .Add(p => p.EndpointPathAndQuery, "/api/apphost-terminal?terminalId=terminal")
            .Add(p => p.ReadOnly, initialReadOnly));
        Assert.Equal(initialReadOnly, Assert.IsType<TerminalViewOptions>(Assert.Single(init.Invocations).Arguments[3]).ReadOnly);
        var viewId = TerminalSetupHelpers.GetTerminalViewId(Assert.Single(init.Invocations).Arguments[1]);
        var registry = Services.GetRequiredService<TerminalViewSessionRegistry>();
        Assert.True(registry.TryGet(viewId, "/api/apphost-terminal?terminalId=terminal", out var session));
        Assert.Equal(initialReadOnly, session.ReadOnly);
        Assert.Empty(update.Invocations);
 
        cut.Render(builder => builder.Add(p => p.ReadOnly, !initialReadOnly));
        Assert.Equal(!initialReadOnly, session.ReadOnly);
        cut.WaitForAssertion(() => Assert.Equal(new object?[] { 1, !initialReadOnly }, Assert.Single(update.Invocations).Arguments));
        cut.Render(builder => builder.Add(p => p.ReadOnly, initialReadOnly));
        Assert.Equal(initialReadOnly, session.ReadOnly);
        cut.WaitForAssertion(() => Assert.Collection(update.Invocations,
            invocation => Assert.Equal(new object?[] { 1, !initialReadOnly }, invocation.Arguments),
            invocation => Assert.Equal(new object?[] { 1, initialReadOnly }, invocation.Arguments)));
        Assert.Single(init.Invocations);
        Assert.Equal(["initTerminal", "getSizePresets", "setReadOnly", "setReadOnly"],
            module.Invocations.Select(i => i.Identifier));
    }
 
    [Fact]
    public void ReadOnly_ChangedDuringInitializationAndUpdateAppliesLatestValue()
    {
        var module = TerminalSetupHelpers.SetupTerminalViewModule(this, "/Components/Controls/TerminalView.razor.js");
        var init = module.Setup<int>("initTerminal", _ => true);
        var update = module.SetupVoid("setReadOnly", _ => true);
        var cut = Render<TerminalView>(builder => builder
            .Add(p => p.EndpointPathAndQuery, "/api/apphost-terminal?terminalId=terminal"));
        cut.Render(builder => builder.Add(p => p.ReadOnly, true));
        var viewId = TerminalSetupHelpers.GetTerminalViewId(Assert.Single(init.Invocations).Arguments[1]);
        Assert.True(Services.GetRequiredService<TerminalViewSessionRegistry>().TryGet(
            viewId, "/api/apphost-terminal?terminalId=terminal", out var session));
        Assert.True(session.ReadOnly, "Authoritative policy must update before JS initialization returns");
        init.SetResult(1);
        cut.WaitForAssertion(() => Assert.Single(update.Invocations));
        cut.Render(builder => builder.Add(p => p.ReadOnly, false));
        update.SetVoidResult();
        cut.WaitForAssertion(() => Assert.Collection(update.Invocations,
            invocation => Assert.Equal(new object?[] { 1, true }, invocation.Arguments),
            invocation => Assert.Equal(new object?[] { 1, false }, invocation.Arguments)));
        Assert.Single(init.Invocations);
    }
 
    [Fact]
    public void ReadOnlyUpdateFailure_IsVisibleWithoutReconnect()
    {
        var module = TerminalSetupHelpers.SetupTerminalViewModule(this, "/Components/Controls/TerminalView.razor.js");
        module.Setup<int>("initTerminal", _ => true).SetResult(1);
        module.SetupVoid("setReadOnly", _ => true).SetException(new JSException("Unsupported live input policy"));
        var cut = Render<TerminalView>(builder => builder.Add(p => p.ResourceName, "app"));
        cut.Render(builder => builder.Add(p => p.ReadOnly, true));
        cut.WaitForAssertion(() => Assert.Equal(Resources.TerminalStrings.TerminalMountFailed, cut.Find("[role=alert]").TextContent));
        Assert.Equal(["initTerminal", "getSizePresets", "setReadOnly"], module.Invocations.Select(i => i.Identifier));
    }
 
    [Fact]
    public async Task InitializationFailure_OffersExplicitRetryWithoutRenderLoop()
    {
        var module = TerminalSetupHelpers.SetupTerminalViewModule(this, "/Components/Controls/TerminalView.razor.js");
        var failed = true;
        var initialization = module.Setup<int>("initTerminal", _ => failed);
        initialization.SetException(new JSException("Worker unavailable"));
        var retry = module.Setup<int>("initTerminal", _ => !failed);
        var cut = Render<TerminalView>(builder => builder.Add(p => p.ResourceName, "app"));
        cut.WaitForAssertion(() => Assert.Equal(Resources.TerminalStrings.TerminalMountFailed, cut.Find("[role=alert]").TextContent));
        cut.Render(builder => builder.Add(p => p.ResourceName, "app"));
        Assert.Single(initialization.Invocations);
 
        failed = false;
        var retrying = cut.Find(".terminal-error fluent-button").ClickAsync(new MouseEventArgs());
        cut.WaitForAssertion(() => Assert.Single(retry.Invocations));
        cut.Render(builder => builder.Add(p => p.ResourceName, "app"));
        Assert.Single(retry.Invocations);
        retry.SetResult(1);
        await retrying;
    }
 
    [Theory]
    [InlineData("/api/apphost-terminal?terminalId=second")]
    [InlineData(null)]
    public void PendingInitialization_ReconcilesChangedEndpoint(string? updatedEndpoint)
    {
        var module = TerminalSetupHelpers.SetupTerminalViewModule(this, "/Components/Controls/TerminalView.razor.js");
        var init = module.Setup<int>("initTerminal", _ => true);
        var cut = Render<TerminalView>(builder => builder
            .Add(p => p.EndpointPathAndQuery, "/api/apphost-terminal?terminalId=first"));
        cut.Render(builder => builder.Add(p => p.EndpointPathAndQuery, updatedEndpoint));
        init.SetResult(1);
        cut.WaitForAssertion(() =>
        {
            Assert.Single(init.Invocations);
            if (updatedEndpoint is null)
            {
                Assert.Single(module.Invocations, i => i.Identifier == "disposeTerminal");
            }
            else
            {
                var reconnect = Assert.Single(module.Invocations, i => i.Identifier == "reconnectTerminal");
                TerminalSetupHelpers.AssertBoundEndpoint($"ws://localhost{updatedEndpoint}", reconnect.Arguments[1]);
            }
        });
    }
 
    [Theory]
    [InlineData("/api/apphost-terminal?terminalId=second")]
    [InlineData(null)]
    public async Task PendingExplicitRetry_ReconcilesChangedEndpoint(string? updatedEndpoint)
    {
        var module = TerminalSetupHelpers.SetupTerminalViewModule(this, "/Components/Controls/TerminalView.razor.js");
        var failed = true;
        module.Setup<int>("initTerminal", _ => failed).SetException(new JSException("Worker unavailable"));
        var retry = module.Setup<int>("initTerminal", _ => !failed);
        var cut = Render<TerminalView>(builder => builder
            .Add(p => p.EndpointPathAndQuery, "/api/apphost-terminal?terminalId=first"));
        cut.WaitForAssertion(() => Assert.Single(cut.FindAll("[role=alert]")));
        failed = false;
        var retrying = cut.Find(".terminal-error fluent-button").ClickAsync(new MouseEventArgs());
        cut.WaitForAssertion(() => Assert.Single(retry.Invocations));
        cut.Render(builder => builder.Add(p => p.EndpointPathAndQuery, updatedEndpoint));
        retry.SetResult(1);
        await retrying;
        cut.WaitForAssertion(() =>
        {
            Assert.Single(retry.Invocations);
            if (updatedEndpoint is null)
            {
                Assert.Single(module.Invocations, i => i.Identifier == "disposeTerminal");
            }
            else
            {
                var reconnect = Assert.Single(module.Invocations, i => i.Identifier == "reconnectTerminal");
                TerminalSetupHelpers.AssertBoundEndpoint($"ws://localhost{updatedEndpoint}", reconnect.Arguments[1]);
            }
        });
    }
 
    [Fact]
    public async Task EndpointDetached_IgnoresLateStateWithoutRecreatingTheView()
    {
        var module = TerminalSetupHelpers.SetupTerminalViewModule(this, "/Components/Controls/TerminalView.razor.js");
        var init = module.Setup<int>("initTerminal", _ => true);
        init.SetResult(1);
        var cut = Render<TerminalView>(builder => builder
            .Add(p => p.EndpointPathAndQuery, "/api/apphost-terminal?terminalId=first"));
        cut.Render(builder => builder.Add(p => p.EndpointPathAndQuery, null));
        await cut.InvokeAsync(() => cut.Instance.OnTerminalStateChanged(new TerminalToolbarState
        {
            TerminalId = 1, Generation = 2, Error = "disconnected"
        }));
        Assert.Empty(cut.FindAll("[role=alert]"));
        Assert.Single(init.Invocations);
    }
 
    [Fact]
    public void EndpointRemovedAndRestored_DetachesAndInitializesAgain()
    {
        var module = TerminalSetupHelpers.SetupTerminalViewModule(this, "/Components/Controls/TerminalView.razor.js");
        var init = module.Setup<int>("initTerminal", _ => true);
        init.SetResult(1);
        var cut = Render<TerminalView>();
        Assert.Empty(init.Invocations);
        cut.Render(builder => builder.Add(p => p.EndpointPathAndQuery, "/api/apphost-terminal?terminalId=first"));
        cut.WaitForAssertion(() => Assert.Single(init.Invocations));
        cut.Render(builder => builder.Add(p => p.EndpointPathAndQuery, null));
        cut.WaitForAssertion(() => Assert.Single(module.Invocations, i => i.Identifier == "disposeTerminal"));
        cut.Render(builder => builder.Add(p => p.EndpointPathAndQuery, "/api/apphost-terminal?terminalId=second"));
        cut.WaitForAssertion(() => Assert.Equal(2, init.Invocations.Count));
    }
 
    [Theory]
    [InlineData("http://localhost:8080/aspire/", "/aspire/Components/Controls/TerminalView.razor.js", "ws://localhost:8080/aspire/api/terminal?resource=app%20%26%20name")]
    [InlineData("https://dashboard.example/nested/aspire/", "/nested/aspire/Components/Controls/TerminalView.razor.js", "wss://dashboard.example/nested/aspire/api/terminal?resource=app%20%26%20name")]
    public void Initialization_PreservesPathBaseAndWebSocketScheme(string baseUri, string modulePath, string socketUrl)
    {
        Services.AddSingleton<NavigationManager>(new TestNavigationManager(baseUri));
        var module = TerminalSetupHelpers.SetupTerminalViewModule(this, modulePath);
        var init = module.Setup<int>("initTerminal", _ => true);
        init.SetResult(1);
        Render<TerminalView>(builder => builder.Add(p => p.ResourceName, "app & name"));
        var invocation = Assert.Single(init.Invocations);
        TerminalSetupHelpers.AssertBoundEndpoint(socketUrl, invocation.Arguments[1]);
    }
 
    [Theory]
    [InlineData("api/apphost-terminal?terminalId=terminal", "/aspire/api/apphost-terminal?terminalId=terminal")]
    [InlineData("/api/apphost-terminal?terminalId=terminal", "/api/apphost-terminal?terminalId=terminal")]
    public void ExplicitEndpoint_RegistersTheResolvedPathAndQuery(string endpoint, string expectedPathAndQuery)
    {
        Services.AddSingleton<NavigationManager>(new TestNavigationManager("https://dashboard.example/aspire/"));
        var module = TerminalSetupHelpers.SetupTerminalViewModule(this, "/aspire/Components/Controls/TerminalView.razor.js");
        var init = module.Setup<int>("initTerminal", _ => true);
        init.SetResult(1);
        Render<TerminalView>(builder => builder.Add(p => p.EndpointPathAndQuery, endpoint));
        var invocation = Assert.Single(init.Invocations);
        var viewId = TerminalSetupHelpers.GetTerminalViewId(invocation.Arguments[1]);
        TerminalSetupHelpers.AssertBoundEndpoint($"wss://dashboard.example{expectedPathAndQuery}", invocation.Arguments[1]);
        Assert.True(Services.GetRequiredService<TerminalViewSessionRegistry>().TryGet(
            viewId, expectedPathAndQuery, out _));
    }
 
    [Fact]
    public async Task DisposalDuringInitialization_DisposesReturnedTerminalExactlyOnce()
    {
        var module = TerminalSetupHelpers.SetupTerminalViewModule(this, "/Components/Controls/TerminalView.razor.js");
        var init = module.Setup<int>("initTerminal", _ => true);
        var cut = Render<TerminalView>(builder => builder.Add(p => p.ResourceName, "app"));
        cut.WaitForAssertion(() => Assert.Single(init.Invocations));
        var disposing = cut.InvokeAsync(() => cut.Instance.DisposeAsync().AsTask());
        Assert.False(disposing.IsCompleted);
        init.SetResult(1);
        await disposing;
        await cut.InvokeAsync(() => cut.Instance.DisposeAsync().AsTask());
        Assert.Equal(1, Assert.Single(module.Invocations, i => i.Identifier == "disposeTerminal").Arguments[0]);
    }
 
    [Fact]
    public void EndpointChange_RotatesRegistrationAndDisablesOldInput()
    {
        var module = TerminalSetupHelpers.SetupTerminalViewModule(this, "/Components/Controls/TerminalView.razor.js");
        var init = module.Setup<int>("initTerminal", _ => true);
        init.SetResult(1);
        var cut = Render<TerminalView>(builder => builder
            .Add(p => p.EndpointPathAndQuery, "/api/apphost-terminal?terminalId=first"));
        var firstId = TerminalSetupHelpers.GetTerminalViewId(Assert.Single(init.Invocations).Arguments[1]);
        var registry = Services.GetRequiredService<TerminalViewSessionRegistry>();
        Assert.True(registry.TryGet(firstId, "/api/apphost-terminal?terminalId=first", out var firstSession));
 
        cut.Render(builder => builder.Add(p => p.EndpointPathAndQuery, "/api/apphost-terminal?terminalId=second"));
        cut.WaitForAssertion(() => Assert.Single(module.Invocations, i => i.Identifier == "reconnectTerminal"));
        var secondUrl = new Uri(Assert.IsType<string>(Assert.Single(module.Invocations, i => i.Identifier == "reconnectTerminal").Arguments[1]));
        var secondId = QueryHelpers.ParseQuery(secondUrl.Query)["viewId"].ToString();
        Assert.NotEqual(firstId, secondId);
        Assert.False(registry.TryGet(firstId, "/api/apphost-terminal?terminalId=first", out _));
        Assert.True(firstSession.ReadOnly);
        Assert.True(registry.TryGet(secondId, "/api/apphost-terminal?terminalId=second", out _));
    }
 
    [Fact]
    public void CompletionBeforeInitializationFinishes_DisablesInputWithoutDismissingTheView()
    {
        var module = TerminalSetupHelpers.SetupTerminalViewModule(this, "/Components/Controls/TerminalView.razor.js");
        var init = module.Setup<int>("initTerminal", _ => true);
        var cut = Render<TerminalView>(builder => builder
            .Add(p => p.EndpointPathAndQuery, "/api/apphost-terminal?terminalId=terminal"));
        var viewId = TerminalSetupHelpers.GetTerminalViewId(Assert.Single(init.Invocations).Arguments[1]);
        Assert.True(Services.GetRequiredService<TerminalViewSessionRegistry>().TryGet(
            viewId, "/api/apphost-terminal?terminalId=terminal", out var session));
        Assert.False(session.Ended.IsCompleted);
        session.MarkEnded();
        Assert.True(session.Ended.IsCompletedSuccessfully);
        Assert.True(session.ReadOnly);
        Assert.Single(cut.FindAll(".terminal-container"));
        Assert.Equal(["initTerminal"], module.Invocations.Select(i => i.Identifier));
        init.SetResult(1);
        cut.WaitForAssertion(() => Assert.Single(init.Invocations));
        Assert.True(session.ReadOnly);
    }
 
}