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(' 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") 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, ): Promise => { 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( - '[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 { [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 + * (`