File: Integration\Playwright\TerminalTests.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 Aspire.Dashboard.Model;
using Aspire.Dashboard.Terminal;
using Aspire.Dashboard.Tests.Integration.Playwright.Infrastructure;
using Aspire.TestUtilities;
using Aspire.Tests.Shared.DashboardModel;
using Google.Protobuf.WellKnownTypes;
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 TerminalTests(TerminalTests.TerminalDashboardServerFixture fixture)
    : PlaywrightTestsBase<TerminalTests.TerminalDashboardServerFixture>(fixture)
{
    private const string ResourceName = "terminal-resource";
    private const string Endpoint = "/api/terminal?resource=terminal-resource&replica=0";
 
    [Theory]
    [InlineData(false, false)]
    [InlineData(false, true)]
    [InlineData(true, false)]
    [InlineData(true, true)]
    [OuterloopTest("Resource-intensive Playwright browser test")]
    public async Task ReadOnly_BlocksKeyboardAndPasteWithoutInterruptingOutput(bool initialReadOnly, bool chromeless)
    {
        await RunTestAsync(async page =>
        {
            await fixture.TerminalResolver.DiscardPendingConnectionsAsync();
            await page.GotoAsync("/").DefaultTimeout();
            using var session = fixture.DashboardApp.Services.GetRequiredService<TerminalViewSessionRegistry>()
                .Create(Endpoint, initialReadOnly);
            var terminalId = await MountModuleAsync(page, session, chromeless);
            await using var connection = await fixture.TerminalResolver.AcceptConnectionAsync(CancellationToken.None).DefaultTimeout();
            await WaitForConnectedAsync(page, terminalId);
            await MountObserverAsync(page, requestPrimary: true);
            await connection.WaitForPeerHandshakesAsync(CancellationToken.None).DefaultTimeout();
            var primaryId = connection.Presentation.PrimaryPeerId;
            var connectionCount = connection.ConnectionCount;
            Assert.NotNull(primaryId);
 
            var terminal = page.GetByTestId("module-terminal");
            var terminalElement = await terminal.Locator("textarea").ElementHandleAsync();
            Assert.NotNull(terminalElement);
            var input = terminal.GetByRole(AriaRole.Textbox);
            await ExpectReadOnlyAsync(page, initialReadOnly);
            await SetReadOnlyAsync(page, terminalId, session, true);
            await ExpectReadOnlyAsync(page, true);
 
            connection.Workload.Write("\u001b[?25lOutput while read-only\r\n");
            await connection.WaitForProducerTextAsync("Output while read-only", CancellationToken.None).DefaultTimeout();
            await ExpectObserverTextAsync(page, "Output while read-only");
            await Assertions.Expect(terminal.Locator("canvas")).ToBeVisibleAsync();
            await page.EvaluateAsync("() => window.moduleTerminal.focus()");
            await page.Keyboard.TypeAsync("q");
            await PasteAsync(input, "blocked-paste");
            Assert.Equal(["Terminal view does not accept input", "Terminal view does not accept input"],
                await page.EvaluateAsync<string[]>("""
                    async () => {
                        const errors = [];
                        for (const action of [
                            () => window.moduleTerminal.paste('blocked-direct-paste'),
                            () => window.moduleTerminal.runAction('pasteClipboard')
                        ]) {
                            try {
                                await action();
                                errors.push('Input unexpectedly accepted');
                            } catch (error) {
                                errors.push(error.message);
                            }
                        }
                        return errors;
                    }
                    """));
            await page.EvaluateAsync("""
                async id => {
                    const module = await import('/Components/Controls/TerminalView.razor.js');
                    module.setFontSizeFromHost(id, 20);
                    module.setSizeModeFromHost(id, '80x24');
                }
                """, terminalId);
 
            // The independent viewer remains usable while this view is read-only.
            // Its ordered input also provides a barrier for the blocked input above.
            await page.EvaluateAsync("() => window.terminalObserver.paste('other-view')");
            Assert.Equal("other-view", await connection.ReadInputTextAsync("other-view".Length, CancellationToken.None).DefaultTimeout());
            Assert.Equal(primaryId, connection.Presentation.PrimaryPeerId);
            Assert.True(await page.EvaluateAsync<bool>("""
                async id => {
                    const module = await import('/Components/Controls/TerminalView.razor.js');
                    const state = module.getToolbarState(id);
                    return state.fontPx === 13 && state.cols === 137 && state.rows === 41;
                }
                """, terminalId));
 
            await SetReadOnlyAsync(page, terminalId, session, false);
            await ExpectReadOnlyAsync(page, false);
            await input.FocusAsync();
            await page.Keyboard.TypeAsync("x");
            await PasteAsync(input, "allowed-paste");
            Assert.Equal("xallowed-paste", await connection.ReadInputTextAsync("xallowed-paste".Length, CancellationToken.None).DefaultTimeout());
            Assert.Equal(primaryId, connection.Presentation.PrimaryPeerId);
 
            await SetReadOnlyAsync(page, terminalId, session, true);
            await ExpectReadOnlyAsync(page, true);
            await page.EvaluateAsync("() => window.moduleTerminal.focus()");
            await page.Keyboard.TypeAsync("z");
            await PasteAsync(input, "blocked-paste-again");
            await page.EvaluateAsync("() => window.terminalObserver.paste('still-active')");
            Assert.Equal("still-active", await connection.ReadInputTextAsync("still-active".Length, CancellationToken.None).DefaultTimeout());
            await SetReadOnlyAsync(page, terminalId, session, false);
            await PasteAsync(input, "final-check");
            Assert.Equal("final-check", await connection.ReadInputTextAsync("final-check".Length, CancellationToken.None).DefaultTimeout());
            connection.Workload.Write("Output after policy changes\r\n");
            await ExpectObserverTextAsync(page, "Output after policy changes");
            await WaitForConnectedAsync(page, terminalId);
            Assert.True(await terminalElement.EvaluateAsync<bool>("element => element.isConnected"));
            Assert.Equal(connectionCount, connection.ConnectionCount);
        });
    }
 
    [Fact]
    [OuterloopTest("Resource-intensive Playwright browser test")]
    public async Task TerminalFocusNavigation_MovesToExpectedControlsWithoutForwardingInput()
    {
        await RunTestAsync(async page =>
        {
            await using var connection = await OpenTerminalAsync(page);
            var input = page.GetByRole(AriaRole.Textbox, new() { Name = "Interactive terminal input", Exact = true });
            var decreaseFontButton = page.GetByRole(AriaRole.Button, new() { Name = "Decrease font size", Exact = true });
            var precedingControl = page.GetByLabel("Page toolbar", new() { Exact = true })
                .GetByRole(AriaRole.Button, new() { Name = "Settings", Exact = true });
            var focusHint = page.Locator(".terminal-focus-hint");
 
            await Assertions.Expect(decreaseFontButton).ToBeEnabledAsync();
            await input.FocusAsync();
            await Assertions.Expect(focusHint).ToBeVisibleAsync();
            await page.Keyboard.PressAsync("F6");
            await Assertions.Expect(decreaseFontButton).ToBeFocusedAsync();
            await Assertions.Expect(focusHint).ToBeHiddenAsync();
 
            await page.Keyboard.PressAsync("F6");
            await Assertions.Expect(input).ToBeFocusedAsync();
            await Assertions.Expect(focusHint).ToBeVisibleAsync();
            await page.Keyboard.PressAsync("Shift+F6");
            await Assertions.Expect(precedingControl).ToBeFocusedAsync();
            await Assertions.Expect(focusHint).ToBeHiddenAsync();
 
            // The producer's first key must be this character, not an intercepted F6 key.
            await input.FocusAsync();
            await Assertions.Expect(focusHint).ToBeVisibleAsync();
            await page.Keyboard.TypeAsync("x");
            Assert.Equal("x", await connection.ReadInputTextAsync(1, CancellationToken.None).DefaultTimeout());
        });
    }
 
    [Fact]
    [OuterloopTest("Resource-intensive Playwright browser test")]
    public async Task SecondaryTypingAndPaste_PreservePrimaryAndProducerDimensions()
    {
        await RunTestAsync(async page =>
        {
            await using var connection = await OpenTerminalAsync(page);
            await MountObserverAsync(page, requestPrimary: true);
            var primaryId = connection.Presentation.PrimaryPeerId;
            Assert.NotNull(primaryId);
            await ExpectProducerDimensionsAsync(page);
 
            var input = page.GetByRole(AriaRole.Textbox, new() { Name = "Interactive terminal input", Exact = true });
            await input.FocusAsync();
            await page.Keyboard.TypeAsync("x");
            await PasteAsync(input, "paste");
            Assert.Equal("xpaste", await connection.ReadInputTextAsync(6, CancellationToken.None).DefaultTimeout());
            Assert.Equal(primaryId, connection.Presentation.PrimaryPeerId);
            await ExpectProducerDimensionsAsync(page);
        });
    }
 
    [Fact]
    [OuterloopTest("Resource-intensive Playwright browser test")]
    public async Task InitialConnection_UsesProducerDimensions()
    {
        await RunTestAsync(async page =>
        {
            await using var connection = await OpenTerminalAsync(page);
            await ExpectProducerDimensionsAsync(page);
        });
    }
 
    [Fact]
    [OuterloopTest("Resource-intensive Playwright browser test")]
    public async Task ExplicitSizing_TakesPrimaryAndAppliesRequestedGrid()
    {
        await RunTestAsync(async page =>
        {
            await using var connection = await OpenTerminalAsync(page);
            await MountObserverAsync(page, requestPrimary: true);
            var primaryId = connection.Presentation.PrimaryPeerId;
            var dropdown = page.Locator("fluent-field.terminal-size-select fluent-dropdown");
            var dimensions = TerminalDimensions(page);
            await dimensions.ClickAsync();
            await dropdown.Locator("fluent-option[text='80×24']").ClickAsync();
            await Assertions.Expect(dimensions).ToHaveTextAsync("80×24");
            Assert.NotNull(connection.Presentation.PrimaryPeerId);
            Assert.NotEqual(primaryId, connection.Presentation.PrimaryPeerId);
            await page.WaitForFunctionAsync("() => window.terminalObserver.geometry.columns === 80 && window.terminalObserver.geometry.rows === 24").DefaultTimeout();
        });
    }
 
    [Theory]
    [InlineData("Decrease font size", 8, -1)]
    [InlineData("Increase font size", 32, 1)]
    [OuterloopTest("Resource-intensive Playwright browser test")]
    public async Task FontSizeControls_TakePrimaryAndRespectPackageBounds(string name, int bound, int delta)
    {
        await RunTestAsync(async page =>
        {
            await using var connection = await OpenTerminalAsync(page);
            await MountObserverAsync(page, requestPrimary: true);
            var primaryId = connection.Presentation.PrimaryPeerId;
            var button = page.GetByRole(AriaRole.Button, new() { Name = name, Exact = true });
            for (var fontSize = 13 + delta; fontSize != bound + delta; fontSize += delta)
            {
                await button.ClickAsync();
                await Assertions.Expect(page.Locator(".terminal-font-size")).ToHaveTextAsync($"{fontSize}px");
            }
            await Assertions.Expect(button).ToHaveAttributeAsync("disabled", string.Empty);
            Assert.NotNull(connection.Presentation.PrimaryPeerId);
            Assert.NotEqual(primaryId, connection.Presentation.PrimaryPeerId);
        });
    }
 
    [Fact]
    [OuterloopTest("Resource-intensive Playwright browser test")]
    public async Task ProducerOutput_RendersInCanvasAndPublicClientSnapshot()
    {
        await RunTestAsync(async page =>
        {
            await using var connection = await OpenTerminalAsync(page);
            await MountObserverAsync(page, requestPrimary: false);
            connection.Workload.Write("\u001b[?25lready");
            await ExpectObserverTextAsync(page, "ready");
            var canvas = page.Locator(".terminal-view canvas");
            var before = await canvas.ScreenshotAsync();
            connection.Workload.Write("\r\nTerminal browser output is visible");
            await ExpectObserverTextAsync(page, "Terminal browser output is visible");
            await Assertions.Expect(canvas).ToBeVisibleAsync();
            await AsyncTestHelpers.AssertIsTrueRetryAsync(async () =>
            {
                var after = await canvas.ScreenshotAsync();
                return !before.AsSpan().SequenceEqual(after);
            }, "The dashboard canvas should present the producer's new output.");
        });
    }
 
    [Fact]
    [OuterloopTest("Resource-intensive Playwright browser test")]
    public async Task ModifiedF6_DoesNotMoveFocusFromTerminal()
    {
        await RunTestAsync(async page =>
        {
            await using var connection = await OpenTerminalAsync(page);
            var input = page.GetByRole(AriaRole.Textbox, new() { Name = "Interactive terminal input", Exact = true });
            foreach (var key in new[] { "Control+F6", "Alt+F6", "Meta+F6" })
            {
                await input.FocusAsync();
                await page.Keyboard.PressAsync(key);
                await Assertions.Expect(input).ToBeFocusedAsync();
            }
        });
    }
 
    private async Task<TestTerminalConnection> OpenTerminalAsync(IPage page)
    {
        await fixture.TerminalResolver.DiscardPendingConnectionsAsync();
        await page.GotoAsync($"/consolelogs/resource/{ResourceName}").DefaultTimeout();
        var connection = await fixture.TerminalResolver.AcceptConnectionAsync(CancellationToken.None).DefaultTimeout();
        await connection.WaitForPeerHandshakesAsync(CancellationToken.None).DefaultTimeout();
        await ExpectProducerDimensionsAsync(page);
        return connection;
    }
 
    private static Task ExpectProducerDimensionsAsync(IPage page) =>
        Assertions.Expect(TerminalDimensions(page))
            .ToHaveTextAsync($"{TestTerminalConnection.Columns}×{TestTerminalConnection.Rows}");
 
    private static ILocator TerminalDimensions(IPage page) =>
        page.Locator("fluent-field.terminal-size-select [role='combobox']");
 
    private static Task SetReadOnlyAsync(IPage page, int terminalId, TerminalViewSession session, bool readOnly)
    {
        // Match the component ordering: enforce policy on the server before changing UI.
        session.ReadOnly = readOnly;
        return page.EvaluateAsync("""
            async ({ terminalId, readOnly }) => {
                const module = await import('/Components/Controls/TerminalView.razor.js');
                module.setReadOnly(terminalId, readOnly);
            }
            """, new { terminalId, readOnly });
    }
 
    private static async Task ExpectReadOnlyAsync(IPage page, bool readOnly)
    {
        await page.WaitForFunctionAsync("""
            readOnly => window.moduleTerminal?.readOnly === readOnly
            """, readOnly).DefaultTimeout();
        var input = page.GetByTestId("module-terminal").Locator("textarea");
        if (readOnly)
        {
            await Assertions.Expect(input).ToBeDisabledAsync();
        }
        else
        {
            await Assertions.Expect(input).ToBeEnabledAsync();
        }
    }
 
    private static Task PasteAsync(ILocator input, string text) =>
        input.EvaluateAsync("""
            (element, text) => {
                const data = new DataTransfer();
                data.setData('text/plain', text);
                element.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }));
            }
            """, text);
 
    private static async Task WaitForConnectedAsync(IPage page, int terminalId)
    {
        await page.WaitForFunctionAsync("""
            id => window.terminalModule.getToolbarState(id)?.connected === true
            """, terminalId).DefaultTimeout();
    }
 
    private static Task<int> MountModuleAsync(IPage page, TerminalViewSession session, bool chromeless) =>
        page.EvaluateAsync<int>("""
            async ({ endpoint, viewId, readOnly, chromeless }) => {
                const module = await import('/Components/Controls/TerminalView.razor.js');
                window.terminalModule = module;
                const container = document.createElement('div');
                container.dataset.testid = 'module-terminal';
                container.style.cssText = 'position:fixed;left:0;top:0;width:700px;height:500px;z-index:10000';
                document.body.appendChild(container);
                const template = document.createElement('div');
                template.innerHTML = '<div><fluent-button aria-label="Copy">Copy</fluent-button></div>';
                template.hidden = true;
                document.body.appendChild(template);
                const footer = document.createElement('div');
                footer.tabIndex = -1;
                document.body.appendChild(footer);
                const url = new URL(endpoint, location.href);
                url.protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
                url.searchParams.set('viewId', viewId);
                // Capture the public client returned by the real mount, without replacing
                // its input implementation, to exercise direct paste/action entry points.
                const { WebTerminal } = await import('/js/hex1b-web-terminal/dist/index.js');
                const mount = WebTerminal.mount;
                WebTerminal.mount = async (...args) => {
                    const client = await mount.call(WebTerminal, ...args);
                    window.moduleTerminal = client;
                    return client;
                };
                try {
                    return module.initTerminal(container, url.href, null, {
                        label: 'Test terminal input', readOnly, chromeless
                    }, template, footer);
                } finally {
                    WebTerminal.mount = mount;
                }
            }
            """, new { endpoint = Endpoint, viewId = session.Id, readOnly = session.ReadOnly, chromeless });
 
    private static async Task MountObserverAsync(IPage page, bool requestPrimary)
    {
        await page.EvaluateAsync("""
            async ({ endpoint, requestPrimary, columns, rows }) => {
                const { WebTerminal } = await import('/js/hex1b-web-terminal/dist/index.js');
                const container = document.createElement('div');
                container.dataset.testid = 'observer-terminal';
                container.style.cssText = 'position:fixed;right:0;top:0;width:400px;height:200px';
                document.body.appendChild(container);
                // Use only the package's public client surface, never worker state or HWT frames.
                window.terminalObserver = await WebTerminal.mount(container, {
                    url: new URL(endpoint, location.href),
                    label: 'Observer terminal input',
                    sizing: { mode: 'fixed', columns, rows, fontSize: 13 }
                });
                if (requestPrimary) {
                    window.terminalObserver.requestPrimary();
                }
            }
            """, new { endpoint = Endpoint, requestPrimary, columns = TestTerminalConnection.Columns, rows = TestTerminalConnection.Rows });
        if (requestPrimary)
        {
            await page.WaitForFunctionAsync("() => window.terminalObserver.peer.isPrimary").DefaultTimeout();
        }
    }
 
    private static async Task ExpectObserverTextAsync(IPage page, string text)
    {
        await page.WaitForFunctionAsync("text => window.terminalObserver.screenText.includes(text)", text).DefaultTimeout();
    }
 
    public sealed class TerminalDashboardServerFixture : DashboardServerFixture
    {
        internal TestTerminalConnectionResolver TerminalResolver { get; } = new();
 
        protected override IReadOnlyList<ResourceViewModel> Resources =>
        [
            ModelTestHelpers.CreateResource(
                resourceName: ResourceName,
                state: KnownResourceState.Running,
                properties: new Dictionary<string, ResourcePropertyViewModel>
                {
                    [KnownProperties.Terminal.Enabled] = StringProperty(KnownProperties.Terminal.Enabled, "true"),
                    [KnownProperties.Terminal.ReplicaIndex] = StringProperty(KnownProperties.Terminal.ReplicaIndex, "0"),
                    [KnownProperties.Terminal.ReplicaCount] = StringProperty(KnownProperties.Terminal.ReplicaCount, "1"),
                })
        ];
 
        protected override void ConfigureServices(IServiceCollection services)
        {
            services.AddSingleton<ITerminalConnectionResolver>(_ => TerminalResolver);
        }
 
        private static ResourcePropertyViewModel StringProperty(string name, string value) =>
            new(name, new Value { StringValue = value }, isValueSensitive: false, knownProperty: null,
                sortOrder: 0, displayName: null, isHighlighted: false);
    }
}