File: Pages\ConsoleLogsTerminalTests.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.Pages;
using Aspire.Dashboard.Components.Resize;
using Aspire.Dashboard.Model;
using Aspire.Dashboard.Tests.Shared;
using Aspire.Dashboard.Utils;
using Aspire.Tests.Shared.DashboardModel;
using Bunit;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.FluentUI.AspNetCore.Components;
using Xunit;
using Value = Google.Protobuf.WellKnownTypes.Value;
 
namespace Aspire.Dashboard.Components.Tests.Pages;
 
// Focused bUnit coverage for the central user-visible render branch in
// ConsoleLogs.razor: when the selected resource has WithTerminal() applied,
// the page must mount BOTH TerminalView and LogViewer (the two are toggled
// via the View dropdown; both stay mounted so neither tears down on flips).
// For non-terminal resources only LogViewer is mounted. The HasTerminal()
// predicate itself has unit coverage in ResourceViewModelExtensionsTerminalTests,
// but only a component-level test proves the page actually re-evaluates the
// flag on selection change and that the render branch wires the correct
// parameters through to TerminalView.
//
// We deliberately stop short of a full end-to-end Playwright test here.
// End-to-end coverage requires DCP terminal-host changes that have not yet
// landed in the repo (the production WebSocket path can't be exercised
// against the unmodified DCP shipped in main). Once those changes land we can
// add a Playwright scenario that exercises a real WithTerminal() AppHost
// through the dashboard UI. Until then this bUnit test locks down the
// render-branch contract.
public partial class ConsoleLogsTests
{
    [Fact]
    public async Task TerminalResource_Live_Selected_RendersBothViews_DefaultsToTerminal()
    {
        var consoleLogsChannel = Channel.CreateUnbounded<IReadOnlyList<ResourceLogLine>>();
        var resourceChannel = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
        var terminalResource = CreateTerminalResource("terminal-resource", replicaIndex: 0, replicaCount: 1, state: KnownResourceState.Running);
        var dashboardClient = new TestDashboardClient(
            isEnabled: true,
            consoleLogsChannelProvider: _ => consoleLogsChannel,
            resourceChannelProvider: () => resourceChannel,
            initialResources: [terminalResource]);
 
        SetupConsoleLogsServices(dashboardClient);
        SetupTerminalViewJsInterop();
 
        var navigationManager = Services.GetRequiredService<NavigationManager>();
        navigationManager.NavigateTo(DashboardUrls.ConsoleLogsUrl(resource: "terminal-resource"));
 
        var dimensionManager = Services.GetRequiredService<DimensionManager>();
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        dimensionManager.InvokeOnViewportInformationChanged(viewport);
 
        var cut = RenderComponent<Components.Pages.ConsoleLogs>(builder =>
        {
            builder.Add(p => p.ResourceName, "terminal-resource");
            builder.Add(p => p.ViewportInformation, viewport);
        });
 
        var instance = cut.Instance;
        cut.WaitForState(() => instance.PageViewModel.SelectedResource.Id?.InstanceId == terminalResource.Name);
        // Page wires _selectedResourceHasTerminal in SubscribeAsync after the
        // selection update; wait for the dual-mount branch to take effect.
        cut.WaitForState(() => cut.FindComponents<TerminalView>().Count > 0);
 
        // Both views are mounted concurrently for terminal-enabled resources
        // so the View dropdown can flip between them without tearing down
        // the JS terminal or the LogViewer subscription.
        Assert.Single(cut.FindComponents<TerminalView>());
        Assert.Single(cut.FindComponents<LogViewer>());
 
        // The resource is live (Running), so its PTY is active — the page
        // defaults to the Terminal view because that's the surface the user
        // navigated to see.
        Assert.Equal(ConsoleLogs.ConsoleLogsView.Terminal, instance.ActiveViewForTest);
 
        var terminalView = cut.FindComponents<TerminalView>()[0].Instance;
        Assert.Equal(terminalResource.DisplayName, terminalView.ResourceName);
        Assert.Equal(0, terminalView.ReplicaIndex);
 
        await Task.CompletedTask;
    }
 
