File: Controls\TerminalWindowButtonTests.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.Tests.Shared;
using Aspire.Dashboard.Model;
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.Controls;
 
[UseCulture("en-US")]
public class TerminalWindowButtonTests : DashboardTestContext
{
    public TerminalWindowButtonTests()
    {
        FluentUISetupHelpers.AddCommonDashboardServices(this);
        FluentUISetupHelpers.SetupFluentUIComponents(this);
        FluentUISetupHelpers.SetupFluentButton(this);
    }
 
    [Theory]
    [InlineData(true, "terminal", "terminal-window/apphost/terminal", 19)]
    [InlineData(false, null, "terminal-window/apphost/terminal", 19)]
    [InlineData(false, "", "terminal-window/apphost/terminal", 19)]
    [InlineData(false, "terminal", null, 19)]
    [InlineData(false, "terminal", "", 19)]
    [InlineData(false, "terminal", "terminal-window/apphost/terminal", null)]
    [InlineData(false, "terminal", "terminal-window/apphost/terminal", 0)]
    public async Task Registration_WaitsUntilLaunchingIsAvailable(bool disabled, string? key, string? url, int? fontSize)
    {
        var module = TerminalSetupHelpers.SetupTerminalWindows(this);
        var registration = module.SetupVoid("registerTerminalWindowButton", _ => true);
        var cut = Render<TerminalWindowButton>(builder => builder
            .Add(p => p.Label, "Open")
            .Add(p => p.Disabled, disabled)
            .Add(p => p.TerminalKey, key)
            .Add(p => p.Url, url)
            .Add(p => p.FontSize, fontSize));
        cut.Render();
 
        Assert.True(cut.FindComponent<FluentButton>().Instance.Disabled);
        Assert.Equal([], JSInterop.Invocations.Where(invocation => invocation.Identifier == "import" &&
            Equals(invocation.Arguments[0], "/js/app-terminalwindow.js")));
        Assert.Empty(module.Invocations);
 
        cut.Render(builder => builder
            .Add(p => p.Disabled, false)
            .Add(p => p.TerminalKey, "terminal")
            .Add(p => p.Url, "terminal-window/apphost/terminal")
            .Add(p => p.FontSize, 19));
        Assert.Single(registration.Invocations);
        Assert.True(cut.FindComponent<FluentButton>().Instance.Disabled);
        cut.Render();
        Assert.Single(registration.Invocations);
        registration.SetVoidResult();
        cut.WaitForAssertion(() => Assert.False(cut.FindComponent<FluentButton>().Instance.Disabled));
 
        cut.Render(builder => builder.Add(p => p.Disabled, true));
        cut.Render(builder => builder.Add(p => p.Disabled, false));
        Assert.Single(registration.Invocations);
        await cut.InvokeAsync(() => cut.Instance.DisposeAsync().AsTask());
        Assert.Single(module.Invocations, invocation => invocation.Identifier == "unregisterTerminalWindowButton");
    }
 
    [Fact]
    public async Task Disposal_BeforeLaunchingIsAvailable_DoesNotLoadJavaScript()
    {
        var module = TerminalSetupHelpers.SetupTerminalWindows(this);
        var cut = Render<TerminalWindowButton>(builder => builder.Add(p => p.Label, "Open"));
 
        await cut.InvokeAsync(() => cut.Instance.DisposeAsync().AsTask());
 
        Assert.Empty(module.Invocations);
        Assert.Equal([], JSInterop.Invocations.Where(invocation => invocation.Identifier == "import" &&
            Equals(invocation.Arguments[0], "/js/app-terminalwindow.js")));
    }
 
