From d227cfcb101ef6017e3597b210add9d744ea364f Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:55:47 +0100 Subject: [PATCH 1/3] Stop background git from racing test_docs_mirror temp cleanup test_staged_root_sourced_doc_with_stale_mirror_fails errored in the "Script smoke tests & backend build" job on PR #1857 (run 33609703555) with OSError [Errno 39] Directory not empty: '.git' raised from TemporaryDirectory cleanup. The test body passed; a background git process spawned by init/add/commit (auto-gc, fsmonitor, or maintenance) was still writing under .git when shutil.rmtree ran. The suite passes on main most of the time and locally, so this is a race, not a logic bug. Disable gc.auto, core.fsmonitor and maintenance.auto for the throwaway repos, and point GIT_CONFIG_GLOBAL/GIT_CONFIG_SYSTEM at os.devnull so a runner's host config cannot re-enable them. Construct both temp directories with ignore_cleanup_errors=True as a belt-and-braces fallback so a straggler can never fail a test whose assertions already passed. --- scripts/tests/test_docs_mirror.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/scripts/tests/test_docs_mirror.py b/scripts/tests/test_docs_mirror.py index 8376ad149..7a149f68c 100644 --- a/scripts/tests/test_docs_mirror.py +++ b/scripts/tests/test_docs_mirror.py @@ -4,6 +4,7 @@ from __future__ import annotations import importlib.util +import os from pathlib import Path import subprocess import sys @@ -20,6 +21,17 @@ sys.modules[SPEC.name] = docs_mirror SPEC.loader.exec_module(docs_mirror) +# Background git processes (auto-gc, fsmonitor, maintenance) can still be +# writing under .git when TemporaryDirectory cleanup runs, which makes rmtree +# fail with "Directory not empty". Disable them for the throwaway repos and +# ignore host config so it cannot re-enable them. +GIT_ENV = { + **os.environ, + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_CONFIG_SYSTEM": os.devnull, +} + + def run_git(root: Path, *args: str) -> None: subprocess.run( [ @@ -30,10 +42,17 @@ def run_git(root: Path, *args: str) -> None: "user.email=test@example.invalid", "-c", "user.name=test", + "-c", + "gc.auto=0", + "-c", + "core.fsmonitor=false", + "-c", + "maintenance.auto=false", *args, ], check=True, capture_output=True, + env=GIT_ENV, ) @@ -59,7 +78,7 @@ class DocsMirrorMappingTest(unittest.TestCase): class DocsMirrorStagedTest(unittest.TestCase): def setUp(self) -> None: - self._temporary = tempfile.TemporaryDirectory() + self._temporary = tempfile.TemporaryDirectory(ignore_cleanup_errors=True) self.addCleanup(self._temporary.cleanup) self.root = Path(self._temporary.name) run_git(self.root, "init", "-q") @@ -139,7 +158,7 @@ class DocsMirrorStagedTest(unittest.TestCase): class DocsMirrorWorktreeTest(unittest.TestCase): def test_worktree_drift_and_sync(self) -> None: - with tempfile.TemporaryDirectory() as temporary: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temporary: root = Path(temporary) write(root, "docs/GUIDE.md", "# Guide v2\n") write(root, "frontend-modern/public/docs/GUIDE.md", "# Guide v1\n") From 908afdf0c5e1278bd38d73f3be42e28cca12fbe0 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 2 Sep 2026 10:53:18 +0100 Subject: [PATCH 2/3] Keep navigation badges readable and the skip link first in tab order The primary and mobile navigation count badges used text-amber-900, which tailwind.config.js defines at 25% alpha as a dark-surface background token. Once the navigation became links (08581a2bc9, 5cfa0f26b6) and lost aria-disabled, the axe scan in the product-trust spec stopped skipping the Patrol badge and measured it at 1.49:1. The badges now use opaque amber-800 (slate-900 on amber-400 in dark mode) and the update banner's Pre-release label moves from orange-700 (4.38:1) to orange-800. The skip-to-content link lived inside AppLayout, but the global banners render before AppLayout, so whenever the update banner showed the first Tab landed on its Dismiss button. The link now renders from the app shell ahead of the banner block, with AppLayout keeping the #main target. Verified on the pulse-dev Playwright rig against a pulse:test image built from this change: axe WCAG A/AA scans of /alerts/overview, /patrol, /settings/infrastructure and /settings/system-general at 1280x720 and of /actions at 390x844 report no colour-contrast violation on the navigation or banner, and Tab from the page start focuses the skip link. Contract-Neutral: colour and DOM-order accessibility fix with no public contract change --- frontend-modern/browser-verification.json | 49 +++++++++++-------- frontend-modern/src/App.tsx | 5 ++ frontend-modern/src/AppLayout.tsx | 25 ++-------- .../src/__tests__/AppLayout.test.tsx | 4 -- .../src/components/UpdateBanner.tsx | 2 +- .../src/components/shared/MobileNavBar.tsx | 4 +- .../components/shared/SkipToContentLink.tsx | 33 +++++++++++++ .../__tests__/SkipToContentLink.test.tsx | 41 ++++++++++++++++ 8 files changed, 114 insertions(+), 49 deletions(-) create mode 100644 frontend-modern/src/components/shared/SkipToContentLink.tsx create mode 100644 frontend-modern/src/components/shared/__tests__/SkipToContentLink.test.tsx diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 4d29339e3..f61341654 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,42 +1,49 @@ { "version": 1, - "base_sha": "facee87bb4e7b84a0ce682d6e142d35b55be3ace", - "verified_at": "2026-09-02T08:55:07Z", + "base_sha": "b5f694b216e7a333c8ce26746b4e218e59c65efc", + "verified_at": "2026-09-02T09:53:16Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/api/patrol.ts", - "frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx", - "frontend-modern/src/features/patrol/PatrolWeeklyDigestCard.tsx" + "frontend-modern/src/App.tsx", + "frontend-modern/src/AppLayout.tsx", + "frontend-modern/src/components/UpdateBanner.tsx", + "frontend-modern/src/components/shared/MobileNavBar.tsx", + "frontend-modern/src/components/shared/SkipToContentLink.tsx" ], "content_sha256": { - "frontend-modern/src/api/patrol.ts": "c411c12d504b53e64b1435c5b336ba953f5051f6909f2bfef3e46067d5b88f34", - "frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx": "0d46fd5afbbdf8e1b885fa558be070c07203cc6613dda4458e9790fa8f2e4099", - "frontend-modern/src/features/patrol/PatrolWeeklyDigestCard.tsx": "9a9728f618c502a5379f56e93cad29ea939f5c26e73e3c7d22195308a8087b81" + "frontend-modern/src/App.tsx": "a46918db428d7965d72e5e8a296ea813970472dfe8fd04f4dcae18439fa80375", + "frontend-modern/src/AppLayout.tsx": "827b69928bf4b6b2da8b445d0b95bd2a89cc2721c494d624f13d422af2e7ff99", + "frontend-modern/src/components/UpdateBanner.tsx": "14f6125638c81b66bf69a7b40231f2187851edfc5f08093e82c4ba3b4f73005e", + "frontend-modern/src/components/shared/MobileNavBar.tsx": "f0aaa04aab89c0de1794d80448cb478e6cc5cac274519fe37a793b530cd6a00f", + "frontend-modern/src/components/shared/SkipToContentLink.tsx": "3d89ab42df5b342639af1b87012b173f5965b0a1041785a8b53f32675956b1bc" }, "routes": [ - "/patrol" + "/alerts/overview", + "/actions", + "/patrol", + "/settings/infrastructure", + "/settings/system-general" ], "viewports": [ { "width": 1280, - "height": 800 + "height": 720 }, { - "width": 375, - "height": 812 + "width": 390, + "height": 844 } ], "states": [ - "Activity tab with the This week card above Verified outcomes, populated from an isolated mock-mode backend (11 runs, 3 new issues, watch-only mode)", - "card tiles in single column at 375px with no horizontal overflow", - "card refresh in flight and settled", - "Verified outcomes empty state and Review and history below the card", - "watch-only tile copy for Investigated and Fixes run" + "Desktop primary navigation with the Patrol utility link carrying an amber attention-count badge on /alerts/overview", + "Phone-width mobile navigation rail with the Patrol destination carrying an amber count badge on /actions", + "Update banner showing the Pre-release badge beside the update message", + "Skip to main content link revealed at the top-left after the first Tab from the page start, ahead of the update banner controls, at desktop and phone widths" ], "interactions": [ - "clicked the Activity workspace tab", - "scrolled the card into view at desktop and narrow widths", - "clicked Refresh this week's summary and confirmed the tiles reloaded without an error state", - "checked console for card-originated errors (only unrelated dev websocket/update-check noise)" + "ran the axe-core WCAG 2.x A/AA scan on /alerts/overview, /patrol, /settings/infrastructure and /settings/system-general at 1280x720 with the desktop attention badge rendered and confirmed no color-contrast violation remains on the navigation badges", + "ran the axe-core WCAG 2.x A/AA scan on /actions at 390x844 with the mobile rail count badge rendered and confirmed no color-contrast violation remains on the badge or the Pre-release label", + "read the computed badge colours in the page and confirmed the count text now resolves to an opaque amber-800 instead of the theme's 25 percent alpha amber-900", + "reset focus to the document body on /actions at 390x844 with the update banner present, pressed Tab and confirmed the skip link received focus and became visible, then pressed Enter and confirmed focus moved to the main landmark" ] } diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index 1050cf660..4b34f0857 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -7,6 +7,7 @@ import { SecurityWarning } from './components/SecurityWarning'; import { Login } from './components/Login'; import { logger } from './utils/logger'; import { UpdateBanner } from './components/UpdateBanner'; +import { SkipToContentLink } from './components/shared/SkipToContentLink'; import { WhatsNewCard } from './components/WhatsNewCard'; import { DemoBanner } from './components/DemoBanner'; import { CommercialMigrationBanner } from './components/CommercialMigrationBanner'; @@ -532,6 +533,10 @@ function App() { > + {/* First focusable element in the document: the skip link + has to precede the banners below, or Tab from the page + start lands on a banner control instead. */} + {/* Global banners deep-link into settings (security hardening, telemetry preferences, license management), so they are for sessions that can actually reach those diff --git a/frontend-modern/src/AppLayout.tsx b/frontend-modern/src/AppLayout.tsx index 3d4ea0def..c00ce6a70 100644 --- a/frontend-modern/src/AppLayout.tsx +++ b/frontend-modern/src/AppLayout.tsx @@ -255,10 +255,8 @@ export function AppLayout(props: AppLayoutProps) { const browserBrandName = createMemo(() => customBrandName() || 'Pulse'); const [headerVisible, setHeaderVisible] = createSignal(true); - const [skipLinkFocused, setSkipLinkFocused] = createSignal(false); const [primaryRouteMemoryVersion, setPrimaryRouteMemoryVersion] = createSignal(0); let headerEl: HTMLDivElement | undefined; - let mainContentEl: HTMLElement | undefined; let assistantLauncherEl: HTMLButtonElement | undefined; let restoreAssistantLauncherFocus = false; let headerHideTimeout: ReturnType | undefined; @@ -726,22 +724,8 @@ export function AppLayout(props: AppLayoutProps) {
- {/* Skip-to-content link: visually hidden until focused, then - appears as a button at the top-left. Lets keyboard users - jump past the chrome straight into the page content. */} - mainContentEl?.focus()} - onFocus={() => setSkipLinkFocused(true)} - onBlur={() => setSkipLinkFocused(false)} - class={ - skipLinkFocused() - ? 'absolute left-2 top-2 z-[100] rounded bg-blue-600 px-3 py-2 text-sm font-medium text-white shadow-lg outline outline-2 outline-offset-2 outline-white' - : 'sr-only' - } - > - Skip to main content - + {/* The skip-to-content link renders in App ahead of the global + banners; #main below is its target. */}
)} {tab.breakdown && tab.breakdown.warning > 0 && ( - + {tab.breakdown.warning} )} @@ -952,7 +936,7 @@ export function AppLayout(props: AppLayoutProps) { ); } return ( - + {total} ); @@ -982,7 +966,6 @@ export function AppLayout(props: AppLayoutProps) {
{ expect(main).toHaveClass('mb-1', 'sm:mb-2'); expect(main).toHaveAttribute('id', 'main'); expect(main).toHaveAttribute('tabindex', '-1'); - const skipLink = screen.getByRole('link', { name: 'Skip to main content' }); - expect(skipLink).toHaveAttribute('href', '#main'); - fireEvent.click(skipLink); - expect(main).toHaveFocus(); expect(screen.getByText('Preview')).toHaveClass('bg-orange-700', 'text-white'); expect(screen.getByText('Preview')).not.toHaveClass('bg-orange-500'); expect(container.querySelector('footer')).toHaveClass('pulse-footer', 'px-2', 'sm:px-4'); diff --git a/frontend-modern/src/components/UpdateBanner.tsx b/frontend-modern/src/components/UpdateBanner.tsx index 923483e26..6d359db59 100644 --- a/frontend-modern/src/components/UpdateBanner.tsx +++ b/frontend-modern/src/components/UpdateBanner.tsx @@ -170,7 +170,7 @@ export function UpdateBanner() { {/* Pre-release badge */} - + Pre-release diff --git a/frontend-modern/src/components/shared/MobileNavBar.tsx b/frontend-modern/src/components/shared/MobileNavBar.tsx index 923afc8a2..1c139e5bd 100644 --- a/frontend-modern/src/components/shared/MobileNavBar.tsx +++ b/frontend-modern/src/components/shared/MobileNavBar.tsx @@ -55,7 +55,7 @@ function MobileNavDestinationContent(props: { 0}> - + {badges().warning} @@ -66,7 +66,7 @@ function MobileNavDestinationContent(props: { {(count) => ( diff --git a/frontend-modern/src/components/shared/SkipToContentLink.tsx b/frontend-modern/src/components/shared/SkipToContentLink.tsx new file mode 100644 index 000000000..c26099240 --- /dev/null +++ b/frontend-modern/src/components/shared/SkipToContentLink.tsx @@ -0,0 +1,33 @@ +import { Component, createSignal } from 'solid-js'; + +/** + * Skip-to-content link for keyboard and screen-reader users. + * + * It has to be the first focusable element in the document, ahead of every + * global banner (update, security, demo), so a single Tab from the page start + * reaches it. The shell renders it before those banners; AppLayout owns the + * `#main` target it jumps to. Visually hidden until focused, then shown as a + * button at the top-left. + */ +export const SkipToContentLink: Component<{ targetId?: string }> = (props) => { + const targetId = () => props.targetId ?? 'main'; + const [focused, setFocused] = createSignal(false); + + return ( + document.getElementById(targetId())?.focus()} + onFocus={() => setFocused(true)} + onBlur={() => setFocused(false)} + class={ + focused() + ? 'absolute left-2 top-2 z-[100] rounded bg-blue-600 px-3 py-2 text-sm font-medium text-white shadow-lg outline outline-2 outline-offset-2 outline-white' + : 'sr-only' + } + > + Skip to main content + + ); +}; + +export default SkipToContentLink; diff --git a/frontend-modern/src/components/shared/__tests__/SkipToContentLink.test.tsx b/frontend-modern/src/components/shared/__tests__/SkipToContentLink.test.tsx new file mode 100644 index 000000000..c695cb596 --- /dev/null +++ b/frontend-modern/src/components/shared/__tests__/SkipToContentLink.test.tsx @@ -0,0 +1,41 @@ +import { fireEvent, render, screen } from '@solidjs/testing-library'; +import { describe, expect, it } from 'vitest'; +import appSource from '@/App.tsx?raw'; +import { SkipToContentLink } from '@/components/shared/SkipToContentLink'; + +describe('SkipToContentLink', () => { + it('moves focus to the main landmark and reveals itself only while focused', () => { + render(() => ( + <> + +
+ Content +
+ + )); + + const skipLink = screen.getByRole('link', { name: 'Skip to main content' }); + expect(skipLink).toHaveAttribute('href', '#main'); + expect(skipLink).toHaveClass('sr-only'); + + fireEvent.focus(skipLink); + expect(skipLink).not.toHaveClass('sr-only'); + fireEvent.blur(skipLink); + expect(skipLink).toHaveClass('sr-only'); + + fireEvent.click(skipLink); + expect(screen.getByRole('main')).toHaveFocus(); + }); + + it('is rendered by the app shell ahead of the global banners', () => { + // Tab order follows DOM order. The update, security and demo banners + // render before AppLayout, so the link has to sit above that banner block + // or the first Tab lands on a banner control instead. + const skipLinkIndex = appSource.indexOf(''); + const bannerBlockIndex = appSource.indexOf(''); + const layoutIndex = appSource.indexOf(' Date: Wed, 2 Sep 2026 10:53:18 +0100 Subject: [PATCH 3/3] Realign Core E2E specs with the link navigation and Patrol controls Core E2E has been red on main since 2026-08-29 because product changes landed without their browser specs following: - The desktop and mobile navigation became links (08581a2bc9, 5cfa0f26b6); the specs still selected role=tab and tablist. helpers.ts gains primaryNavigation and primaryNavigationLink, and every navigation selector uses them or the navigation/link roles. - The Patrol records disclosure became "Finding options and history" and the per-finding Manage control became "Finding options for " (21367b655d). - Collapsed threshold sections are now inert and aria-hidden (434f777a59). Sections open collapsed, so the Ceph and threshold-override specs had been asserting on invisible rows; they expand the section first and follow the toggle button into the heading. - The APT dialog copy changed punctuation, the backups route gained /date, and the mobile rail no longer has separate primary and utility rails. - The phone-width accessibility scan runs under reduced motion so it samples the settled update banner instead of a mid-fade frame. Validated on the pulse-dev rig against a pulse:test image built from this branch: 17, 18, 66, 73, 83 and journeys/01 pass on chromium; 04, 53 (nav flow), 66, 73, 81, 83 and journeys/01 on mobile-chrome and mobile-safari. Probation specs 78, 91 and 92 still fail on Patrol workbench content that changed in today's landings; that is product drift outside the navigation change and is left for its lane. Contract-Neutral: spec realignment and test-only navigation helpers with no runtime or contract change --- tests/integration/tests/01-core-e2e.spec.ts | 10 +++-- .../tests/02-navigation-perf.spec.ts | 10 ++++- tests/integration/tests/04-mobile.spec.ts | 43 ++++++++----------- .../integration/tests/06-theme-visual.spec.ts | 2 +- .../tests/11-first-session.spec.ts | 8 ++-- .../tests/17-proxmox-backups-layout.spec.ts | 7 +-- .../tests/18-patrol-runtime-state.spec.ts | 9 +++- .../53-demo-mode-commercial-boundary.spec.ts | 25 ++++++----- .../tests/66-ceph-alert-thresholds.spec.ts | 9 ++++ ...patrol-assistant-operator-briefing.spec.ts | 30 +++++++------ .../78-monitor-first-patrol-workbench.spec.ts | 24 ++++++----- .../tests/81-actions-inbox.spec.ts | 2 +- .../83-product-trust-accessibility.spec.ts | 4 ++ ...erational-trust-protection-posture.spec.ts | 9 ++-- ...rational-trust-attention-workbench.spec.ts | 11 +++-- ...erational-trust-availability-facet.spec.ts | 4 +- tests/integration/tests/helpers.ts | 19 ++++++++ ...oke-bootstrap-login-infrastructure.spec.ts | 12 +++--- 18 files changed, 138 insertions(+), 100 deletions(-) diff --git a/tests/integration/tests/01-core-e2e.spec.ts b/tests/integration/tests/01-core-e2e.spec.ts index 37a80bbc6..ba298c5d9 100644 --- a/tests/integration/tests/01-core-e2e.spec.ts +++ b/tests/integration/tests/01-core-e2e.spec.ts @@ -63,11 +63,15 @@ test.describe.serial('Core E2E flows', () => { test.skip(true, 'Virtualization Hosts section not present (nodes not in unified resources)'); } - const sectionToggle = proxmoxNodesHeading.locator('xpath=ancestor::button[1]'); + // The section heading wraps its toggle button; the toggle's + // aria-controls names the collapsible body that holds the rows. + const sectionToggle = proxmoxNodesHeading.getByRole('button'); if ((await sectionToggle.getAttribute('aria-expanded')) === 'false') { await sectionToggle.click(); } - const proxmoxNodesSection = sectionToggle.locator('..'); + const proxmoxNodesSection = page.locator( + `#${await sectionToggle.getAttribute('aria-controls')}`, + ); const globalDefaultsContainer = proxmoxNodesSection .getByText('Global Defaults', { exact: true }) .first() @@ -206,7 +210,7 @@ test.describe.serial('Core E2E flows', () => { await expect(page.getByRole('heading', { name: 'Alert Thresholds' })).toBeVisible(); const sectionToggleAfterReload = page .getByRole('heading', { name: 'Virtualization Hosts' }) - .locator('xpath=ancestor::button[1]'); + .getByRole('button'); if ((await sectionToggleAfterReload.getAttribute('aria-expanded')) === 'false') { await sectionToggleAfterReload.click(); } diff --git a/tests/integration/tests/02-navigation-perf.spec.ts b/tests/integration/tests/02-navigation-perf.spec.ts index 70bfbf92d..2d6f3c032 100644 --- a/tests/integration/tests/02-navigation-perf.spec.ts +++ b/tests/integration/tests/02-navigation-perf.spec.ts @@ -1,5 +1,11 @@ import { test, expect, type Page } from '@playwright/test'; -import { ensureAuthenticated, getMockMode, setMockMode, waitForPulseReady } from './helpers'; +import { + ensureAuthenticated, + getMockMode, + primaryNavigationLink, + setMockMode, + waitForPulseReady, +} from './helpers'; const truthy = (value: string | undefined) => { if (!value) return false; @@ -91,7 +97,7 @@ const measureTabTransition = async ( waitForReady: (page: Page) => Promise<void>, ): Promise<number> => { const start = Date.now(); - await page.getByRole('tab', { name: new RegExp(`^${tabName}$`) }).first().click(); + await primaryNavigationLink(page, new RegExp(`^${tabName}$`)).first().click(); await waitForReady(page); return Date.now() - start; }; diff --git a/tests/integration/tests/04-mobile.spec.ts b/tests/integration/tests/04-mobile.spec.ts index 540d33b78..431ff6294 100644 --- a/tests/integration/tests/04-mobile.spec.ts +++ b/tests/integration/tests/04-mobile.spec.ts @@ -85,7 +85,7 @@ test.describe("Mobile viewport flows", () => { await page.goto("/infrastructure"); await expect(page.locator("#root")).toBeVisible(); - const bottomNav = page.getByRole("tablist", { name: "Mobile navigation" }); + const bottomNav = page.getByRole("navigation", { name: "Mobile navigation" }); // Wait for the nav to mount before evaluating (evaluateAll does not auto-wait; // WebKit can be slower to render SolidJS components than Chromium). @@ -116,7 +116,7 @@ test.describe("Mobile viewport flows", () => { await page.goto("/infrastructure"); await expect(page.locator("#root")).toBeVisible(); - const nav = page.getByRole("tablist", { name: "Mobile navigation" }); + const nav = page.getByRole("navigation", { name: "Mobile navigation" }); await expect(nav).toBeVisible(); // Verify the safe-area CSS class is applied to the nav. The computed padding-bottom @@ -200,52 +200,45 @@ test.describe("Mobile viewport flows", () => { await expect(table.locator("xpath=..")).toHaveClass(/overflow-x-auto/); }); - test("utility destinations stay pinned beside the scrollable platform rail", async ({ + test("utility destinations stay pinned in the fixed rail", async ({ page, }) => { await page.goto("/proxmox/overview"); - const primaryRail = page.locator('[data-mobile-nav-rail="primary"]'); - const utilityRail = page.locator('[data-mobile-nav-rail="utility"]'); - await expect(primaryRail).toBeVisible({ timeout: 30_000 }); - await expect(utilityRail).toBeVisible({ timeout: 30_000 }); + // The mobile rail no longer scrolls a platform strip: one fixed rail holds + // the platform switcher, the pinned utility destinations, and the More + // trigger for everything else, so the whole rail must fit the viewport. + const rail = page.locator('[data-mobile-nav-rail="fixed"]'); + await expect(rail).toBeVisible({ timeout: 30_000 }); await expect .poll( () => page.evaluate(() => { const element = document.querySelector<HTMLElement>( - '[data-mobile-nav-rail="primary"]', + '[data-mobile-nav-rail="fixed"]', ); if (!element) return null; - return { - hasScrollableOverflow: ["auto", "scroll"].includes( - window.getComputedStyle(element).overflowX, - ), - preservesRailWidth: element.scrollWidth >= element.clientWidth, - }; + return element.scrollWidth <= element.clientWidth + 1; }), { timeout: 30_000 }, ) - .toEqual({ - hasScrollableOverflow: true, - preservesRailWidth: true, - }); + .toBe(true); const viewportWidth = await getViewportWidth(page); - for (const tabId of ["alerts", "ai", "settings"]) { - const button = utilityRail.locator(`button[data-tab-id="${tabId}"]`); - await expect(button).toBeVisible(); - let box = await button.boundingBox(); + for (const tabId of ["platform-switcher", "alerts", "ai", "more"]) { + const destination = rail.locator(`[data-tab-id="${tabId}"]`); + await expect(destination).toBeVisible(); + let box = await destination.boundingBox(); await expect .poll(async () => { - box = await button.boundingBox(); + box = await destination.boundingBox(); return box; }) .not.toBeNull(); expect( box, - `${tabId} utility destination should have a layout box`, + `${tabId} destination should have a layout box`, ).toBeTruthy(); expect((box?.x ?? 0) + (box?.width ?? 0)).toBeLessThanOrEqual( viewportWidth + 1, @@ -421,7 +414,7 @@ test.describe("Mobile viewport flows", () => { await page.goto("/infrastructure"); await expect(page.locator("#root")).toBeVisible(); - const nav = page.getByRole("tablist", { name: "Mobile navigation" }); + const nav = page.getByRole("navigation", { name: "Mobile navigation" }); await expect(nav).toBeVisible(); const aiButton = page.getByRole("button", { diff --git a/tests/integration/tests/06-theme-visual.spec.ts b/tests/integration/tests/06-theme-visual.spec.ts index fd721f43e..82ca67d5f 100644 --- a/tests/integration/tests/06-theme-visual.spec.ts +++ b/tests/integration/tests/06-theme-visual.spec.ts @@ -75,7 +75,7 @@ async function stabilizeVisualState(page: Page): Promise<void> { [data-testid="update-banner"] { display: none !important; } - [role="tab"] span[class*="rounded-full"] { + nav[aria-label="Primary navigation"] a span[class*="rounded-full"] { display: none !important; } .tabs [role="group"][aria-label="Infrastructure"] { diff --git a/tests/integration/tests/11-first-session.spec.ts b/tests/integration/tests/11-first-session.spec.ts index 4a2ef3100..45cebfe35 100644 --- a/tests/integration/tests/11-first-session.spec.ts +++ b/tests/integration/tests/11-first-session.spec.ts @@ -4,6 +4,7 @@ import { ensureFirstRunExperience, navigateToSettings, apiRequest, + primaryNavigationLink, trackBrowserRequests, waitForPulseReady, } from "./helpers"; @@ -108,11 +109,8 @@ test.describe.serial("First-session experience", () => { await ensureAuthenticated(page); - // The Settings tab is rendered as a div[role="tab"] in the top utility bar. - const settingsTab = page - .locator('[role="tab"]') - .filter({ hasText: "Settings" }) - .first(); + // Settings is a link in the primary navigation's utility group. + const settingsTab = primaryNavigationLink(page, "Settings"); await expect( settingsTab, "Settings tab should be visible in the top nav bar", diff --git a/tests/integration/tests/17-proxmox-backups-layout.spec.ts b/tests/integration/tests/17-proxmox-backups-layout.spec.ts index bb801337e..9759a5e61 100644 --- a/tests/integration/tests/17-proxmox-backups-layout.spec.ts +++ b/tests/integration/tests/17-proxmox-backups-layout.spec.ts @@ -1,13 +1,10 @@ import { expect, test, type Page } from "@playwright/test"; -import { ensureAuthenticated } from "./helpers"; +import { ensureAuthenticated, primaryNavigationLink } from "./helpers"; const DESKTOP_VIEWPORT = { width: 1440, height: 900 }; async function openProxmoxBackups(page: Page) { - const proxmoxTab = page.getByRole("tab", { - name: "Proxmox", - exact: true, - }); + const proxmoxTab = primaryNavigationLink(page, "Proxmox"); await expect(proxmoxTab).toBeVisible({ timeout: 30_000 }); await proxmoxTab.click(); diff --git a/tests/integration/tests/18-patrol-runtime-state.spec.ts b/tests/integration/tests/18-patrol-runtime-state.spec.ts index 66da557cb..d50ded864 100644 --- a/tests/integration/tests/18-patrol-runtime-state.spec.ts +++ b/tests/integration/tests/18-patrol-runtime-state.spec.ts @@ -1,6 +1,11 @@ import { expect, test, type Page, type Route } from "@playwright/test"; -import { apiRequest, ensureAuthenticated, trackBrowserRequests } from "./helpers"; +import { + apiRequest, + ensureAuthenticated, + primaryNavigationLink, + trackBrowserRequests, +} from "./helpers"; const PATROL_BLOCK_REASON = "Connect a provider to power Pulse Assistant and Patrol."; @@ -580,7 +585,7 @@ test.describe("Patrol runtime-state browser contract", () => { entitlementsRequests.clear(); await mockBlockedPatrolRuntimeState(page); - await page.getByRole("tab", { name: "Patrol" }).click(); + await primaryNavigationLink(page, /Patrol/).click(); await expect(page).toHaveURL(/\/patrol/); // Banner and badge both use sentence case now. diff --git a/tests/integration/tests/53-demo-mode-commercial-boundary.spec.ts b/tests/integration/tests/53-demo-mode-commercial-boundary.spec.ts index fcd78465c..81bf99d28 100644 --- a/tests/integration/tests/53-demo-mode-commercial-boundary.spec.ts +++ b/tests/integration/tests/53-demo-mode-commercial-boundary.spec.ts @@ -2,7 +2,12 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { expect, test as base, type Page } from '@playwright/test'; -import { createAuthenticatedStorageState, ensureAuthenticated, trackBrowserRequests } from './helpers'; +import { + createAuthenticatedStorageState, + ensureAuthenticated, + primaryNavigationLink, + trackBrowserRequests, +} from './helpers'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -101,10 +106,10 @@ async function getVisibleSettingsNavigation( projectName: string, ) { if (projectName.startsWith('mobile-')) { - await page - .getByRole('tablist', { name: 'Mobile navigation' }) - .getByRole('button', { name: 'Settings', exact: true }) - .click(); + // Settings lives in the mobile rail's overflow menu, behind More navigation. + const mobileNavigation = page.getByRole('navigation', { name: 'Mobile navigation' }); + await mobileNavigation.getByRole('button', { name: 'More navigation' }).click(); + await mobileNavigation.getByRole('menuitem', { name: /^Settings/ }).click(); const settingsDrawerButton = page .getByRole('main') .getByRole('button', { name: 'Settings', exact: true }); @@ -355,10 +360,7 @@ base.describe('Demo mode commercial boundary', () => { await expect(page.getByText('Pro Trial:', { exact: false })).toHaveCount(0); await expect(page.getByText('Monitored systems: 16/5', { exact: true })).toHaveCount(0); await expect( - page - .locator('[role="tab"]') - .filter({ hasText: 'Settings' }) - .getByText('Pro', { exact: true }), + primaryNavigationLink(page, 'Settings').getByText('Pro', { exact: true }), ).toHaveCount(0); expect(licenseStatusRequests, 'demo settings route should not read license status').toBe(0); @@ -473,10 +475,7 @@ base.describe('Managed demo runtime commercial boundary', () => { await expect(page.getByText('Pro Trial:', { exact: false })).toHaveCount(0); await expect(page.getByText(/Monitored systems:\s*\d+\/\d+/)).toHaveCount(0); await expect( - page - .locator('[role="tab"]') - .filter({ hasText: 'Settings' }) - .getByText('Pro', { exact: true }), + primaryNavigationLink(page, 'Settings').getByText('Pro', { exact: true }), ).toHaveCount(0); expect( diff --git a/tests/integration/tests/66-ceph-alert-thresholds.spec.ts b/tests/integration/tests/66-ceph-alert-thresholds.spec.ts index f651a0146..c0490355d 100644 --- a/tests/integration/tests/66-ceph-alert-thresholds.spec.ts +++ b/tests/integration/tests/66-ceph-alert-thresholds.spec.ts @@ -272,6 +272,15 @@ test.describe('Ceph alert thresholds', () => { await expect(page).toHaveURL(/\/alerts\/thresholds\/infrastructure/); await expect(page.getByRole('heading', { name: 'Alert Thresholds' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Storage Devices' })).toBeVisible(); + // Threshold sections open collapsed; the collapsed body is inert and + // aria-hidden, so expand it before asserting on the rows it contains. + const storageToggle = page + .getByRole('heading', { name: 'Storage Devices' }) + .getByRole('button'); + if ((await storageToggle.getAttribute('aria-expanded')) === 'false') { + await storageToggle.click(); + } + await expect(storageToggle).toHaveAttribute('aria-expanded', 'true'); await expect(page.getByText('ceph-pool', { exact: true })).toBeVisible(); await expect(page.getByText('data_replication', { exact: true })).toBeVisible(); await expect(page.getByRole('button', { name: 'Revert to defaults for ceph-pool' })).toBeVisible(); diff --git a/tests/integration/tests/73-patrol-assistant-operator-briefing.spec.ts b/tests/integration/tests/73-patrol-assistant-operator-briefing.spec.ts index 4d067b491..e487e974d 100644 --- a/tests/integration/tests/73-patrol-assistant-operator-briefing.spec.ts +++ b/tests/integration/tests/73-patrol-assistant-operator-briefing.spec.ts @@ -20,7 +20,9 @@ async function openPatrolRecords(page: Page) { }); await expect(activityTab).toBeVisible({ timeout: 60_000 }); await activityTab.click(); - const recordsButton = page.getByRole("button", { name: /Patrol records/ }); + const recordsButton = page.getByRole("button", { + name: /Finding options and history/, + }); await expect(recordsButton).toBeVisible({ timeout: 60_000 }); await recordsButton.click(); } @@ -619,13 +621,13 @@ test.describe("Patrol Assistant operator briefing", () => { await openPatrolRecords(page); await page.getByText("High CPU usage").click(); - const findingReview = page.locator( - "#finding-finding-operator-briefing-details", - ); - await findingReview.getByText("Manage", { exact: true }).click(); - await findingReview - .getByRole("button", { name: "Open in Assistant" }) + // The finding options control and its menu render on the finding card, + // outside the details region. + await page + .getByRole("button", { name: "Finding options for High CPU usage" }) + .first() .click(); + await page.getByRole("button", { name: "Open in Assistant" }).click(); const assistantContext = page.getByLabel("Assistant context"); await expect(assistantContext).toBeVisible(); @@ -760,10 +762,11 @@ test.describe("Patrol Assistant operator briefing", () => { "#finding-finding-operator-briefing-details", ); await expect(queuedFinding.getByText("details unavailable")).toBeVisible(); - await queuedFinding.getByText("Manage", { exact: true }).click(); - await queuedFinding - .getByRole("button", { name: "Open in Assistant" }) + await page + .getByRole("button", { name: "Finding options for High CPU usage" }) + .first() .click(); + await page.getByRole("button", { name: "Open in Assistant" }).click(); const queuedAssistantContext = page.getByLabel("Assistant context"); await expect(queuedAssistantContext).toBeVisible(); @@ -792,10 +795,11 @@ test.describe("Patrol Assistant operator briefing", () => { await expect( expiredFinding.getByText("Action details unavailable"), ).toBeVisible(); - await expiredFinding.getByText("Manage", { exact: true }).click(); - await expiredFinding - .getByRole("button", { name: "Open in Assistant" }) + await page + .getByRole("button", { name: "Finding options for High CPU usage" }) + .first() .click(); + await page.getByRole("button", { name: "Open in Assistant" }).click(); const hydratedFindingAssistantContext = page.getByLabel("Assistant context"); diff --git a/tests/integration/tests/78-monitor-first-patrol-workbench.spec.ts b/tests/integration/tests/78-monitor-first-patrol-workbench.spec.ts index b49959381..9474470e1 100644 --- a/tests/integration/tests/78-monitor-first-patrol-workbench.spec.ts +++ b/tests/integration/tests/78-monitor-first-patrol-workbench.spec.ts @@ -3,7 +3,11 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { expect, test as base, type Page } from "@playwright/test"; -import { createAuthenticatedStorageState } from "./helpers"; +import { + createAuthenticatedStorageState, + primaryNavigation, + primaryNavigationLink, +} from "./helpers"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -653,14 +657,12 @@ test.describe("Monitor-first Patrol workbench browser contract", () => { timeout: 30_000, }); - const desktopNav = page.getByRole("tablist", { - name: "Primary navigation", - }); + const desktopNav = primaryNavigation(page); await expect( - desktopNav.getByRole("tab", { name: "Proxmox" }), + desktopNav.getByRole("link", { name: "Proxmox" }), ).toBeVisible(); await expect( - desktopNav.getByRole("tab", { + desktopNav.getByRole("link", { name: /Patrol: 1 (?:action awaits approval|active attention item)/, }), ).toBeVisible(); @@ -710,7 +712,7 @@ test.describe("Monitor-first Patrol workbench browser contract", () => { await expect(page.getByText("No Patrol work waiting")).toHaveCount(0); await expect(page.getByText("Next check scheduled")).toHaveCount(0); - await page.getByRole("tab", { name: "Patrol" }).click(); + await primaryNavigationLink(page, /Patrol/).click(); await expect(page).toHaveURL(/\/patrol$/); await expect( page.getByRole("heading", { level: 1, name: "Patrol" }), @@ -759,7 +761,7 @@ test.describe("Monitor-first Patrol workbench browser contract", () => { ).toHaveCount(0); await expect(page.getByText("What Pulse checked")).toHaveCount(0); - await page.getByRole("tab", { name: /Patrol/ }).click(); + await primaryNavigationLink(page, /Patrol/).click(); await expect(page).toHaveURL(/\/patrol$/); await expect( page.getByRole("heading", { level: 2, name: "1 decision needs you" }), @@ -886,7 +888,7 @@ test.describe("Monitor-first Patrol workbench browser contract", () => { ).toBeVisible(); await page.getByRole("tab", { name: "Activity", exact: true }).click(); await page - .getByRole("button", { name: /Findings and run records/ }) + .getByRole("button", { name: /Finding options and history/ }) .click(); await expect( page.getByText("Operating system updates need review").first(), @@ -933,7 +935,7 @@ test.describe("Monitor-first Patrol workbench browser contract", () => { await page.reload({ waitUntil: "domcontentloaded" }); await page.getByRole("tab", { name: "Activity", exact: true }).click(); await page - .getByRole("button", { name: /Findings and run records/ }) + .getByRole("button", { name: /Finding options and history/ }) .click(); const cleanupTitle = page .getByText("Downloaded package data is using needed space") @@ -991,7 +993,7 @@ test.describe("Monitor-first Patrol workbench browser contract", () => { await page.reload({ waitUntil: "domcontentloaded" }); await page.getByRole("tab", { name: "Activity", exact: true }).click(); await page - .getByRole("button", { name: /Findings and run records/ }) + .getByRole("button", { name: /Finding options and history/ }) .click(); await page.getByRole("button", { name: "Resolved", exact: true }).click(); const resolvedTitle = page diff --git a/tests/integration/tests/81-actions-inbox.spec.ts b/tests/integration/tests/81-actions-inbox.spec.ts index 67250658a..1065602e8 100644 --- a/tests/integration/tests/81-actions-inbox.spec.ts +++ b/tests/integration/tests/81-actions-inbox.spec.ts @@ -630,7 +630,7 @@ test("APT history keeps update execution verification recovery and delayed recei await expect(dialog.getByText("Confirmed by executing agent")).toBeVisible(); await expect(dialog.getByText("Source: Executing agent")).toBeVisible(); await expect( - dialog.getByText("Yes — fact only; no reboot was authorized"), + dialog.getByText("Yes — fact only. No reboot was authorized"), ).toBeVisible(); await expect(dialog.getByTestId("action-execution-truth")).toBeVisible(); await expect(dialog.getByTestId("action-verification-truth")).toBeVisible(); diff --git a/tests/integration/tests/83-product-trust-accessibility.spec.ts b/tests/integration/tests/83-product-trust-accessibility.spec.ts index 48b74f8d7..4c19429fe 100644 --- a/tests/integration/tests/83-product-trust-accessibility.spec.ts +++ b/tests/integration/tests/83-product-trust-accessibility.spec.ts @@ -105,6 +105,10 @@ test("Actions remains named, directly reachable, keyboard accessible, and free o page, }, testInfo) => { await page.setViewportSize({ width: 390, height: 844 }); + // Scan the settled surface: the update banner slides in over 300ms and + // axe would otherwise sample its controls mid-fade and report blended + // colours as contrast failures. + await page.emulateMedia({ reducedMotion: "reduce" }); await page.route("**/api/actions?*", (route) => route.fulfill({ status: 200, diff --git a/tests/integration/tests/90-operational-trust-protection-posture.spec.ts b/tests/integration/tests/90-operational-trust-protection-posture.spec.ts index d152db16a..441bec575 100644 --- a/tests/integration/tests/90-operational-trust-protection-posture.spec.ts +++ b/tests/integration/tests/90-operational-trust-protection-posture.spec.ts @@ -1,13 +1,10 @@ import { expect, test, type Page } from "@playwright/test"; -import { ensureAuthenticated } from "./helpers"; +import { ensureAuthenticated, primaryNavigationLink } from "./helpers"; const DESKTOP_VIEWPORT = { width: 1440, height: 900 }; async function openDesktopProxmoxBackups(page: Page) { - const proxmoxTab = page.getByRole("tab", { - name: "Proxmox", - exact: true, - }); + const proxmoxTab = primaryNavigationLink(page, "Proxmox"); await expect(proxmoxTab).toBeVisible({ timeout: 30_000 }); await proxmoxTab.click(); @@ -16,7 +13,7 @@ async function openDesktopProxmoxBackups(page: Page) { }); await expect(sections).toBeVisible({ timeout: 60_000 }); await sections.getByRole("link", { name: "Backups", exact: true }).click(); - await expect(page).toHaveURL(/\/proxmox\/backups$/); + await expect(page).toHaveURL(/\/proxmox\/backups\/date$/); } test.describe("Operational trust protection posture", () => { diff --git a/tests/integration/tests/91-operational-trust-attention-workbench.spec.ts b/tests/integration/tests/91-operational-trust-attention-workbench.spec.ts index 3b2f7ecb4..695e9be5a 100644 --- a/tests/integration/tests/91-operational-trust-attention-workbench.spec.ts +++ b/tests/integration/tests/91-operational-trust-attention-workbench.spec.ts @@ -1,4 +1,5 @@ import { expect, test, type Page, type Route } from "@playwright/test"; +import { primaryNavigationLink } from "./helpers"; type AttentionMode = "active" | "calm" | "failed"; @@ -112,7 +113,7 @@ test.beforeEach(async ({ page }) => { ).not.toBe(""); await expect( page - .getByRole("tab", { name: "Patrol", exact: true }) + .getByRole("link", { name: "Patrol", exact: true }) .or(page.getByRole("button", { name: "Patrol", exact: true })) .first(), ).toBeVisible(); @@ -694,10 +695,8 @@ test("starts from the normal monitor shell and reaches the canonical attention q (page.viewportSize()?.width ?? 1280) < 1024 ? page .getByRole("navigation", { name: "Mobile navigation" }) - .getByRole("button", { name: /Patrol/ }) - : page - .getByRole("tab", { name: /Patrol/ }) - .or(page.getByRole("link", { name: /Patrol/ })); + .getByRole("link", { name: /Patrol/ }) + : primaryNavigationLink(page, /Patrol/); await patrolNavigation.click(); await expect(page).toHaveURL(/\/patrol/); @@ -749,7 +748,7 @@ test("makes active operational work primary and preserves the evidence boundary" await page.goto("/patrol", { waitUntil: "domcontentloaded" }); await expect( - page.getByRole("tab", { name: "Patrol: 2 active attention items" }).or( + page.getByRole("link", { name: "Patrol: 2 active attention items" }).or( page.getByRole("button", { name: "Patrol: 2 active attention items", }), diff --git a/tests/integration/tests/92-operational-trust-availability-facet.spec.ts b/tests/integration/tests/92-operational-trust-availability-facet.spec.ts index f6240d23a..4b48f3337 100644 --- a/tests/integration/tests/92-operational-trust-availability-facet.spec.ts +++ b/tests/integration/tests/92-operational-trust-availability-facet.spec.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { test as base, expect, type Page, type Route } from "@playwright/test"; -import { createAuthenticatedStorageState } from "./helpers"; +import { createAuthenticatedStorageState, primaryNavigation } from "./helpers"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -590,7 +590,7 @@ test.describe("Operational trust availability resource facet", () => { const queue = page.getByRole("region", { name: "Patrol decision inbox" }); await expect(queue).toBeVisible({ timeout: 30_000 }); await expect( - page.getByRole("tab", { + primaryNavigation(page).getByRole("link", { name: /Patrol: 1 (?:action awaits approval|active attention item)/, }), ).toBeVisible(); diff --git a/tests/integration/tests/helpers.ts b/tests/integration/tests/helpers.ts index 6f59e3ad8..0f4523875 100644 --- a/tests/integration/tests/helpers.ts +++ b/tests/integration/tests/helpers.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { Browser, + Locator, Page, Request, expect, @@ -1402,6 +1403,24 @@ export async function getMockMode(page: Page) { ); } +/** + * The desktop shell's primary navigation. Since the navigation became links + * (`<nav aria-label="Primary navigation">` with one `<a>` per destination), + * destinations are addressed by link role; the utility links carry their + * count in the accessible name ("Patrol: 2 active attention items"), so pass a + * regular expression when the count is not under test. + */ +export function primaryNavigation(page: Page): Locator { + return page.getByRole("navigation", { name: "Primary navigation" }); +} + +export function primaryNavigationLink(page: Page, name: string | RegExp): Locator { + return primaryNavigation(page).getByRole("link", { + name, + exact: typeof name === "string", + }); +} + /** * Navigate to settings page */ diff --git a/tests/integration/tests/journeys/01-smoke-bootstrap-login-infrastructure.spec.ts b/tests/integration/tests/journeys/01-smoke-bootstrap-login-infrastructure.spec.ts index d17bd317c..8a3cc140a 100644 --- a/tests/integration/tests/journeys/01-smoke-bootstrap-login-infrastructure.spec.ts +++ b/tests/integration/tests/journeys/01-smoke-bootstrap-login-infrastructure.spec.ts @@ -11,6 +11,8 @@ import { setMockMode, getMockMode, trackBrowserRequests, + primaryNavigation, + primaryNavigationLink, } from '../helpers'; /** @@ -142,12 +144,12 @@ test.describe.serial('Journey: Bootstrap → Login → Infrastructure', () => { await ensureJourneyReady(page); - // Wait for the primary navigation tablist to render before checking individual + // Wait for the primary navigation to render before checking individual // tabs. After login redirect, the SPA may still be hydrating the layout. - // Target the specific aria-label to avoid matching other tablists (e.g. mobile nav). + // Target the specific aria-label to avoid matching other navigation (e.g. mobile nav). await expect( - page.locator('[role="tablist"][aria-label="Primary navigation"]'), - 'Primary navigation tablist should render', + primaryNavigation(page), + 'Primary navigation should render', ).toBeVisible({ timeout: 15_000 }); // Core navigation tabs. The IA is platform-first: there is no single @@ -161,7 +163,7 @@ test.describe.serial('Journey: Bootstrap → Login → Infrastructure', () => { ]; for (const tabName of expectedTabs) { - const tab = page.locator('[role="tab"]').filter({ hasText: tabName }).first(); + const tab = primaryNavigationLink(page, new RegExp(tabName)).first(); await expect( tab, `Navigation tab "${tabName}" should be visible`,