    [Fact]
    public async Task TerminalResource_ViewPicker_MarksActiveViewAsChecked()
    {
        var consoleLogsChannel = Channel.CreateUnbounded<IReadOnlyList<ResourceLogLine>>();
        var resourceChannel = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
        var terminalResource = CreateTerminalResource("terminal-resource", replicaIndex: 0, replicaCount: 1, state: KnownResourceState.Running);
        var dashboardClient = new TestDashboardClient(
            isEnabled: true,
            consoleLogsChannelProvider: _ => consoleLogsChannel,
            resourceChannelProvider: () => resourceChannel,
            initialResources: [terminalResource]);
 
        SetupConsoleLogsServices(dashboardClient);
        SetupTerminalViewJsInterop();
 
        var navigationManager = Services.GetRequiredService<NavigationManager>();
        navigationManager.NavigateTo(DashboardUrls.ConsoleLogsUrl(resource: "terminal-resource"));
 
        var dimensionManager = Services.GetRequiredService<DimensionManager>();
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        dimensionManager.InvokeOnViewportInformationChanged(viewport);
 
        var cut = RenderComponent<Components.Pages.ConsoleLogs>(builder =>
        {
            builder.Add(p => p.ResourceName, "terminal-resource");
            builder.Add(p => p.ViewportInformation, viewport);
        });
 
        var instance = cut.Instance;
        cut.WaitForState(() => instance.PageViewModel.SelectedResource.Id?.InstanceId == terminalResource.Name);
        cut.WaitForState(() => cut.FindComponents<TerminalView>().Count > 0);
 
        // The view-toggle items are the first two entries in the menu, added in
        // Console-then-Terminal order (see UpdateMenuButtons). Both are modeled as
        // checkable menu items so assistive technology can announce the selection;
        // the live resource defaults to Terminal, so only the Terminal item is
        // checked.
        cut.WaitForState(() => instance.ActiveViewForTest == ConsoleLogs.ConsoleLogsView.Terminal);
        Assert.Equal(MenuItemRole.MenuItemCheckbox, instance.LogsMenuItemsForTest[0].Role);
        Assert.Equal(MenuItemRole.MenuItemCheckbox, instance.LogsMenuItemsForTest[1].Role);
        Assert.False(instance.LogsMenuItemsForTest[0].Checked);
        Assert.True(instance.LogsMenuItemsForTest[1].Checked);
 
        // Switching to Console moves the checked state to the Console item.
        await cut.InvokeAsync(() => instance.HandleViewChangedForTestAsync(nameof(ConsoleLogs.ConsoleLogsView.Console)));
        cut.WaitForState(() => instance.ActiveViewForTest == ConsoleLogs.ConsoleLogsView.Console);
        Assert.True(instance.LogsMenuItemsForTest[0].Checked);
        Assert.False(instance.LogsMenuItemsForTest[1].Checked);
    }
 
    [Fact]
    public async Task TerminalResource_NotLive_Selected_RendersBothViews_DefaultsToConsole()
    {
        var consoleLogsChannel = Channel.CreateUnbounded<IReadOnlyList<ResourceLogLine>>();
        var resourceChannel = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
        // A terminal-enabled resource that is still Waiting has no live PTY yet,
        // so pre-PTY hosting messages (WaitFor, startup failures) belong on the
        // Console view. The page must NOT flip to Terminal until the resource is
        // running.
        var terminalResource = CreateTerminalResource("terminal-resource", replicaIndex: 0, replicaCount: 1, state: KnownResourceState.Waiting);
        var dashboardClient = new TestDashboardClient(
            isEnabled: true,
            consoleLogsChannelProvider: _ => consoleLogsChannel,
            resourceChannelProvider: () => resourceChannel,
            initialResources: [terminalResource]);
 
        SetupConsoleLogsServices(dashboardClient);
        SetupTerminalViewJsInterop();
 
        var navigationManager = Services.GetRequiredService<NavigationManager>();
        navigationManager.NavigateTo(DashboardUrls.ConsoleLogsUrl(resource: "terminal-resource"));
 
        var dimensionManager = Services.GetRequiredService<DimensionManager>();
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        dimensionManager.InvokeOnViewportInformationChanged(viewport);
 
        var cut = RenderComponent<Components.Pages.ConsoleLogs>(builder =>
        {
            builder.Add(p => p.ResourceName, "terminal-resource");
            builder.Add(p => p.ViewportInformation, viewport);
        });
 
        var instance = cut.Instance;
        cut.WaitForState(() => instance.PageViewModel.SelectedResource.Id?.InstanceId == terminalResource.Name);
        cut.WaitForState(() => cut.FindComponents<TerminalView>().Count > 0);
 
        // Both views mount regardless of state so a later Running transition
        // doesn't need to re-create the terminal, but the non-live resource
        // stays on Console.
        Assert.Single(cut.FindComponents<TerminalView>());
        Assert.Single(cut.FindComponents<LogViewer>());
        Assert.Equal(ConsoleLogs.ConsoleLogsView.Console, instance.ActiveViewForTest);
 
        await Task.CompletedTask;
    }
 