    [Fact]
    public void RegistrationAndMetadata_AreRequiredBeforeEnablingNativeButton()
    {
        Services.AddSingleton<NavigationManager>(new TestNavigationManager("http://localhost/aspire/nested/"));
        var module = TerminalSetupHelpers.SetupTerminalWindows(this, "/aspire/nested");
        var registration = module.SetupVoid("registerTerminalWindowButton", _ => true);
        var cut = Render<TerminalWindowButton>(builder => builder
            .Add(p => p.Label, "Open in new window")
            .Add(p => p.TerminalKey, "first")
            .Add(p => p.Url, "terminal-window/apphost/first")
            .Add(p => p.FontSize, 19));
 
        Assert.True(cut.FindComponent<FluentButton>().Instance.Disabled);
        Assert.False(cut.FindComponent<FluentButton>().Instance.OnClick.HasDelegate);
        cut.Render(builder => builder
            .Add(p => p.TerminalKey, "second #1/?%+")
            .Add(p => p.Url, "terminal-window/apphost/second%20%231%2F%3F%25%2B")
            .Add(p => p.FontSize, 23));
        Assert.True(cut.FindComponent<FluentButton>().Instance.Disabled);
        registration.SetVoidResult();
        cut.WaitForAssertion(() => Assert.False(cut.FindComponent<FluentButton>().Instance.Disabled));
        Assert.Equal("second #1/?%+", cut.Find("fluent-button").GetAttribute("data-terminal-window-key"));
        Assert.Equal("http://localhost/aspire/nested/terminal-window/apphost/second%20%231%2F%3F%25%2B?fontSize=23",
            cut.Find("fluent-button").GetAttribute("data-terminal-window-url"));
        Assert.Single(registration.Invocations);
 
        cut.Render(builder => builder.Add(p => p.FontSize, null));
        Assert.True(cut.FindComponent<FluentButton>().Instance.Disabled);
        Assert.Null(cut.Find("fluent-button").GetAttribute("data-terminal-window-url"));
        cut.Render(builder => builder.Add(p => p.FontSize, 17).Add(p => p.Disabled, true));
        Assert.True(cut.FindComponent<FluentButton>().Instance.Disabled);
        cut.Render(builder => builder.Add(p => p.Disabled, false).Add(p => p.TerminalKey, null));
        Assert.True(cut.FindComponent<FluentButton>().Instance.Disabled);
        cut.Render(builder => builder.Add(p => p.TerminalKey, "second").Add(p => p.Url, null));
        Assert.True(cut.FindComponent<FluentButton>().Instance.Disabled);
        cut.Render(builder => builder.Add(p => p.Url, "terminal-window/apphost/second"));
        Assert.False(cut.FindComponent<FluentButton>().Instance.Disabled);
    }
 
    [Fact]
    public async Task Notifications_UseCapturedKeyAndAreIgnoredAfterDisposal()
    {
        var module = TerminalSetupHelpers.SetupTerminalWindows(this);
        var opened = new List<(string Key, TerminalWindowOpenResult Result)>();
        var closed = new List<string>();
        var cut = Render<TerminalWindowButton>(builder => builder
            .Add(p => p.Label, "Open")
            .Add(p => p.TerminalKey, "first")
            .Add(p => p.Url, "terminal-window/apphost/first")
            .Add(p => p.FontSize, 19)
            .Add(p => p.OnWindowOpened, launch => opened.Add(launch))
            .Add(p => p.OnWindowClosed, key => closed.Add(key)));
        var launcher = TerminalSetupHelpers.GetWindowLauncher(this, cut);
        cut.Render(builder => builder.Add(p => p.TerminalKey, "second"));
        await cut.InvokeAsync(() => launcher.OnTerminalWindowOpenedAsync("first", "opened"));
        await cut.InvokeAsync(() => launcher.OnTerminalWindowOpenedAsync("first", "focused"));
        await cut.InvokeAsync(() => launcher.OnTerminalWindowClosedAsync("first"));
        Assert.Equal([("first", TerminalWindowOpenResult.Opened), ("first", TerminalWindowOpenResult.Focused)], opened);
        Assert.Equal(["first"], closed);
 
        await cut.InvokeAsync(() => cut.Instance.DisposeAsync().AsTask());
        await cut.InvokeAsync(() => launcher.OnTerminalWindowOpenedAsync("second", "opened"));
        await cut.InvokeAsync(() => launcher.OnTerminalWindowClosedAsync("second"));
        Assert.Equal(2, opened.Count);
        Assert.Equal(["first"], closed);
        var registration = Assert.Single(module.Invocations, i => i.Identifier == "registerTerminalWindowButton");
        var unregister = Assert.Single(module.Invocations, i => i.Identifier == "unregisterTerminalWindowButton");
        Assert.Equal(registration.Arguments[1], unregister.Arguments[0]);
        Assert.Equal(["registerTerminalWindowButton", "unregisterTerminalWindowButton"],
            module.Invocations.Select(i => i.Identifier));
    }
 
    [Fact]
    public async Task DisposalDuringRegistration_UnregistersOnceWithoutEnablingButton()
    {
        var module = TerminalSetupHelpers.SetupTerminalWindows(this);
        var registration = module.SetupVoid("registerTerminalWindowButton", _ => true);
        var cut = Render<TerminalWindowButton>(builder => builder
            .Add(p => p.Label, "Open")
            .Add(p => p.TerminalKey, "terminal")
            .Add(p => p.Url, "terminal-window/apphost/terminal")
            .Add(p => p.FontSize, 19));
        Assert.Single(registration.Invocations);
        var disposing = cut.InvokeAsync(() => cut.Instance.DisposeAsync().AsTask());
        Assert.False(disposing.IsCompleted);
        registration.SetVoidResult();
        await disposing;
        await cut.InvokeAsync(() => cut.Instance.DisposeAsync().AsTask());
        Assert.True(cut.FindComponent<FluentButton>().Instance.Disabled);
        Assert.Single(module.Invocations, i => i.Identifier == "unregisterTerminalWindowButton");
    }
 
