// 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.Layout;
using Aspire.Dashboard.Components.Tests.Shared;
using Aspire.Dashboard.Model;
using Aspire.DashboardService.Proto.V1;
using Bunit;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.FluentUI.AspNetCore.Components;
using Microsoft.JSInterop;
using Xunit;
namespace Aspire.Dashboard.Components.Tests.Layout;
public partial class TerminalDockTests
{
[Theory]
[InlineData(false, false)]
[InlineData(false, true)]
[InlineData(true, false)]
[InlineData(true, true)]
public async Task ReloadRecovery_KeepsPlaceholderUntilExplicitReturnOrConfirmedClosure(bool storageFailure, bool closeFailure)
{
var updates = Channel.CreateUnbounded<WatchTerminalsUpdate>();
var client = TerminalSetupHelpers.CreateTerminalDashboardClient(terminalChannelProvider: () => updates);
TerminalSetupHelpers.SetupTerminalComponents(this, client);
var module = TerminalSetupHelpers.SetupTerminalWindows(this);
var adoption = module.SetupVoid("adoptTerminalWindows", _ => true);
if (closeFailure)
{
// JS reports failed durable revocation after unconditionally releasing its live popup handle.
module.SetupVoid("closeTerminalWindow", _ => true).SetException(new JSException("Storage denied"));
}
var toasts = Render<FluentToastProvider>();
var cut = Render<TerminalDock>();
await cut.InvokeAsync(cut.Instance.ToggleAsync);
await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("terminal"));
var launcher = TerminalSetupHelpers.GetWindowLauncher(this, cut);
if (storageFailure)
{
adoption.SetException(new JSException("Storage denied"));
}
else
{
await cut.InvokeAsync(() => launcher.OnTerminalWindowOpenedAsync("terminal", "recovering"));
adoption.SetVoidResult();
}
cut.WaitForAssertion(() =>
{
Assert.Single(cut.FindAll(".terminal-dock-detached"));
Assert.Equal(Resources.TerminalStrings.TerminalDockRecoveringWindow,
cut.Find(".terminal-dock-detached > span").TextContent);
Assert.Empty(cut.FindComponents<TerminalView>());
});
if (storageFailure)
{
// The placeholder renders before the error notification. Observe the toast too so the test
// cannot finish while the adoption failure handler is still running.
toasts.WaitForAssertion(() => Assert.Equal(Resources.TerminalStrings.TerminalWindowTrackingFailed,
Assert.Single(toasts.FindComponents<FluentToast>()).Instance.Title));
}
cut.Render();
await cut.InvokeAsync(cut.Instance.ToggleAsync);
await cut.InvokeAsync(cut.Instance.ToggleAsync);
Assert.Equal([], JSInterop.Invocations.Where(i => i.Identifier == "initTerminal"));
var focus = cut.Find("[data-terminal-window-focus-key]");
Assert.Equal("terminal", focus.GetAttribute("data-terminal-window-focus-key"));
Assert.Equal(cut.Find(".terminal-dock-detach").GetAttribute("data-terminal-window-focus-group"),
focus.GetAttribute("data-terminal-window-focus-group"));
await cut.InvokeAsync(() => cut.FindAll(".terminal-dock-detached-actions .aspire-button")[1].ClickAsync(new()));
cut.WaitForAssertion(() => Assert.Single(cut.FindComponents<TerminalView>()));
var close = Assert.Single(module.Invocations, i => i.Identifier == "closeTerminalWindow");
Assert.Equal("terminal", close.Arguments[0]);
Assert.Empty(client.ClosedTerminals);
}
[Theory]
[InlineData("")]
[InlineData("/aspire/nested")]
public async Task ReplacementDock_AdoptsWindowsBeforeMountingAnyViewers(string pathBase)
{
var updates = Channel.CreateUnbounded<WatchTerminalsUpdate>();
var client = TerminalSetupHelpers.CreateTerminalDashboardClient(terminalChannelProvider: () => updates);
Services.AddSingleton<NavigationManager>(new TestNavigationManager($"https://dashboard.example{pathBase}/"));
TerminalSetupHelpers.SetupTerminalComponents(this, client, pathBase);
var old = Render<TerminalDock>();
await old.InvokeAsync(old.Instance.ToggleAsync);
await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("active", "inactive"));
old.WaitForAssertion(() =>
{
Assert.Equal(2, old.FindComponents<TerminalView>().Count);
Assert.Equal(2, JSInterop.Invocations.Count(i => i.Identifier == "initTerminal"));
});
var oldLauncher = TerminalSetupHelpers.GetWindowLauncher(this, old);
await old.InvokeAsync(() => oldLauncher.OnTerminalWindowOpenedAsync("active", "opened"));
await old.InvokeAsync(() => oldLauncher.OnTerminalWindowOpenedAsync("inactive", "opened"));
await old.InvokeAsync(() => old.Instance.DisposeAsync().AsTask()).DefaultTimeout();
Assert.Empty(client.ClosedTerminals);
Assert.Equal([], JSInterop.Invocations.Where(i => i.Identifier == "closeTerminalWindow"));
var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var module = new TestJSObjectReference
{
BeforeInvokeAsync = identifier =>
{
if (identifier == "adoptTerminalWindows")
{
started.TrySetResult();
return release.Task;
}
return Task.CompletedTask;
}
};
TestJSObjectReference.SetupImport(this, $"{pathBase}/js/app-terminalwindow.js").SetResult(module);
var cut = Render<TerminalDock>();
TerminalWindowLauncher launcher;
try
{
await cut.InvokeAsync(cut.Instance.ToggleAsync);
await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("active", "inactive"));
await started.Task.DefaultTimeout();
Assert.Equal(2, cut.FindAll("[role=tab]").Count);
Assert.Empty(cut.FindComponents<TerminalView>());
Assert.Equal(2, JSInterop.Invocations.Count(i => i.Identifier == "initTerminal"));
var registration = Assert.Single(module.Invocations, i => i.Identifier == "registerTerminalWindowButton");
launcher = Assert.IsType<DotNetObjectReference<TerminalWindowLauncher>>(registration.Arguments[2]).Value;
var adoption = Assert.Single(module.Invocations, i => i.Identifier == "adoptTerminalWindows");
Assert.Equal(registration.Arguments[1], adoption.Arguments[0]);
Assert.Equal(["active", "inactive"], Assert.IsType<string[]>(adoption.Arguments[1]));
// bUnit drives the JS acknowledgements, not real popup handles. The Node suite verifies retention.
await cut.InvokeAsync(() => launcher.OnTerminalWindowOpenedAsync("active", "adopted"));
await cut.InvokeAsync(() => launcher.OnTerminalWindowOpenedAsync("inactive", "adopted"));
Assert.Equal(2, cut.FindAll(".terminal-dock-detached").Count);
Assert.Empty(cut.FindComponents<TerminalView>());
}
finally
{
release.TrySetResult();
}
await cut.InvokeAsync(() => launcher.OnTerminalWindowClosedAsync("active"));
cut.WaitForAssertion(() =>
{
var view = Assert.Single(cut.FindComponents<TerminalView>()).Instance;
Assert.Equal("dock:active", view.SizeMemoryKey);
Assert.True(view.AutoFit);
Assert.Single(cut.FindAll(".terminal-dock-detached"));
});
await cut.FindAll(".terminal-dock-detached-actions .aspire-button")[1].ClickAsync(new());
cut.WaitForAssertion(() =>
{
Assert.Empty(cut.FindAll(".terminal-dock-detached"));
Assert.Equal([true, false], cut.FindComponents<TerminalView>().Select(c => c.Instance.AutoFit));
});
var close = Assert.Single(module.Invocations, i => i.Identifier == "closeTerminalWindow");
Assert.Equal("inactive", close.Arguments[0]);
Assert.Empty(client.ClosedTerminals);
await cut.InvokeAsync(() => cut.Instance.DisposeAsync().AsTask()).DefaultTimeout();
Assert.Single(module.Invocations, i => i.Identifier == "unregisterTerminalWindowButton");
Assert.Equal(1, module.DisposeCount);
}
[Fact]
public async Task Adoption_ChangedSnapshotChecksNewIdentitiesAndDoesNotMountRemovedTerminal()
{
var updates = Channel.CreateUnbounded<WatchTerminalsUpdate>();
var client = TerminalSetupHelpers.CreateTerminalDashboardClient(terminalChannelProvider: () => updates);
TerminalSetupHelpers.SetupTerminalComponents(this, client);
var started = Channel.CreateUnbounded<bool>();
var firstRelease = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var secondRelease = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var adoptionCount = 0;
var module = new TestJSObjectReference
{
BeforeInvokeAsync = identifier =>
{
if (identifier == "adoptTerminalWindows")
{
started.Writer.TryWrite(true);
return ++adoptionCount == 1 ? firstRelease.Task : secondRelease.Task;
}
return Task.CompletedTask;
}
};
TestJSObjectReference.SetupImport(this, "/js/app-terminalwindow.js").SetResult(module);
var cut = Render<TerminalDock>();
try
{
await cut.InvokeAsync(cut.Instance.ToggleAsync);
await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("old-identity"));
await started.Reader.ReadAsync().AsTask().DefaultTimeout();
var registration = Assert.Single(module.Invocations, i => i.Identifier == "registerTerminalWindowButton");
var launcher = Assert.IsType<DotNetObjectReference<TerminalWindowLauncher>>(registration.Arguments[2]).Value;
await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("new-identity"));
cut.WaitForAssertion(() => Assert.Equal("new-identity", cut.Find("[role=tab]").TextContent.Trim()));
await cut.InvokeAsync(() => launcher.OnTerminalWindowOpenedAsync("old-identity", "adopted"));
var close = Assert.Single(module.Invocations, i => i.Identifier == "closeTerminalWindow");
Assert.Equal("old-identity", close.Arguments[0]);
Assert.Empty(cut.FindComponents<TerminalView>());
Assert.Empty(cut.FindAll(".terminal-dock-detached"));
firstRelease.SetResult();
await started.Reader.ReadAsync().AsTask().DefaultTimeout();
var adoptions = module.Invocations.Where(i => i.Identifier == "adoptTerminalWindows").ToArray();
Assert.Equal(2, adoptions.Length);
Assert.Equal(["new-identity"], Assert.IsType<string[]>(adoptions[1].Arguments[1]));
Assert.Empty(cut.FindComponents<TerminalView>());
await cut.InvokeAsync(() => launcher.OnTerminalWindowOpenedAsync("new-identity", "adopted"));
}
finally
{
firstRelease.TrySetResult();
secondRelease.TrySetResult();
}
cut.WaitForAssertion(() =>
{
Assert.Empty(cut.FindComponents<TerminalView>());
Assert.Single(cut.FindAll(".terminal-dock-detached"));
});
Assert.Equal([], JSInterop.Invocations.Where(i => i.Identifier == "initTerminal"));
Assert.Empty(client.ClosedTerminals);
await cut.InvokeAsync(() => cut.Instance.DisposeAsync().AsTask()).DefaultTimeout();
}
}