    [Fact]
    public async Task TerminalResource_NotLive_Selected_ThenGoesLive_StaysOnConsole()
    {
        var consoleLogsChannel = Channel.CreateUnbounded<IReadOnlyList<ResourceLogLine>>();
        var resourceChannel = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
        // The Terminal default is latched at resource-selection time, not on
        // every state update. A Waiting terminal resource the user has already
        // selected (and is reading on Console) must stay on Console when it
        // later transitions to Running via a resource-channel update — otherwise
        // the view would yank out from under the user the moment the PTY comes
        // up. This locks down the resetView gate in SubscribeAsync against a
        // future change that re-defaults on state-update renders.
        var terminalResource = CreateTerminalResource("terminal-resource", replicaIndex: 0, replicaCount: 1, state: KnownResourceState.Waiting);
        var dashboardClient = new TestDashboardClient(
            isEnabled: true,
            consoleLogsChannelProvider: _ => consoleLogsChannel,
            resourceChannelProvider: () => resourceChannel,
            initialResources: [terminalResource]);
 
        SetupConsoleLogsServices(dashboardClient);
        SetupTerminalViewJsInterop();
 
        var navigationManager = Services.GetRequiredService<NavigationManager>();
        navigationManager.NavigateTo(DashboardUrls.ConsoleLogsUrl(resource: "terminal-resource"));
 
        var dimensionManager = Services.GetRequiredService<DimensionManager>();
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        dimensionManager.InvokeOnViewportInformationChanged(viewport);
 
        var cut = RenderComponent<Components.Pages.ConsoleLogs>(builder =>
        {
            builder.Add(p => p.ResourceName, "terminal-resource");
            builder.Add(p => p.ViewportInformation, viewport);
        });
 
        var instance = cut.Instance;
        cut.WaitForState(() => instance.PageViewModel.SelectedResource.Id?.InstanceId == terminalResource.Name);
        cut.WaitForState(() => cut.FindComponents<TerminalView>().Count > 0);
 
        // The non-live resource starts on Console.
        Assert.Equal(ConsoleLogs.ConsoleLogsView.Console, instance.ActiveViewForTest);
 
        // The same resource transitions Waiting -> Running through the resource
        // subscription — the production path that background-processes state
        // changes. This is an Upsert of an already-selected resource, not a
        // selection change, so it must not re-apply the selection-time default.
        var runningResource = CreateTerminalResource("terminal-resource", replicaIndex: 0, replicaCount: 1, state: KnownResourceState.Running);
        resourceChannel.Writer.TryWrite([
            new ResourceViewModelChange(ResourceViewModelChangeType.Upsert, runningResource)
        ]);
 
        // Wait until the page has actually applied the Running snapshot so the
        // assertion below can't pass just because the update hasn't arrived yet.
        cut.WaitForState(() => instance.GetResourceSnapshotForTest("terminal-resource")?.KnownState == KnownResourceState.Running);
 
        // The running default only applies on the initial selection, so the view
        // must still be Console after the live transition.
        Assert.Equal(ConsoleLogs.ConsoleLogsView.Console, instance.ActiveViewForTest);
    }
 