    [Theory]
    [InlineData("blocked", nameof(Resources.TerminalStrings.TerminalToolbarOpenInWindowBlocked))]
    [InlineData("failed", nameof(Resources.TerminalStrings.TerminalToolbarOpenInWindowFailed))]
    public async Task BrowserFailure_ShowsActionableToast(string result, string resourceKey)
    {
        TerminalSetupHelpers.SetupTerminalWindows(this);
        var toasts = Render<FluentToastProvider>();
        var cut = Render<TerminalWindowButton>(builder => builder
            .Add(p => p.Label, "Open")
            .Add(p => p.TerminalKey, "terminal")
            .Add(p => p.Url, "terminal-window/apphost/terminal")
            .Add(p => p.FontSize, 19));
        var launcher = TerminalSetupHelpers.GetWindowLauncher(this, cut);
        await cut.InvokeAsync(() => launcher.OnTerminalWindowOpenedAsync("terminal", result));
        var toast = Assert.Single(toasts.FindComponents<FluentToast>()).Instance;
        Assert.Equal(ToastIntent.Error, toast.Intent);
        Assert.Equal(resourceKey == nameof(Resources.TerminalStrings.TerminalToolbarOpenInWindowBlocked)
            ? Resources.TerminalStrings.TerminalToolbarOpenInWindowBlocked
            : Resources.TerminalStrings.TerminalToolbarOpenInWindowFailed, toast.Title);
    }
 
    [Fact]
    public void RegistrationFailure_StaysDisabledAndShowsActionableToast()
    {
        var module = TerminalSetupHelpers.SetupTerminalWindows(this);
        module.SetupVoid("registerTerminalWindowButton", _ => true).SetException(new JSException("Module registration failed"));
        var toasts = Render<FluentToastProvider>();
        var cut = Render<TerminalWindowButton>(builder => builder
            .Add(p => p.Label, "Open")
            .Add(p => p.TerminalKey, "terminal")
            .Add(p => p.Url, "terminal-window/apphost/terminal")
            .Add(p => p.FontSize, 19));
        Assert.True(cut.FindComponent<FluentButton>().Instance.Disabled);
        toasts.WaitForAssertion(() => Assert.Equal(Resources.TerminalStrings.TerminalToolbarOpenInWindowFailed,
            Assert.Single(toasts.FindComponents<FluentToast>()).Instance.Title));
        cut.Render();
        Assert.Single(module.Invocations, invocation => invocation.Identifier == "registerTerminalWindowButton");
        Assert.Single(toasts.FindComponents<FluentToast>());
    }
 
    [Fact]
    public async Task Adoption_RegistersWithoutViewerMetadataAndWaitsForReconciliation()
    {
        var module = TerminalSetupHelpers.SetupTerminalWindows(this);
        var registration = module.SetupVoid("registerTerminalWindowButton", _ => true);
        var adoption = module.SetupVoid("adoptTerminalWindows", _ => true);
        var opened = new List<(string Key, TerminalWindowOpenResult Result)>();
        var checkedKeys = Channel.CreateUnbounded<string[]>();
        var cut = Render<TerminalWindowButton>(builder => builder
            .Add(p => p.Label, "Open")
            .Add(p => p.Disabled, true)
            .Add(p => p.WindowKeysToAdopt, ["first", "inactive"])
            .Add(p => p.OnWindowOpened, launch => opened.Add(launch))
            .Add(p => p.OnWindowsAdopted, keys => { checkedKeys.Writer.TryWrite(keys); }));
 
        Assert.Single(registration.Invocations);
        Assert.Empty(adoption.Invocations);
        Assert.False(checkedKeys.Reader.TryPeek(out _));
        registration.SetVoidResult();
 
        // Registration renders before starting adoption, which is deliberately paused here. No subsequent render
        // can wake WaitForAssertion, so observe the interop invocation independently on the renderer instead.
        await AsyncTestHelpers.AssertIsTrueRetryAsync(
            () => cut.InvokeAsync(() => adoption.Invocations.Count > 0),
            "Window adoption did not start after registration completed.");
        var invocation = Assert.Single(adoption.Invocations);
        Assert.Equal(registration.Invocations.Single().Arguments[1], invocation.Arguments[0]);
        Assert.Equal(["first", "inactive"], Assert.IsType<string[]>(invocation.Arguments[1]));
        Assert.True(cut.FindComponent<FluentButton>().Instance.Disabled);
        cut.Render();
        Assert.Single(registration.Invocations);
        Assert.Single(adoption.Invocations);
 
        var launcher = TerminalSetupHelpers.GetWindowLauncher(this, cut);
        await cut.InvokeAsync(() => launcher.OnTerminalWindowOpenedAsync("inactive", "adopted"));
        Assert.Equal([("inactive", TerminalWindowOpenResult.Adopted)], opened);
        Assert.False(checkedKeys.Reader.TryPeek(out _));
        adoption.SetVoidResult();
        Assert.Equal(["first", "inactive"], await checkedKeys.Reader.ReadAsync().AsTask().DefaultTimeout());
 
        cut.Render(builder => builder.Add(p => p.WindowKeysToAdopt, ["inactive", "new"]));
        Assert.Equal(["new"], await checkedKeys.Reader.ReadAsync().AsTask().DefaultTimeout());
        Assert.Equal(2, adoption.Invocations.Count);
        Assert.Equal(["new"], Assert.IsType<string[]>(adoption.Invocations.Last().Arguments[1]));
        Assert.Single(registration.Invocations);
        Assert.True(cut.FindComponent<FluentButton>().Instance.Disabled);
    }
 
