File: Controls\TerminalDisconnectedBannerTests.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.Collections.Concurrent;
using System.Threading.Channels;
using Aspire.Dashboard.Components.Controls;
using Aspire.Dashboard.Components.Tests.Shared;
using Aspire.Dashboard.Model;
using Aspire.Dashboard.Tests.Shared;
using Aspire.Tests.Shared.DashboardModel;
using Bunit;
using Google.Protobuf.WellKnownTypes;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.FluentUI.AspNetCore.Components;
using Xunit;
 
namespace Aspire.Dashboard.Components.Tests.Controls;
 
[UseCulture("en-US")]
public class TerminalDisconnectedBannerTests : DashboardTestContext
{
    [Theory]
    [InlineData("0", "Terminal disconnected (exit code 0). The last output has been preserved.", "Terminal disconnected (exit code 0). Show recovery actions")]
    [InlineData("42", "Terminal disconnected (exit code 42). The last output has been preserved.", "Terminal disconnected (exit code 42). Show recovery actions")]
    [InlineData("-1", "Terminal disconnected (exit code -1). The last output has been preserved.", "Terminal disconnected (exit code -1). Show recovery actions")]
    [InlineData(null, "Terminal disconnected. The last output has been preserved.", "Terminal disconnected. Show recovery actions")]
    public async Task Message_TracksReportedExitCodeAndClearsWhenResourceIsRemoved(string? exitCode, string message, string label)
    {
        var updates = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
        var initial = ModelTestHelpers.CreateResource(resourceName: "shell");
        var client = new TestDashboardClient(isEnabled: true, initialResources: [initial], resourceChannelProvider: () => updates);
        TerminalSetupHelpers.SetupTerminalComponents(this, client);
        var cut = Render<TerminalDisconnectedBanner>(builder => builder.Add(p => p.ResourceName, "shell"));
        cut.WaitForAssertion(() => Assert.Equal(1, client.ResourceSubscriptionCount));
        Assert.Equal("Terminal disconnected. The last output has been preserved.", cut.Find("[role=status]").TextContent);
 
        var resource = ModelTestHelpers.CreateResource(resourceName: "shell", state: KnownResourceState.Finished, properties: exitCode is null ? [] : new()
        {
            [KnownProperties.Resource.ExitCode] = new(KnownProperties.Resource.ExitCode, Value.ForString(exitCode),
                isValueSensitive: false, knownProperty: null, sortOrder: 0, displayName: null, isHighlighted: false)
        });
        await updates.Writer.WriteAsync([new(ResourceViewModelChangeType.Upsert, resource)]);
        cut.WaitForAssertion(() => Assert.Equal(message, cut.Find("[role=status]").TextContent));
        await cut.Find(".terminal-show-output").ClickAsync(new());
        Assert.Equal(label, cut.Find(".terminal-show-banner").GetAttribute("aria-label"));
        Assert.Equal(label, cut.Find(".terminal-show-banner").GetAttribute("title"));
 
        await updates.Writer.WriteAsync([new(ResourceViewModelChangeType.Delete, resource)]);
        cut.WaitForAssertion(() => Assert.Equal("Terminal disconnected. Show recovery actions", cut.Find(".terminal-show-banner").GetAttribute("aria-label")));
        await cut.Find(".terminal-show-banner").ClickAsync(new());
        Assert.Equal("Terminal disconnected. The last output has been preserved.", cut.Find("[role=status]").TextContent);
    }
 