    [Fact]
    public async Task TerminalResource_Live_ManualConsoleSelection_SurvivesFilterChange()
    {
        var consoleLogsChannel = Channel.CreateUnbounded<IReadOnlyList<ResourceLogLine>>();
        var resourceChannel = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
        var terminalResource = CreateTerminalResource("terminal-resource", replicaIndex: 0, replicaCount: 1, state: KnownResourceState.Running);
        var dashboardClient = new TestDashboardClient(
            isEnabled: true,
            consoleLogsChannelProvider: _ => consoleLogsChannel,
            resourceChannelProvider: () => resourceChannel,
            initialResources: [terminalResource]);
 
        SetupConsoleLogsServices(dashboardClient);
        SetupTerminalViewJsInterop();
 
        var navigationManager = Services.GetRequiredService<NavigationManager>();
        navigationManager.NavigateTo(DashboardUrls.ConsoleLogsUrl(resource: "terminal-resource"));
 
        var dimensionManager = Services.GetRequiredService<DimensionManager>();
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        dimensionManager.InvokeOnViewportInformationChanged(viewport);
 
        var cut = RenderComponent<Components.Pages.ConsoleLogs>(builder =>
        {
            builder.Add(p => p.ResourceName, "terminal-resource");
            builder.Add(p => p.ViewportInformation, viewport);
        });
 
        var instance = cut.Instance;
        cut.WaitForState(() => instance.PageViewModel.SelectedResource.Id?.InstanceId == terminalResource.Name);
        cut.WaitForState(() => cut.FindComponents<TerminalView>().Count > 0);
 
        // The live resource defaults to Terminal, then the user deliberately
        // picks Console from the ⋯ menu.
        Assert.Equal(ConsoleLogs.ConsoleLogsView.Terminal, instance.ActiveViewForTest);
        await cut.InvokeAsync(() => instance.HandleViewChangedForTestAsync(nameof(ConsoleLogs.ConsoleLogsView.Console)));
        cut.WaitForState(() => instance.ActiveViewForTest == ConsoleLogs.ConsoleLogsView.Console);
 
        // Clearing the console logs raises ConsoleLogsManager.OnFiltersChanged,
        // which re-enters SubscribeAsync. That refresh must preserve the user's
        // manual Console selection rather than snapping the running resource back
        // to Terminal.
        var consoleLogsManager = Services.GetRequiredService<ConsoleLogsManager>();
        await cut.InvokeAsync(() => consoleLogsManager.UpdateFiltersAsync(ConsoleLogsFilters.CreateClearAll(DateTime.UtcNow)));
 
        Assert.Equal(ConsoleLogs.ConsoleLogsView.Console, instance.ActiveViewForTest);
    }
 
    [Fact]
    public async Task SwitchingFromTerminalToNonTerminalResource_TearsDownTerminalView()
    {
        var consoleLogsChannel = Channel.CreateUnbounded<IReadOnlyList<ResourceLogLine>>();
        var resourceChannel = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
        var terminalResource = CreateTerminalResource("terminal-resource", replicaIndex: 0, replicaCount: 1);
        var plainResource = ModelTestHelpers.CreateResource(resourceName: "plain-resource", state: KnownResourceState.Running);
        var dashboardClient = new TestDashboardClient(
            isEnabled: true,
            consoleLogsChannelProvider: _ => consoleLogsChannel,
            resourceChannelProvider: () => resourceChannel,
            initialResources: [terminalResource, plainResource]);
 
        SetupConsoleLogsServices(dashboardClient);
        SetupTerminalViewJsInterop();
 
        var navigationManager = Services.GetRequiredService<NavigationManager>();
        navigationManager.NavigateTo(DashboardUrls.ConsoleLogsUrl(resource: "terminal-resource"));
 
        var dimensionManager = Services.GetRequiredService<DimensionManager>();
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        dimensionManager.InvokeOnViewportInformationChanged(viewport);
 
        var cut = RenderComponent<Components.Pages.ConsoleLogs>(builder =>
        {
            builder.Add(p => p.ResourceName, "terminal-resource");
            builder.Add(p => p.ViewportInformation, viewport);
        });
 
        var instance = cut.Instance;
        cut.WaitForState(() => instance.PageViewModel.SelectedResource.Id?.InstanceId == terminalResource.Name);
        cut.WaitForState(() => cut.FindComponents<TerminalView>().Count > 0);
 
        // Sanity: terminal resource mounts both views.
        Assert.Single(cut.FindComponents<TerminalView>());
        Assert.Single(cut.FindComponents<LogViewer>());
 
        // Switch to the plain resource. Use the same ResourceSelect-driven
        // path as ResourceName_SubscribeOnLoadAndChange_* so we exercise the
        // production selection-changed pipeline, not a direct parameter set.
        navigationManager.LocationChanged += (sender, e) =>
        {
            cut.SetParametersAndRender(builder =>
            {
                builder.Add(m => m.ResourceName, "plain-resource");
            });
        };
        var resourceSelect = cut.FindComponent<ResourceSelect>();
        var innerSelect = resourceSelect.Find("fluent-select");
        innerSelect.Change("plain-resource");
 
        cut.WaitForState(() => instance.PageViewModel.SelectedResource.Id?.InstanceId == plainResource.Name);
        // For a non-terminal resource the TerminalView is not mounted at all
        // (only LogViewer is needed), so the dual-mount branch is skipped.
        cut.WaitForState(() => cut.FindComponents<TerminalView>().Count == 0);
 
        Assert.Empty(cut.FindComponents<TerminalView>());
        Assert.Single(cut.FindComponents<LogViewer>());
 
        await Task.CompletedTask;
    }
 