    [Fact]
    public async Task Disposal_DuringAdoption_DoesNotSignalReadinessOrCloseWindows()
    {
        var module = TerminalSetupHelpers.SetupTerminalWindows(this);
        var adoption = module.SetupVoid("adoptTerminalWindows", _ => true);
        var checkedKeys = new List<string[]>();
        var cut = Render<TerminalWindowButton>(builder => builder
            .Add(p => p.Label, "Open")
            .Add(p => p.WindowKeysToAdopt, ["terminal"])
            .Add(p => p.OnWindowsAdopted, keys => checkedKeys.Add(keys)));
        Assert.Single(adoption.Invocations);
 
        await cut.InvokeAsync(() => cut.Instance.DisposeAsync().AsTask());
        adoption.SetVoidResult();
        cut.Render();
 
        Assert.Empty(checkedKeys);
        Assert.Equal(["registerTerminalWindowButton", "adoptTerminalWindows", "unregisterTerminalWindowButton"],
            module.Invocations.Select(i => i.Identifier));
    }
 
    [Fact]
    public void AdoptionFailure_DoesNotSignalReadinessOrRetryOnEveryRender()
    {
        var module = TerminalSetupHelpers.SetupTerminalWindows(this);
        var adoption = module.SetupVoid("adoptTerminalWindows", _ => true);
        adoption.SetException(new JSException("Window reconciliation failed"));
        var checkedKeys = new List<string[]>();
        var toasts = Render<FluentToastProvider>();
        var cut = Render<TerminalWindowButton>(builder => builder
            .Add(p => p.Label, "Open")
            .Add(p => p.WindowKeysToAdopt, ["terminal"])
            .Add(p => p.OnWindowsAdopted, keys => checkedKeys.Add(keys)));
 
        toasts.WaitForAssertion(() => Assert.Equal(Resources.TerminalStrings.TerminalWindowTrackingFailed,
            Assert.Single(toasts.FindComponents<FluentToast>()).Instance.Title));
        cut.Render();
        Assert.Empty(checkedKeys);
        Assert.Single(adoption.Invocations);
    }
 
    [Theory]
    [InlineData("registerTerminalWindowButton")]
    [InlineData("adoptTerminalWindows")]
    public async Task TrackingFailure_ReportsNewTerminalsWithoutRetrying(string operation)
    {
        var module = TerminalSetupHelpers.SetupTerminalWindows(this);
        var failure = module.SetupVoid(operation, _ => true);
        failure.SetException(new JSException("Browser storage unavailable"));
        var failedKeys = Channel.CreateUnbounded<string[]>();
        var checkedKeys = new List<string[]>();
        Render<FluentToastProvider>();
        var cut = Render<TerminalWindowButton>(builder => builder
            .Add(p => p.Label, "Open")
            .Add(p => p.WindowKeysToAdopt, ["first"])
            .Add(p => p.OnWindowsAdopted, keys => checkedKeys.Add(keys))
            .Add(p => p.OnWindowTrackingFailed, keys => { failedKeys.Writer.TryWrite(keys); }));
        Assert.Equal(["first"], await failedKeys.Reader.ReadAsync().AsTask().DefaultTimeout());
 
        cut.Render(builder => builder.Add(p => p.WindowKeysToAdopt, ["first", "new"]));
        Assert.Equal(["new"], await failedKeys.Reader.ReadAsync().AsTask().DefaultTimeout());
        cut.Render();
        Assert.False(failedKeys.Reader.TryPeek(out _));
        Assert.Empty(checkedKeys);
        Assert.Single(failure.Invocations);
    }
}