File: Integration\Playwright\DashboardInteractionsTests.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.Text.RegularExpressions;
using Aspire.Dashboard.Model;
using Aspire.Dashboard.Tests.Integration.Playwright.Infrastructure;
using Aspire.TestUtilities;
using Aspire.Tests.Shared.DashboardModel;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Playwright;
using Xunit;
 
namespace Aspire.Dashboard.Tests.Integration.Playwright;
 
// Functional coverage for the net-new interactive behaviors implemented purely in app.js: grid
// column auto-fit (double-click a resize handle) and the floating scroll-to-bottom button for
// large scroll regions. These carry real runtime logic (column/track alignment, overflow/edge
// thresholds) and are coupled to specific markup (".resize-handle", ".continuous-scroll-overflow").
// Scanning resting page state can't catch a regression here, so we drive the interactions and assert
// their DOM effects - which also fails loudly if any of those selectors are renamed out from under the JS.
[RequiresFeature(TestFeature.Playwright)]
public class DashboardInteractionsTests : PlaywrightTestsBase<DashboardInteractionsTests.InteractionsDashboardServerFixture>
{
    public DashboardInteractionsTests(InteractionsDashboardServerFixture dashboardServerFixture)
        : base(dashboardServerFixture)
    {
    }
 
    [Fact]
    [OuterloopTest("Resource-intensive Playwright browser test")]
    public async Task GridColumn_DoubleClickResizeHandle_AutoFitsColumnWidth()
    {
        await RunTestAsync(async page =>
        {
            await GoToResourcesAndWaitAsync(page);
 
            var grid = page.Locator(".main-grid").First;
            await Assertions.Expect(grid).ToBeVisibleAsync();
 
            // Guard against a Fluent UI Blazor rename of the resize handle class: the auto-fit
            // double-click handler keys off exactly these selectors, so if neither is present the
            // feature is silently dead and this count assertion surfaces it.
            var handles = page.Locator(".main-grid .resize-handle, .main-grid .col-width-draghandle");
            Assert.True(await handles.CountAsync() > 0, "Expected at least one grid resize handle to be rendered.");
 
            // Fluent writes the resolved template from GetGridTemplateColumns() to the table's *inline*
            // grid-template-columns, so it is already populated at rest (typically with fr/auto tracks).
            // auto-fit resolves every track to concrete px and rewrites the inline template with the
            // fitted column, so the reliable end-to-end proof is that the inline value *changes* and is
            // now an explicit px template.
            var inlineBefore = await grid.EvaluateAsync<string>("el => el.style.gridTemplateColumns");
 
            // The auto-fit behavior is a delegated document-level "dblclick" listener that keys off
            // e.target.closest(".resize-handle"). Dispatch the dblclick straight onto the handle element
            // rather than a pixel-precise click: the handle is a thin edge bar whose pointer-events are
            // gated, so a coordinate double-click retargets to the header behind it and never reaches the
            // handler. DispatchEvent fires a real bubbling MouseEvent on the exact element, which bubbles
            // to the document listener exactly as a user double-click on the handle would.
            await handles.First.DispatchEventAsync("dblclick");
 
            await page.WaitForFunctionAsync(
                @"before => {
                    const g = document.querySelector('.main-grid');
                    if (!g) { return false; }
                    const now = g.style.gridTemplateColumns;
                    return now.length > 0 && now !== before && /px/.test(now);
                }",
                inlineBefore)
                .DefaultTimeout();
        });
    }
 
    [Fact]
    [OuterloopTest("Resource-intensive Playwright browser test")]
    public async Task ScrollButtons_ActivateForOverflowingRegion_AndScrollIt()
    {
        await RunTestAsync(async page =>
        {
            await GoToResourcesAndWaitAsync(page);
 
            // The mock dashboard client serves no console-log/telemetry stream, so no page naturally
            // renders an overflowing ".continuous-scroll-overflow" (that class lives on Console logs,
            // Traces and Structured logs). Inject a representative region using the same contract
            // selector the feature discovers, so we can exercise the button feature's real logic -
            // MutationObserver discovery, overflow-threshold activation (240px), edge-threshold
            // visibility (120px) and click-to-scroll - end to end. There are no other scroll regions
            // on the Resources page, so the single ".scroll-buttons" root belongs to this region.
            await page.EvaluateAsync(@"() => {
                const region = document.createElement('div');
                region.className = 'continuous-scroll-overflow';
                region.id = 'synthetic-scroll-region';
                region.style.cssText = 'position:fixed;left:0;top:0;width:400px;height:300px;overflow:auto;z-index:1;';
                const tall = document.createElement('div');
                tall.style.height = '2000px';
                region.appendChild(tall);
                document.body.appendChild(region);
            }");
 
            var buttons = page.Locator(".scroll-buttons").First;
            var bottomButton = page.Locator(".scroll-button.scroll-to-bottom").First;
 
            // Overflow (2000 - 300 = 1700px) is well past the 240px activation threshold.
            await Assertions.Expect(buttons).ToHaveClassAsync(new Regex(@"\bis-active\b"));
            await Assertions.Expect(page.Locator(".scroll-button.scroll-to-top")).ToHaveCountAsync(0);
            await Assertions.Expect(bottomButton).ToHaveClassAsync(new Regex(@"\bis-visible\b"));
 
            // The buttons are proximity-gated (review feedback made them on-demand rather than always-on):
            // a candidate button carries .is-visible but only actually shows while the pointer is near the
            // region, when JS toggles .is-hovered on the .scroll-buttons group. Hover the region to reveal
            // them - this both satisfies Playwright's actionability check for the click below and asserts
            // the reveal works. The 200ms hide delay is cancelled as the click moves the pointer onto the
            // button (its own pointerenter fires), so the button stays actionable through the click.
            await page.Locator("#synthetic-scroll-region").HoverAsync();
            await Assertions.Expect(bottomButton).ToBeVisibleAsync();
 
            await bottomButton.ClickAsync();
 
            // Clicking jumps the region toward the bottom (smooth scroll; poll for scrollTop to move
            // well past the edge threshold).
            await page.WaitForFunctionAsync(
                "() => { const r = document.getElementById('synthetic-scroll-region'); return !!r && r.scrollTop > 500; }")
                .DefaultTimeout();
 
            // The affordance disappears once the region is already near the bottom.
            await Assertions.Expect(bottomButton).Not.ToHaveClassAsync(new Regex(@"\bis-visible\b"));
        });
    }
 
    [Fact]
    [OuterloopTest("Resource-intensive Playwright browser test")]
    public async Task ScrollButtons_RemainInactiveWhenRegionHasNoRoomForControl()
    {
        await RunTestAsync(async page =>
        {
            await GoToResourcesAndWaitAsync(page);
 
            await page.EvaluateAsync("""
                () => {
                    const region = document.createElement('div');
                    region.className = 'continuous-scroll-overflow';
                    region.id = 'partially-visible-scroll-region';
                    region.style.cssText = 'position:fixed;left:0;top:-280px;width:400px;height:300px;overflow:auto;';
                    const tall = document.createElement('div');
                    tall.style.height = '2000px';
                    region.appendChild(tall);
                    document.body.appendChild(region);
                }
                """);
 
            var buttons = page.Locator(".scroll-buttons");
            await Assertions.Expect(buttons).ToHaveCountAsync(1);
            await page.EvaluateAsync("() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))").DefaultTimeout();
 
            Assert.Equal("scroll-buttons", await buttons.GetAttributeAsync("class"));
 
            await page.EvaluateAsync("""
                () => {
                    document.getElementById('partially-visible-scroll-region').style.top = '0';
                    window.dispatchEvent(new Event('resize'));
                }
                """);
            await page.EvaluateAsync("() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))").DefaultTimeout();
 
            Assert.Equal("scroll-buttons is-active", await buttons.GetAttributeAsync("class"));
        });
    }
 
    private static async Task GoToResourcesAndWaitAsync(IPage page)
    {
        await page.GotoAsync("/");
        await Assertions
            .Expect(page.GetByText(InteractionsDashboardServerFixture.ParentResourceName).First)
            .ToBeVisibleAsync();
    }
 
    public sealed class InteractionsDashboardServerFixture : DashboardServerFixture
    {
        public const string ParentResourceName = "parentapp";
 
        protected override IReadOnlyList<ResourceViewModel> Resources =>
        [
            ModelTestHelpers.CreateResource(
                resourceName: ParentResourceName,
                resourceType: KnownResourceTypes.Project,
                state: KnownResourceState.Running,
                urls:
                [
                    new UrlViewModel("http", new Uri("about:blank#parent-url"), isInternal: false, isInactive: false, UrlDisplayPropertiesViewModel.Empty)
                ]),
        ];
    }
}