    [Fact]
    public async Task SwitchingFromTerminalToNonTerminalResource_RestoresSearchFilter()
    {
        var consoleLogsChannel = Channel.CreateUnbounded<IReadOnlyList<ResourceLogLine>>();
        var resourceChannel = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
        var terminalResource = CreateTerminalResource("terminal-resource", replicaIndex: 0, replicaCount: 1);
        var plainResource = ModelTestHelpers.CreateResource(resourceName: "plain-resource", state: KnownResourceState.Running);
        var dashboardClient = new TestDashboardClient(
            isEnabled: true,
            consoleLogsChannelProvider: _ => consoleLogsChannel,
            resourceChannelProvider: () => resourceChannel,
            initialResources: [terminalResource, plainResource]);
 
        SetupConsoleLogsServices(dashboardClient);
        SetupTerminalViewJsInterop();
 
        var navigationManager = Services.GetRequiredService<NavigationManager>();
        navigationManager.NavigateTo(DashboardUrls.ConsoleLogsUrl(resource: "terminal-resource"));
 
        var dimensionManager = Services.GetRequiredService<DimensionManager>();
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        dimensionManager.InvokeOnViewportInformationChanged(viewport);
 
        var cut = RenderComponent<Components.Pages.ConsoleLogs>(builder =>
        {
            builder.Add(p => p.ResourceName, "terminal-resource");
            builder.Add(p => p.ViewportInformation, viewport);
        });
 
        var instance = cut.Instance;
        cut.WaitForState(() => instance.PageViewModel.SelectedResource.Id?.InstanceId == terminalResource.Name);
        cut.WaitForState(() => cut.FindComponents<TerminalView>().Count > 0);
 
        // Switch to the Terminal view via the user picker so the log filter
        // is hidden (the filter only applies to LogViewer content, not to
        // a live PTY). Then verify that navigating to a non-terminal
        // resource restores the filter UI.
        await cut.InvokeAsync(() => instance.HandleViewChangedForTestAsync(nameof(ConsoleLogs.ConsoleLogsView.Terminal)));
        cut.WaitForState(() => instance.ActiveViewForTest == ConsoleLogs.ConsoleLogsView.Terminal);
        Assert.Empty(cut.FindComponents<FluentSearch>());
 
        navigationManager.LocationChanged += (sender, e) =>
        {
            cut.SetParametersAndRender(builder =>
            {
                builder.Add(m => m.ResourceName, "plain-resource");
            });
        };
        var resourceSelect = cut.FindComponent<ResourceSelect>();
        var innerSelect = resourceSelect.Find("fluent-select");
        innerSelect.Change("plain-resource");
 
        cut.WaitForState(() => instance.PageViewModel.SelectedResource.Id?.InstanceId == plainResource.Name);
        cut.WaitForState(() => cut.FindComponents<LogViewer>().Count > 0);
 
        consoleLogsChannel.Writer.TryWrite([
            new ResourceLogLine(1, "first log", IsErrorMessage: false),
            new ResourceLogLine(2, "filtered log", IsErrorMessage: false)
        ]);
        cut.WaitForAssertion(() => Assert.Equal(2, cut.FindAll(".log-content").Count));
 
        var search = Assert.Single(cut.FindComponents<FluentSearch>());
        await cut.InvokeAsync(() => search.Instance.ValueChanged.InvokeAsync("filtered"));
 
        cut.WaitForAssertion(() =>
        {
            var logViewer = Assert.Single(cut.FindComponents<LogViewer>());
            Assert.Equal("filtered", logViewer.Instance.FilterText);
 
            var content = Assert.Single(cut.FindAll(".log-content"));
            Assert.Contains("filtered log", content.TextContent);
        });
    }
 