    [Theory]
    [InlineData(CommandViewModel.StartCommand, ResourceCommandResponseKind.Succeeded)]
    [InlineData(CommandViewModel.RestartCommand, ResourceCommandResponseKind.Succeeded)]
    [InlineData(CommandViewModel.StartCommand, ResourceCommandResponseKind.Failed)]
    [InlineData(CommandViewModel.RestartCommand, ResourceCommandResponseKind.Cancelled)]
    public async Task Restart_ExecutesAdvertisedCommandForExactReplicaAndReportsResult(string commandName, ResourceCommandResponseKind resultKind)
    {
        var executions = new ConcurrentQueue<(string Resource, string Command)>();
        var response = new TaskCompletionSource<ResourceCommandResponseViewModel>(TaskCreationOptions.RunContinuationsAsynchronously);
        var resource = ModelTestHelpers.CreateResource(resourceName: "shell-replica",
            displayName: "shell", commands: [CreateCommand(commandName)]);
        var client = new TestDashboardClient(isEnabled: true, initialResources: [resource],
            resourceChannelProvider: () => Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>(),
            executeResourceCommand: (name, _, command, _, _) =>
            {
                executions.Enqueue((name, command.Name));
                return response.Task;
            });
        TerminalSetupHelpers.SetupTerminalComponents(this, client);
        Render<FluentToastProvider>();
        var restarted = 0;
        var cut = Render<TerminalDisconnectedBanner>(builder => builder
            .Add(p => p.ResourceName, "shell-replica")
            .Add(p => p.OnRestarted, () => restarted++));
        cut.WaitForAssertion(() => Assert.Single(cut.FindAll(".terminal-restart-resource")));
        Assert.Equal("Restart resource", cut.Find(".terminal-restart-resource").TextContent.Trim());
        Assert.Equal("Terminal disconnected. The last output has been preserved.", cut.Find("[role=status]").TextContent);
 
        var click = cut.InvokeAsync(() => cut.Find(".terminal-restart-resource").ClickAsync(new()));
        cut.WaitForAssertion(() => Assert.Equal([("shell-replica", commandName)], executions.ToArray()));
        Assert.True(cut.FindComponents<FluentButton>().Single(b => b.Instance.Class == "terminal-restart-resource").Instance.Disabled);
        response.SetResult(new ResourceCommandResponseViewModel { Kind = resultKind, Message = "Command result" });
        await click;
        Assert.False(cut.FindComponents<FluentButton>().Single(b => b.Instance.Class == "terminal-restart-resource").Instance.Disabled);
        var notification = Assert.Single(Services.GetRequiredService<Dashboard.Model.INotificationService>().GetNotifications()).Entry;
        Assert.Equal(resultKind switch
        {
            ResourceCommandResponseKind.Succeeded => MessageBarIntent.Success,
            ResourceCommandResponseKind.Cancelled => MessageBarIntent.Info,
            _ => MessageBarIntent.Error
        },
            notification.Intent);
        Assert.Equal(resultKind == ResourceCommandResponseKind.Succeeded ? 1 : 0, restarted);
        Assert.Single(cut.FindAll("[role=status]"));
    }
 
    [Fact]
    public async Task Restart_UsesCapturedResourceNameWhileResourcesChange()
    {
        var updates = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
        var response = new TaskCompletionSource<ResourceCommandResponseViewModel>(TaskCreationOptions.RunContinuationsAsynchronously);
        var resource = ModelTestHelpers.CreateResource(resourceName: "shell-replica",
            displayName: "shell", commands: [CreateCommand(CommandViewModel.RestartCommand)]);
        var client = new TestDashboardClient(isEnabled: true, initialResources: [resource],
            resourceChannelProvider: () => updates, executeResourceCommand: (_, _, _, _, _) => response.Task);
        TerminalSetupHelpers.SetupTerminalComponents(this, client);
        var toasts = Render<FluentToastProvider>();
        var cut = Render<TerminalDisconnectedBanner>(builder => builder.Add(p => p.ResourceName, "shell-replica"));
        cut.WaitForAssertion(() => Assert.Single(cut.FindAll(".terminal-restart-resource")));
 
        var click = cut.InvokeAsync(() => cut.Find(".terminal-restart-resource").ClickAsync(new()));
        cut.WaitForAssertion(() => Assert.True(cut.FindComponents<FluentButton>()
            .Single(b => b.Instance.Class == "terminal-restart-resource").Instance.Disabled));
        var updated = ModelTestHelpers.CreateResource(resourceName: "shell-replica", displayName: "shell",
            commands: [CreateCommand(CommandViewModel.RestartCommand)], state: KnownResourceState.Finished, properties: new()
            {
                [KnownProperties.Resource.ExitCode] = new(KnownProperties.Resource.ExitCode, Value.ForString("42"),
                    isValueSensitive: false, knownProperty: null, sortOrder: 0, displayName: null, isHighlighted: false)
            });
        await updates.Writer.WriteAsync([
            new(ResourceViewModelChangeType.Upsert, updated),
            new(ResourceViewModelChangeType.Upsert, ModelTestHelpers.CreateResource(resourceName: "shell-other", displayName: "shell"))]);
        cut.WaitForAssertion(() => Assert.Equal("Terminal disconnected (exit code 42). The last output has been preserved.",
            cut.Find("[role=status]").TextContent));
        response.SetResult(new ResourceCommandResponseViewModel { Kind = ResourceCommandResponseKind.Succeeded });
        await click;
 
        // MainLayout rerenders the provider when a command replaces its notification.
        toasts.Render();
        toasts.WaitForAssertion(() => Assert.Equal($"shell \"{CommandViewModel.RestartCommand}\" succeeded",
            toasts.FindComponent<FluentToast>().Instance.Title));
    }
 
