// 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);
}
}