    [Fact]
    public async Task TerminalResource_ViewToggle_RenderedDisplayStylesMatchActiveView()
    {
        // The user-visible contract for the view flip is not the _activeView
        // enum but the `display: contents` / `display: none` pair on the two
        // wrapper divs in ConsoleLogs.razor. Lock down that mapping so a
        // future edit that inverts the ternary or swaps the wrappers can't
        // pass the enum-based tests while producing a broken UI.
        var consoleLogsChannel = Channel.CreateUnbounded<IReadOnlyList<ResourceLogLine>>();
        var resourceChannel = Channel.CreateUnbounded<IReadOnlyList<ResourceViewModelChange>>();
        // Use a non-live (Waiting) terminal resource so the page starts on the
        // Console view; a Running resource would default straight to Terminal
        // (its PTY is live) and we want to exercise the Console→Terminal flip.
        var terminalResource = CreateTerminalResource("terminal-resource", replicaIndex: 0, replicaCount: 1, state: KnownResourceState.Waiting);
        var dashboardClient = new TestDashboardClient(
            isEnabled: true,
            consoleLogsChannelProvider: _ => consoleLogsChannel,
            resourceChannelProvider: () => resourceChannel,
            initialResources: [terminalResource]);
 
        SetupConsoleLogsServices(dashboardClient);
        SetupTerminalViewJsInterop();
 
        var navigationManager = Services.GetRequiredService<NavigationManager>();
        navigationManager.NavigateTo(DashboardUrls.ConsoleLogsUrl(resource: "terminal-resource"));
 
        var dimensionManager = Services.GetRequiredService<DimensionManager>();
        var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false);
        dimensionManager.InvokeOnViewportInformationChanged(viewport);
 
        var cut = RenderComponent<Components.Pages.ConsoleLogs>(builder =>
        {
            builder.Add(p => p.ResourceName, "terminal-resource");
            builder.Add(p => p.ViewportInformation, viewport);
        });
 
        var instance = cut.Instance;
        cut.WaitForState(() => instance.PageViewModel.SelectedResource.Id?.InstanceId == terminalResource.Name);
        cut.WaitForState(() => cut.FindComponents<TerminalView>().Count > 0);
 
        // Initial state: page defaults to Console. The Console wrapper (the
        // one containing LogViewer) must be visible; the Terminal wrapper
        // must be hidden. Locate each wrapper by descending from the
        // component's rendered element.
        var (consoleWrapper, terminalWrapper) = FindViewWrappers(cut);
        Assert.Contains("display: contents", consoleWrapper.GetAttribute("style"));
        Assert.Contains("display: none", terminalWrapper.GetAttribute("style"));
 
        // Flip to Terminal via the user-picked latch path and re-query the
        // wrappers — the render pass must swap the two `display` values.
        await cut.InvokeAsync(() => instance.HandleViewChangedForTestAsync(nameof(ConsoleLogs.ConsoleLogsView.Terminal)));
        cut.WaitForState(() => instance.ActiveViewForTest == ConsoleLogs.ConsoleLogsView.Terminal);
 
