| File: Integration\Playwright\MobileNavMenuTests.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.Tests.Integration.Playwright.Infrastructure; using Aspire.TestUtilities; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Playwright; using Xunit; namespace Aspire.Dashboard.Tests.Integration.Playwright; [RequiresFeature(TestFeature.Playwright)] public sealed class MobileNavMenuTests : PlaywrightTestsBase<DashboardServerFixture> { private const string SettingsMenuItemTitle = "Settings"; public MobileNavMenuTests(DashboardServerFixture dashboardServerFixture) : base(dashboardServerFixture) { } [Fact] [OuterloopTest("Resource-intensive Playwright browser test")] public async Task MobileNavMenuClosesWhenFocusLeavesMenu() { await using var context = await PlaywrightFixture.Browser.NewContextAsync(new BrowserNewContextOptions { IgnoreHTTPSErrors = true, BaseURL = DashboardServerFixture.DashboardApp.FrontendSingleEndPointAccessor().GetResolvedAddress(), ViewportSize = new ViewportSize { Width = 640, Height = 384 } }); var page = await context.NewPageAsync(); await page.GotoAsync("/").DefaultTimeout(); await Assertions.Expect(page.GetByText(MockDashboardClient.TestResource1.DisplayName)).ToBeVisibleAsync(); await page.Locator(".navigation-button").ClickAsync(); var menu = page.Locator("fluent-menu.mobile-nav-menu"); await Assertions.Expect(menu).ToBeVisibleAsync(); await page.Keyboard.PressAsync("Tab"); Assert.True(await page.EvaluateAsync<bool>(IsFocusInsideMobileNavMenuScript)); await page.EvaluateAsync(""" () => { const button = document.createElement('button'); button.id = 'outside-mobile-nav'; button.textContent = 'Outside mobile nav'; document.body.appendChild(button); } """); await page.Locator("#outside-mobile-nav").FocusAsync(); await Assertions.Expect(menu).ToBeHiddenAsync(); var activeElementId = await page.EvaluateAsync<string?>("() => document.activeElement?.id"); Assert.Equal("outside-mobile-nav", activeElementId); } [Fact] [OuterloopTest("Resource-intensive Playwright browser test")] public async Task MobileNavFocusRemainsVisibleAtHighZoomViewport() { await using var context = await PlaywrightFixture.Browser.NewContextAsync(new BrowserNewContextOptions { IgnoreHTTPSErrors = true, BaseURL = DashboardServerFixture.DashboardApp.FrontendSingleEndPointAccessor().GetResolvedAddress(), ViewportSize = new ViewportSize { Width = 640, Height = 384 } }); var page = await context.NewPageAsync(); await page.GotoAsync("/").DefaultTimeout(); await Assertions.Expect(page.GetByText(MockDashboardClient.TestResource1.DisplayName)).ToBeVisibleAsync(); await page.Locator(".navigation-button").ClickAsync(); var menu = page.Locator("fluent-menu.mobile-nav-menu"); await Assertions.Expect(menu).ToBeVisibleAsync(); await page.Keyboard.PressAsync("Escape"); await Assertions.Expect(menu).ToBeHiddenAsync(); var activeElementId = await page.EvaluateAsync<string?>("() => document.activeElement?.id"); Assert.Equal("dashboard-navigation-button", activeElementId); await page.Locator(".navigation-button").ClickAsync(); await Assertions.Expect(menu).ToBeVisibleAsync(); await page.Keyboard.PressAsync("Tab"); var focusHistory = new List<string?>(); for (var i = 0; i < 15; i++) { var activeTitle = await page.EvaluateAsync<string?>(GetActiveMenuItemTitleScript); focusHistory.Add(activeTitle ?? await page.EvaluateAsync<string>(""" () => { const element = document.activeElement; return `${element?.tagName ?? '<null>'}:${element?.className ?? ''}:${element?.getAttribute?.('title') ?? ''}`; } """)); if (activeTitle == SettingsMenuItemTitle) { break; } await page.Keyboard.PressAsync("ArrowDown"); } var metrics = await page.EvaluateAsync<MobileNavFocusMetrics>(""" () => { const menu = document.querySelector('fluent-menu.mobile-nav-menu'); const focusedMenuItem = getActiveMenuItem(); const menuRect = menu.getBoundingClientRect(); const focusedRect = focusedMenuItem?.getBoundingClientRect() ?? new DOMRect(); const style = getComputedStyle(menu); return { activeTitle: focusedMenuItem?.getAttribute('title'), menuTop: menuRect.top, menuBottom: menuRect.bottom, focusedTop: focusedRect.top, focusedBottom: focusedRect.bottom, viewportHeight: innerHeight, paddingTop: style.paddingTop, paddingBottom: style.paddingBottom, paddingTopValue: Number.parseFloat(style.paddingTop), paddingBottomValue: Number.parseFloat(style.paddingBottom), scrollPaddingTop: style.scrollPaddingTop, scrollPaddingBottom: style.scrollPaddingBottom }; function getActiveMenuItem() { const visited = new Set(); let element = document.activeElement; while (element && !visited.has(element)) { visited.add(element); if (element.matches?.('fluent-menu-item')) { return element; } if (element.shadowRoot?.activeElement) { element = element.shadowRoot.activeElement; continue; } element = element.getRootNode?.().host ?? null; } return null; } } """); Assert.True(metrics.ActiveTitle == SettingsMenuItemTitle, $"Focus did not reach the Settings menu item. History: {string.Join(" -> ", focusHistory)}"); Assert.True(metrics.MenuTop >= 0, $"Menu starts above viewport: {metrics}"); Assert.True(metrics.MenuBottom <= metrics.ViewportHeight, $"Menu extends below viewport: {metrics}"); Assert.True(metrics.FocusedTop >= metrics.MenuTop + metrics.PaddingTopValue - 0.5, $"Focused item starts inside the menu focus padding: {metrics}"); Assert.True(metrics.FocusedBottom <= metrics.MenuBottom - metrics.PaddingBottomValue + 0.5, $"Focused item ends inside the menu focus padding: {metrics}"); Assert.Equal("4px", metrics.PaddingTop); Assert.Equal("4px", metrics.PaddingBottom); Assert.Equal("4px", metrics.ScrollPaddingTop); Assert.Equal("4px", metrics.ScrollPaddingBottom); await page.Keyboard.PressAsync("Escape"); await Assertions.Expect(menu).ToBeHiddenAsync(); activeElementId = await page.EvaluateAsync<string?>("() => document.activeElement?.id"); Assert.Equal("dashboard-navigation-button", activeElementId); } private const string GetActiveMenuItemTitleScript = """ () => { const visited = new Set(); let element = document.activeElement; while (element && !visited.has(element)) { visited.add(element); if (element.matches?.('fluent-menu-item')) { return element.getAttribute('title'); } if (element.shadowRoot?.activeElement) { element = element.shadowRoot.activeElement; continue; } element = element.getRootNode?.().host ?? null; } return null; } """; private const string IsFocusInsideMobileNavMenuScript = """ () => { const menu = document.querySelector('fluent-menu.mobile-nav-menu'); if (!menu) return false; let element = document.activeElement; while (element) { if (element === menu) { return true; } // Traverse upward: if inside a shadow root, go to the shadow host; // otherwise walk up the light DOM via parentElement. const root = element.getRootNode?.(); element = (root instanceof ShadowRoot ? root.host : element.parentElement) ?? null; } return false; } """; private sealed class MobileNavFocusMetrics { public string? ActiveTitle { get; set; } public double MenuTop { get; set; } public double MenuBottom { get; set; } public double FocusedTop { get; set; } public double FocusedBottom { get; set; } public double ViewportHeight { get; set; } public string PaddingTop { get; set; } = null!; public string PaddingBottom { get; set; } = null!; public double PaddingTopValue { get; set; } public double PaddingBottomValue { get; set; } public string ScrollPaddingTop { get; set; } = null!; public string ScrollPaddingBottom { get; set; } = null!; } }