File: Integration\Playwright\TerminalDockTests.cs
Web Access
Project: src\tests\Aspire.Dashboard.Tests\Aspire.Dashboard.Tests.csproj (Aspire.Dashboard.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.Terminal;
using Aspire.Dashboard.Tests.Integration.Playwright.Infrastructure;
using Aspire.Dashboard.Tests.Shared;
using Aspire.DashboardService.Proto.V1;
using Aspire.TestUtilities;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Playwright;
using Xunit;
 
namespace Aspire.Dashboard.Tests.Integration.Playwright;
 
[RequiresFeature(TestFeature.Playwright)]
public sealed class TerminalDockTests(TerminalDockTests.TerminalDockDashboardServerFixture fixture)
    : PlaywrightTestsBase<TerminalDockTests.TerminalDockDashboardServerFixture>(fixture)
{
    [Fact]
    [OuterloopTest("Resource-intensive Playwright browser test")]
    public async Task EmptyDock_ResizingPreservesContentInPriorityOrder()
    {
        await RunTestAsync(async page =>
        {
            await fixture.StartSessionAsync();
            await page.SetViewportSizeAsync(1280, 900);
            await page.GotoAsync("/").DefaultTimeout();
            await page.GetByRole(AriaRole.Button, new() { Name = "Toggle terminal (`)", Exact = true }).ClickAsync();
            var panel = page.Locator(".terminal-dock-panel");
            var heading = panel.GetByRole(AriaRole.Heading, new() { Name = "No docked terminals", Exact = true });
            var hint = panel.Locator(".terminal-dock-panel-hint");
            var moreInformation = panel.GetByRole(AriaRole.Link, new() { Name = "More information", Exact = true, IncludeHidden = true });
            var body = panel.Locator(".terminal-dock-panel-body");
            var handle = page.GetByRole(AriaRole.Separator, new() { Name = "Terminals", Exact = true });
            await Assertions.Expect(panel).ToBeVisibleAsync();
 
            foreach (var width in new[] { 1280, 360 })
            {
                await page.SetViewportSizeAsync(width, 900);
                await Assertions.Expect(handle).ToHaveAttributeAsync("aria-valuemax", "900");
                foreach (var height in new[] { 420, 320, 160, 120, 160, 420 })
                {
                    await handle.FocusAsync();
                    await page.Keyboard.PressAsync("Home");
                    var remaining = height - 120;
                    while (remaining >= 50)
                    {
                        await page.Keyboard.PressAsync("Shift+ArrowUp");
                        remaining -= 50;
                    }
                    while (remaining > 0)
                    {
                        await page.Keyboard.PressAsync("ArrowUp");
                        remaining -= 10;
                    }
                    await Assertions.Expect(handle).ToHaveAttributeAsync("aria-valuenow", height.ToString());
 
                    var panelBox = await panel.BoundingBoxAsync();
                    Assert.NotNull(panelBox);
                    foreach (var (element, visible) in new[]
                    {
                        (heading, true),
                        (hint, true),
                        (moreInformation, height > 120),
                        (body, height == 420 || (width == 1280 && height == 320))
                    })
                    {
                        if (visible)
                        {
                            await Assertions.Expect(element).ToBeVisibleAsync();
                            var box = await element.BoundingBoxAsync();
                            Assert.NotNull(box);
                            Assert.InRange(box.Y, panelBox.Y - 1, panelBox.Y + panelBox.Height - box.Height + 1);
                        }
                        else
                        {
                            await Assertions.Expect(element).ToBeHiddenAsync();
                        }
                    }
                }
 
                await page.SetViewportSizeAsync(width, 80);
                await Assertions.Expect(handle).ToHaveAttributeAsync("aria-valuemax", "80");
                await handle.FocusAsync();
                await page.Keyboard.PressAsync("End");
                await Assertions.Expect(handle).ToHaveAttributeAsync("aria-valuenow", "80");
                await Assertions.Expect(heading).ToBeVisibleAsync();
                await Assertions.Expect(hint).ToBeHiddenAsync();
                await Assertions.Expect(moreInformation).ToBeHiddenAsync();
                await Assertions.Expect(body).ToBeHiddenAsync();
                var tinyPanelBox = await panel.BoundingBoxAsync();
                var headingBox = await heading.BoundingBoxAsync();
                Assert.NotNull(tinyPanelBox);
                Assert.NotNull(headingBox);
                Assert.InRange(headingBox.Y, tinyPanelBox.Y, tinyPanelBox.Y + tinyPanelBox.Height - headingBox.Height);
            }
        });
    }
 
    [Fact]
    [OuterloopTest("Resource-intensive Playwright browser test")]
    public async Task ResizeHandle_KeyboardAndPointerResizingRespectFocusAndBounds()
    {
        await RunTestAsync(async page =>
        {
            await OpenDockAsync(page);
            var terminals = await page.Locator(".terminal-dock textarea").ElementHandlesAsync();
            var handle = page.GetByRole(AriaRole.Separator, new() { Name = "Terminals", Exact = true });
            var viewportHeight = await page.EvaluateAsync<int>("window.innerHeight");
            var maximum = Math.Min(1200, viewportHeight);
            await Assertions.Expect(handle).ToHaveAttributeAsync("aria-valuemax", maximum.ToString());
            await handle.FocusAsync();
 
            foreach (var (key, expected) in new[]
            {
                ("ArrowUp", 330),
                ("ArrowDown", 320),
                ("Shift+ArrowUp", 370),
                ("Shift+ArrowDown", 320),
                ("Home", 120),
                ("End", maximum),
                ("ArrowUp", maximum),
                ("Home", 120),
                ("ArrowDown", 120)
            })
            {
                await page.Keyboard.PressAsync(key);
                await Assertions.Expect(handle).ToHaveAttributeAsync("aria-valuenow", expected.ToString());
                await Assertions.Expect(handle).ToHaveAttributeAsync("aria-valuetext", $"{expected} pixels high");
                await Assertions.Expect(handle).ToBeFocusedAsync();
                var dockBox = await page.Locator(".terminal-dock").BoundingBoxAsync();
                Assert.NotNull(dockBox);
                Assert.InRange(dockBox.Y, 0, viewportHeight);
                Assert.InRange(dockBox.Height, 120, maximum);
            }
 
            var handleBox = await handle.BoundingBoxAsync();
            Assert.NotNull(handleBox);
            var x = handleBox.X + 100;
            var y = handleBox.Y + handleBox.Height / 2;
            await page.Mouse.MoveAsync(x, y);
            await page.Mouse.DownAsync();
            await page.Mouse.MoveAsync(x, y - 60);
            var draggedHeight = (int)Math.Round(viewportHeight - (y - 60));
            await Assertions.Expect(page.Locator(".terminal-dock")).ToHaveCSSAsync("height", $"{draggedHeight}px");
            await Assertions.Expect(handle).ToHaveAttributeAsync("aria-valuenow", draggedHeight.ToString());
            await page.Mouse.UpAsync();
            await Assertions.Expect(handle).ToHaveAttributeAsync("aria-valuenow", draggedHeight.ToString());
            await Assertions.Expect(handle).ToBeFocusedAsync();
            await page.Keyboard.PressAsync("ArrowUp");
            await Assertions.Expect(handle).ToHaveAttributeAsync("aria-valuenow", (draggedHeight + 10).ToString());
 
            var input = page.Locator(".terminal-dock-pane.active").GetByRole(AriaRole.Textbox);
            await input.FocusAsync();
            foreach (var key in new[] { "ArrowUp", "ArrowDown", "Shift+ArrowUp", "Shift+ArrowDown", "Home", "End" })
            {
                await page.Keyboard.PressAsync(key);
                await Assertions.Expect(input).ToBeFocusedAsync();
                await Assertions.Expect(handle).ToHaveAttributeAsync("aria-valuenow", (draggedHeight + 10).ToString());
            }
 
            Assert.Empty(fixture.Client.ClosedTerminals);
            foreach (var terminal in terminals)
            {
                Assert.True(await terminal.EvaluateAsync<bool>("element => element.isConnected"));
            }
        });
    }
 
    [Fact]
    [OuterloopTest("Resource-intensive Playwright browser test")]
    public async Task ResizeHandle_ViewportChangesKeepTheHandleReachable()
    {
        await RunTestAsync(async page =>
        {
            await OpenDockAsync(page);
            var handle = page.GetByRole(AriaRole.Separator, new() { Name = "Terminals", Exact = true });
            await handle.FocusAsync();
            await page.Keyboard.PressAsync("End");
 
            foreach (var viewportHeight in new[] { 240, 800 })
            {
                await page.SetViewportSizeAsync(1280, viewportHeight);
                await Assertions.Expect(handle).ToHaveAttributeAsync("aria-valuemax", viewportHeight.ToString());
                await page.Keyboard.PressAsync("End");
                await Assertions.Expect(handle).ToHaveAttributeAsync("aria-valuenow", viewportHeight.ToString());
                await Assertions.Expect(handle).ToBeFocusedAsync();
                var box = await handle.BoundingBoxAsync();
                Assert.NotNull(box);
                Assert.InRange(box.Y + box.Height / 2, 0, viewportHeight);
            }
        });
    }
 
    [Fact]
    [OuterloopTest("Resource-intensive Playwright browser test")]
    public async Task KeyboardNavigation_SelectsTabsWithoutInterceptingTerminalInput()
    {
        await RunTestAsync(async page =>
        {
            await OpenDockAsync(page);
            var terminals = await page.Locator(".terminal-dock textarea").ElementHandlesAsync();
            await Tab(page, "first").FocusAsync();
 
            foreach (var (key, expected) in new[]
            {
                ("ArrowLeft", "third"),
                ("ArrowRight", "first"),
                ("End", "third"),
                ("Home", "first"),
                ("ArrowRight", "second"),
                ("Enter", "second"),
                ("Space", "second")
            })
            {
                await page.Keyboard.PressAsync(key);
                await Assertions.Expect(Tab(page, expected)).ToBeFocusedAsync();
                await Assertions.Expect(Tab(page, expected)).ToHaveAttributeAsync("aria-selected", "true");
            }
 
            await page.Keyboard.PressAsync("Tab");
            await Assertions.Expect(page.GetByRole(AriaRole.Button, new() { Name = "Close terminal 'second'", Exact = true }))
                .ToBeFocusedAsync();
            await page.Keyboard.PressAsync("Shift+Tab");
            await Assertions.Expect(Tab(page, "second")).ToBeFocusedAsync();
            await page.Keyboard.PressAsync("Shift+Tab");
            Assert.False(await page.EvaluateAsync<bool>("!!document.activeElement.closest('.terminal-dock-tabstrip')"));
            await page.Keyboard.PressAsync("Tab");
            await Assertions.Expect(Tab(page, "second")).ToBeFocusedAsync();
 
            var input = page.Locator(".terminal-dock-pane.active").GetByRole(AriaRole.Textbox);
            await input.FocusAsync();
            foreach (var key in new[] { "ArrowLeft", "ArrowRight", "Home", "End", "Delete" })
            {
                await page.Keyboard.PressAsync(key);
                await Assertions.Expect(input).ToBeFocusedAsync();
                await Assertions.Expect(Tab(page, "second")).ToHaveAttributeAsync("aria-selected", "true");
            }
 
            await page.Keyboard.PressAsync("F6");
            await Assertions.Expect(page.Locator(".terminal-dock-pane.active")
                .GetByRole(AriaRole.Button, new() { Name = "Decrease font size", Exact = true })).ToBeFocusedAsync();
            await input.FocusAsync();
            await page.Keyboard.PressAsync("Shift+F6");
            Assert.True(await page.EvaluateAsync<bool>("!!document.activeElement.closest('.terminal-dock-tabstrip')"));
 
            Assert.Empty(fixture.Client.ClosedTerminals);
            foreach (var terminal in terminals)
            {
                Assert.True(await terminal.EvaluateAsync<bool>("element => element.isConnected"));
            }
        });
    }
 
    [Fact]
    [OuterloopTest("Resource-intensive Playwright browser test")]
    public async Task CloseTab_RestoresFocusOnlyAfterRemovalIncludingLastTab()
    {
        await RunTestAsync(async page =>
        {
            var (updates, closes) = await OpenDockAsync(page);
            await Tab(page, "second").ClickAsync();
            await Assertions.Expect(Tab(page, "second")).ToHaveAttributeAsync("aria-selected", "true");
 
            foreach (var (closed, next, key) in new[]
            {
                ("second", "third", "Delete"),
                ("third", "first", "Enter"),
                ("first", (string?)null, "Space")
            })
            {
                if (key != "Delete")
                {
                    await page.Keyboard.PressAsync("Tab");
                }
                await page.Keyboard.PressAsync(key);
                Assert.Equal(closed, await closes.Reader.ReadAsync().AsTask().DefaultTimeout());
                await Assertions.Expect(Tab(page, closed)).ToHaveAttributeAsync("aria-selected", "true");
                await Assertions.Expect(key == "Delete"
                    ? Tab(page, closed)
                    : page.GetByRole(AriaRole.Button, new() { Name = $"Close terminal '{closed}'", Exact = true }))
                    .ToBeFocusedAsync();
                await updates.Writer.WriteAsync(Change(TerminalChangeType.Removed, closed));
 
                if (next is not null)
                {
                    await Assertions.Expect(Tab(page, next)).ToBeFocusedAsync();
                    await Assertions.Expect(Tab(page, next)).ToHaveAttributeAsync("aria-selected", "true");
                }
                else
                {
                    await Assertions.Expect(page.Locator(".terminal-dock-collapse")).ToBeFocusedAsync();
                    await Assertions.Expect(page.Locator(".terminal-dock-panel-heading")).ToHaveTextAsync("No docked terminals");
                }
            }
 
            // The tablist is recreated after the empty state, but its dock-scoped listener remains usable.
            await updates.Writer.WriteAsync(Change(TerminalChangeType.Added, "replacement"));
            await Tab(page, "replacement").FocusAsync();
            await page.Keyboard.PressAsync("Delete");
            Assert.Equal("replacement", await closes.Reader.ReadAsync().AsTask().DefaultTimeout());
            await updates.Writer.WriteAsync(Change(TerminalChangeType.Removed, "replacement"));
            await Assertions.Expect(page.Locator(".terminal-dock-collapse")).ToBeFocusedAsync();
            Assert.Equal(["second", "third", "first", "replacement"], fixture.Client.ClosedTerminals.ToArray());
        });
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    [OuterloopTest("Resource-intensive Playwright browser test")]
    public async Task Removal_DoesNotStealFocusAfterMovingElsewhere(bool hideDock)
    {
        await RunTestAsync(async page =>
        {
            var (updates, closes) = await OpenDockAsync(page);
            await Tab(page, "first").FocusAsync();
            await page.Keyboard.PressAsync("Delete");
            Assert.Equal("first", await closes.Reader.ReadAsync().AsTask().DefaultTimeout());
 
            ILocator focusTarget;
            if (hideDock)
            {
                await page.Locator(".terminal-dock-collapse").ClickAsync();
                focusTarget = page.GetByRole(AriaRole.Button, new() { Name = "Toggle terminal (`)", Exact = true });
            }
            else
            {
                await Tab(page, "second").ClickAsync();
                focusTarget = Tab(page, "second");
            }
 
            await focusTarget.FocusAsync();
            await updates.Writer.WriteAsync(Change(TerminalChangeType.Removed, "first"));
            await Assertions.Expect(page.Locator(".terminal-dock-tab-select")).ToHaveCountAsync(2);
            await Assertions.Expect(focusTarget).ToBeFocusedAsync();
        });
    }
 
    private async Task<(Channel<WatchTerminalsUpdate> Updates, Channel<string> Closes)> OpenDockAsync(IPage page)
    {
        var channels = await fixture.StartSessionAsync();
        await page.GotoAsync("/").DefaultTimeout();
        foreach (var id in new[] { "first", "second", "third" })
        {
            await channels.Updates.Writer.WriteAsync(Change(TerminalChangeType.Added, id));
        }
        await channels.Updates.Writer.WriteAsync(Change(TerminalChangeType.Activated, "first"));
        await Assertions.Expect(Tab(page, "first")).ToBeVisibleAsync();
        // Hidden views mount when first shown. Visit each pane so subsequent keyboard
        // navigation proves an existing WebTerminal is retained rather than recreated.
        foreach (var id in new[] { "first", "second", "third" })
        {
            await Tab(page, id).ClickAsync();
            await Assertions.Expect(page.Locator(".terminal-dock-pane.active")
                .GetByRole(AriaRole.Button, new() { Name = "Decrease font size", Exact = true })).ToBeEnabledAsync();
        }
        await Tab(page, "first").ClickAsync();
        await Assertions.Expect(page.Locator(".terminal-dock textarea")).ToHaveCountAsync(3);
        return channels;
    }
 
    private static ILocator Tab(IPage page, string name) => page.GetByRole(AriaRole.Tab, new() { Name = name, Exact = true });
 
    private static WatchTerminalsUpdate Change(TerminalChangeType type, string id) => new()
    {
        Change = new TerminalChangeNotification
        {
            ChangeType = type,
            Terminal = new TerminalDescriptor { TerminalId = id, Title = id }
        }
    };
 
    public sealed class TerminalDockDashboardServerFixture : DashboardServerFixture
    {
        private Channel<WatchTerminalsUpdate> _updates = Channel.CreateUnbounded<WatchTerminalsUpdate>();
        private Channel<string> _closes = Channel.CreateUnbounded<string>();
        internal TestTerminalConnectionResolver TerminalResolver { get; } = new();
 
        public TestDashboardClient Client { get; }
 
        public TerminalDockDashboardServerFixture()
        {
            Client = new TestDashboardClient(
                isEnabled: true,
                terminalChannelProvider: () => Volatile.Read(ref _updates),
                attachTerminal: async (id, token) => (await TerminalResolver.ConnectAsync(id, 0, token))!,
                closeTerminal: (id, token) => Volatile.Read(ref _closes).Writer.WriteAsync(id, token).AsTask());
        }
 
        public async Task<(Channel<WatchTerminalsUpdate> Updates, Channel<string> Closes)> StartSessionAsync()
        {
            await TerminalResolver.DiscardPendingConnectionsAsync();
            var updates = Channel.CreateUnbounded<WatchTerminalsUpdate>();
            var closes = Channel.CreateUnbounded<string>();
            Volatile.Write(ref _updates, updates);
            Volatile.Write(ref _closes, closes);
            Client.ClosedTerminals.Clear();
            return (updates, closes);
        }
 
        protected override void ConfigureServices(IServiceCollection services)
        {
            services.AddSingleton<IDashboardClient>(Client);
            services.AddSingleton<ITerminalConnectionResolver>(_ => TerminalResolver);
        }
    }
}