        (consoleWrapper, terminalWrapper) = FindViewWrappers(cut);
        Assert.Contains("display: none", consoleWrapper.GetAttribute("style"));
        Assert.Contains("display: contents", terminalWrapper.GetAttribute("style"));
    }
 
    [Fact]
    public void TerminalView_InitialRender_ReconnectsWhenResourceChangesDuringInitialization()
    {
        var module = JSInterop.SetupModule("/Components/Controls/TerminalView.razor.js");
        var initTerminal = module.Setup<int>("initTerminal", _ => true);
        var reconnectTerminal = module.Setup<int>("reconnectTerminal", _ => true);
        reconnectTerminal.SetResult(2);
 
        var cut = RenderComponent<TerminalView>(builder =>
        {
            builder.Add(p => p.ResourceName, "first-resource");
            builder.Add(p => p.ReplicaIndex, 0);
        });
 
        cut.SetParametersAndRender(builder =>
        {
            builder.Add(p => p.ResourceName, "second-resource");
            builder.Add(p => p.ReplicaIndex, 1);
        });
 
        initTerminal.SetResult(1);
 
        cut.WaitForAssertion(() =>
        {
            var init = Assert.Single(initTerminal.Invocations);
            var reconnect = Assert.Single(reconnectTerminal.Invocations);
            var initUrl = Assert.IsType<string>(init.Arguments[1]);
            var reconnectUrl = Assert.IsType<string>(reconnect.Arguments[1]);
 
            Assert.Contains("resource=first-resource", initUrl);
            Assert.Contains("replica=0", initUrl);
            Assert.Equal(1, reconnect.Arguments[0]);
            Assert.Contains("resource=second-resource", reconnectUrl);
            Assert.Contains("replica=1", reconnectUrl);
        });
    }
 
    private static (AngleSharp.Dom.IElement Console, AngleSharp.Dom.IElement Terminal) FindViewWrappers(
        Bunit.IRenderedComponent<Components.Pages.ConsoleLogs> cut)
    {
        // LogViewer renders <div class="log-overflow ..."> as its root and
        // TerminalView renders <div class="terminal-container">. The two
        // Console/Terminal wrappers are the enclosing divs that carry the
        // `display: contents;` / `display: none;` inline style bound to
        // _activeView. Walk up from each component root until we hit that
        // wrapper — the raw inline style is the user-visible flip contract.
        var logRoot = cut.Find(".log-overflow");
        var terminalRoot = cut.Find(".terminal-container");
 
        var consoleWrapper = FindWrapperWithDisplayStyle(logRoot);
        var terminalWrapper = FindWrapperWithDisplayStyle(terminalRoot);
 
        Assert.NotNull(consoleWrapper);
        Assert.NotNull(terminalWrapper);
        return (consoleWrapper!, terminalWrapper!);
    }
 
    private static AngleSharp.Dom.IElement? FindWrapperWithDisplayStyle(AngleSharp.Dom.IElement start)
    {
        var current = start.ParentElement;
        while (current is not null)
        {
            var style = current.GetAttribute("style") ?? string.Empty;
            if (current.TagName.Equals("DIV", StringComparison.OrdinalIgnoreCase) &&
                style.Contains("display:", StringComparison.Ordinal))
            {
                return current;
            }
            current = current.ParentElement;
        }
        return null;
    }
 
    private void SetupTerminalViewJsInterop()
    {
        // TerminalView.OnAfterRenderAsync does:
        //   import("/Components/Controls/TerminalView.razor.js")  ->  module
        //   module.initTerminal(elementRef, wsUrl, dotNetRef)     ->  int terminalId
        // Both calls must be matched or bUnit's strict JSInterop throws and
        // the renderer reports an unhandled exception, preventing the test from
        // reaching its assertions. The stubs return harmless defaults — the
        // assertions in these tests are about render-branch selection, not
        // about runtime terminal behaviour.
        var module = JSInterop.SetupModule("/Components/Controls/TerminalView.razor.js");
        module.Setup<int>("initTerminal", _ => true).SetResult(1);
        module.Setup<int>("reconnectTerminal", _ => true).SetResult(2);
        module.SetupVoid("disposeTerminal", _ => true).SetVoidResult();
        module.SetupVoid("refreshLayout", _ => true).SetVoidResult();
        module.SetupVoid("refreshToolbarState", _ => true).SetVoidResult();
        module.Setup<TerminalSizePreset[]>("getSizePresets").SetResult([]);
    }
 
    private static ResourceViewModel CreateTerminalResource(string resourceName, int replicaIndex, int replicaCount, KnownResourceState state = KnownResourceState.Running)
    {
        // WithTerminal() stamps these three properties onto the resource
        // snapshot in DashboardServiceData.cs (covered by
        // DashboardServiceDataTerminalTests). The dashboard's HasTerminal()
        // and TryGetTerminalReplicaInfo() helpers both read from this shape,
        // so this mirrors the production wire contract.
        var properties = new Dictionary<string, ResourcePropertyViewModel>
        {
            [KnownProperties.Terminal.Enabled] = StringProperty(KnownProperties.Terminal.Enabled, "true"),
            [KnownProperties.Terminal.ReplicaIndex] = StringProperty(KnownProperties.Terminal.ReplicaIndex, replicaIndex.ToString()),
            [KnownProperties.Terminal.ReplicaCount] = StringProperty(KnownProperties.Terminal.ReplicaCount, replicaCount.ToString()),
        };
 
        return ModelTestHelpers.CreateResource(
            resourceName: resourceName,
            state: state,
            properties: properties);
    }
 
    private static ResourcePropertyViewModel StringProperty(string name, string value)
    {
        return new ResourcePropertyViewModel(
            name,
            new Value { StringValue = value },
            isValueSensitive: false,
            knownProperty: null,
            sortOrder: 0,
            displayName: null,
            isHighlighted: false);
    }
}