    [Theory]
    [InlineData(false, false)]
    [InlineData(true, false)]
    [InlineData(false, true)]
    public void Restart_ReadOnlyViewsDoNotOfferResourceMutation(bool readOnly, bool clientReadOnly)
    {
        var resource = ModelTestHelpers.CreateResource(resourceName: "shell",
            commands: [CreateCommand(CommandViewModel.RestartCommand)]);
        var client = new TestDashboardClient(isEnabled: true, isReadOnly: clientReadOnly, initialResources: [resource],
            resourceChannelProvider: () => Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>());
        TerminalSetupHelpers.SetupTerminalComponents(this, client);
        var cut = Render<TerminalDisconnectedBanner>(builder => builder
            .Add(p => p.ResourceName, "shell")
            .Add(p => p.ReadOnly, readOnly));
        cut.WaitForAssertion(() => Assert.Equal(1, client.ResourceSubscriptionCount));
        Assert.Equal(readOnly || clientReadOnly ? 0 : 1, cut.FindAll(".terminal-restart-resource").Count);
    }
 
    [Fact]
    public async Task Restart_TracksCommandAvailabilityAndResourceRemoval()
    {
        var updates = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
        var resource = ModelTestHelpers.CreateResource(resourceName: "shell", commands:
            [CreateCommand(CommandViewModel.RestartCommand, CommandViewModelState.Hidden)]);
        var client = new TestDashboardClient(isEnabled: true, initialResources: [resource], resourceChannelProvider: () => updates);
        TerminalSetupHelpers.SetupTerminalComponents(this, client);
        var cut = Render<TerminalDisconnectedBanner>(builder => builder.Add(p => p.ResourceName, "shell"));
        cut.WaitForAssertion(() => Assert.Equal(1, client.ResourceSubscriptionCount));
        Assert.Empty(cut.FindAll(".terminal-restart-resource"));
 
        await updates.Writer.WriteAsync([new(ResourceViewModelChangeType.Upsert, ModelTestHelpers.CreateResource(resourceName: "shell",
            commands: [CreateCommand(CommandViewModel.RestartCommand, CommandViewModelState.Disabled)]))]);
        cut.WaitForAssertion(() => Assert.True(cut.FindComponents<FluentButton>().Single(b => b.Instance.Class == "terminal-restart-resource").Instance.Disabled));
 
        await updates.Writer.WriteAsync([new(ResourceViewModelChangeType.Upsert, ModelTestHelpers.CreateResource(resourceName: "shell",
            commands: [CreateCommand(CommandViewModel.RestartCommand, CommandViewModelState.Disabled),
                CreateCommand(CommandViewModel.StartCommand)]))]);
        cut.WaitForAssertion(() => Assert.False(cut.FindComponents<FluentButton>().Single(b => b.Instance.Class == "terminal-restart-resource").Instance.Disabled));
 
        await updates.Writer.WriteAsync([new(ResourceViewModelChangeType.Delete, resource)]);
        cut.WaitForAssertion(() => Assert.Empty(cut.FindAll(".terminal-restart-resource")));
        Assert.Single(cut.FindAll("[role=status]"));
    }
 
    [Fact]
    public async Task Close_InvokesOwningSurfaceWithoutSubscribingToResources()
    {
        var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var closeCount = 0;
        var client = TerminalSetupHelpers.CreateTerminalDashboardClient();
        TerminalSetupHelpers.SetupTerminalComponents(this, client);
        var cut = Render<TerminalDisconnectedBanner>(builder => builder.Add(p => p.OnClose,
            EventCallback.Factory.Create(this, () =>
            {
                closeCount++;
                return completion.Task;
            })));
        Assert.Equal("Close tab", cut.Find(".terminal-close-tab").TextContent.Trim());
        var click = cut.InvokeAsync(() => cut.Find(".terminal-close-tab").ClickAsync(new()));
        cut.WaitForAssertion(() => Assert.Equal(1, closeCount));
        Assert.True(cut.FindComponents<FluentButton>().Single(b => b.Instance.Class == "terminal-close-tab").Instance.Disabled);
        completion.SetResult();
        await click;
        Assert.False(cut.FindComponents<FluentButton>().Single(b => b.Instance.Class == "terminal-close-tab").Instance.Disabled);
        Assert.Equal(0, client.ResourceSubscriptionCount);
    }
 
    private static CommandViewModel CreateCommand(string name, CommandViewModelState state = CommandViewModelState.Enabled) =>
        new(name, state, name, string.Empty, string.Empty, [], false, "ArrowClockwise", IconVariant.Regular);
}