From 918d87c47a4e4529c6ee3c437c19fbf36ce6224c Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 06:52:43 +0100 Subject: [PATCH] Add automated accessibility regression coverage --- .../components/Settings/SettingsPageShell.tsx | 9 ++- .../SettingsPageShell.mobile.test.tsx | 18 +++++ .../SharedPrimitives.guardrails.test.ts | 6 ++ frontend-modern/src/index.css | 7 ++ frontend-modern/src/pages/Alerts.tsx | 9 ++- tests/integration/package-lock.json | 24 +++++++ tests/integration/package.json | 1 + .../83-product-trust-accessibility.spec.ts | 70 ++++++++++++++++++- 8 files changed, 137 insertions(+), 7 deletions(-) diff --git a/frontend-modern/src/components/Settings/SettingsPageShell.tsx b/frontend-modern/src/components/Settings/SettingsPageShell.tsx index 01dd1d492..d229e445a 100644 --- a/frontend-modern/src/components/Settings/SettingsPageShell.tsx +++ b/frontend-modern/src/components/Settings/SettingsPageShell.tsx @@ -97,11 +97,10 @@ export const SettingsPageShell: Component = (props) => { border={false} class="relative flex min-w-0 max-w-full overflow-visible border-y border-border max-sm:rounded-none sm:border lg:min-h-[600px] lg:flex-row lg:overflow-hidden" > -
= (props) => { onClick={() => props.setSidebarCollapsed(true)} class="hidden rounded-md p-1 transition-colors hover:bg-surface-hover lg:inline-flex" aria-label={shellCopy().collapseSidebarLabel} + aria-controls="settings-sidebar-menu" + aria-expanded="true" > = (props) => { onClick={() => props.setSidebarCollapsed(false)} class="hidden w-full rounded-md p-2 transition-colors hover:bg-surface-hover lg:block" aria-label={shellCopy().expandSidebarLabel} + aria-controls="settings-sidebar-menu" + aria-expanded="false" > = (props) => {
- +
{ const navigation = document.querySelector('[data-settings-navigation]'); const contentBody = document.querySelector('[data-settings-content-body]'); expect(shell).toHaveClass('space-y-0', 'lg:space-y-6'); + expect(navigation?.tagName).toBe('NAV'); + expect(navigation).not.toHaveAttribute('aria-expanded'); expect(navigation).toHaveClass('max-h-[calc(100dvh-8rem)]'); + expect(screen.getByRole('button', { name: 'Collapse settings navigation' })).toHaveAttribute( + 'aria-controls', + 'settings-sidebar-menu', + ); + expect(screen.getByRole('button', { name: 'Collapse settings navigation' })).toHaveAttribute( + 'aria-expanded', + 'true', + ); expect(contentBody).toHaveClass('min-w-0', 'p-0', 'sm:p-4', 'lg:p-5'); setActiveTab('api'); @@ -135,6 +145,14 @@ describe('SettingsPageShell mobile navigation', () => { )); expect(sidebarCollapsed()).toBe(true); + expect(screen.getByRole('button', { name: 'Expand settings navigation' })).toHaveAttribute( + 'aria-controls', + 'settings-sidebar-menu', + ); + expect(screen.getByRole('button', { name: 'Expand settings navigation' })).toHaveAttribute( + 'aria-expanded', + 'false', + ); await screen.getByRole('button', { name: 'Settings' }).click(); await waitFor(() => { diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index 98463247f..becf1b528 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -8153,6 +8153,12 @@ describe('shared primitive guardrails', () => { expect(frontendIndexCssSource).toContain('font-variant-numeric: tabular-nums'); }); + it('disables login entrance animations when reduced motion is requested', () => { + expect(frontendIndexCssSource).toMatch( + /@media \(prefers-reduced-motion: reduce\) \{[\s\S]*?\.animate-fade-in,[\s\S]*?\.animate-slide-up \{[\s\S]*?animation: none;/, + ); + }); + it('keeps search field on shell, runtime, and model owners', () => { const registry = JSON.parse(sharedTemplateRegistrySource) as { rules?: Array<{ diff --git a/frontend-modern/src/index.css b/frontend-modern/src/index.css index cf33ad018..674fbfcc4 100644 --- a/frontend-modern/src/index.css +++ b/frontend-modern/src/index.css @@ -1322,6 +1322,13 @@ body, animation: slide-up 0.8s cubic-bezier(0.16, 1, 0.3, 1) 0.2s both; } +@media (prefers-reduced-motion: reduce) { + .animate-fade-in, + .animate-slide-up { + animation: none; + } +} + .delay-100 { animation-delay: 100ms; } diff --git a/frontend-modern/src/pages/Alerts.tsx b/frontend-modern/src/pages/Alerts.tsx index ec31ea2dc..0997e7a55 100644 --- a/frontend-modern/src/pages/Alerts.tsx +++ b/frontend-modern/src/pages/Alerts.tsx @@ -263,10 +263,9 @@ export function Alerts() { /> -
setSidebarCollapsed(true)} class="p-1 rounded-md hover:bg-surface-hover transition-colors" aria-label={t('alerts.nav.collapseSidebar')} + aria-controls="alerts-sidebar-menu" + aria-expanded="true" > setSidebarCollapsed(false)} class="w-full p-2 rounded-md hover:bg-surface-hover transition-colors" aria-label={t('alerts.nav.expandSidebar')} + aria-controls="alerts-sidebar-menu" + aria-expanded="false" >
-
+
0}> diff --git a/tests/integration/package-lock.json b/tests/integration/package-lock.json index c10f7e9e5..daed53535 100644 --- a/tests/integration/package-lock.json +++ b/tests/integration/package-lock.json @@ -9,11 +9,25 @@ "version": "1.0.0", "license": "MIT", "devDependencies": { + "@axe-core/playwright": "^4.13.0", "@playwright/test": "1.56.1", "@types/node": "^20.10.0", "typescript": "^5.3.0" } }, + "node_modules/@axe-core/playwright": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.13.0.tgz", + "integrity": "sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.13.0" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, "node_modules/@playwright/test": { "version": "1.56.1", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz", @@ -40,6 +54,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/axe-core": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz", + "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", diff --git a/tests/integration/package.json b/tests/integration/package.json index 3dfd5810f..ab40d2fdf 100644 --- a/tests/integration/package.json +++ b/tests/integration/package.json @@ -29,6 +29,7 @@ "author": "rcourtman", "license": "MIT", "devDependencies": { + "@axe-core/playwright": "^4.13.0", "@playwright/test": "1.56.1", "@types/node": "^20.10.0", "typescript": "^5.3.0" diff --git a/tests/integration/tests/83-product-trust-accessibility.spec.ts b/tests/integration/tests/83-product-trust-accessibility.spec.ts index 75c3035b6..616f55e51 100644 --- a/tests/integration/tests/83-product-trust-accessibility.spec.ts +++ b/tests/integration/tests/83-product-trust-accessibility.spec.ts @@ -1,10 +1,32 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { expect, test as base } from "@playwright/test"; +import AxeBuilder from "@axe-core/playwright"; +import { expect, test as base, type Page } from "@playwright/test"; import { createAuthenticatedStorageState } from "./helpers"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const WCAG_TAGS = [ + "wcag2a", + "wcag2aa", + "wcag21a", + "wcag21aa", + "wcag22aa", +] as const; + +const scanForWcagViolations = async (page: Page) => { + const results = await new AxeBuilder({ page }) + .withTags([...WCAG_TAGS]) + .analyze(); + + return results.violations.map((violation) => ({ + id: violation.id, + impact: violation.impact, + help: violation.help, + targets: violation.nodes.map((node) => node.target.join(" ")), + })); +}; + type WorkerFixtures = { authStorageStatePath: string }; const test = base.extend<{}, WorkerFixtures>({ storageState: async ({ authStorageStatePath }, use) => @@ -60,6 +82,7 @@ test("Actions remains named, directly reachable, keyboard accessible, and free o "aria-current", "page", ); + expect(await scanForWcagViolations(page)).toEqual([]); const overflow = await page.evaluate( () => document.documentElement.scrollWidth > @@ -73,3 +96,48 @@ test("Actions remains named, directly reachable, keyboard accessible, and free o contentType: "image/png", }); }); + +test("representative authenticated surfaces have no automatically detectable WCAG A/AA violations", async ({ + page, +}) => { + const surfaces = [ + { route: "/alerts/overview", heading: "Alerts Overview" }, + { route: "/settings/system-general", heading: "General" }, + { route: "/patrol", heading: "Patrol" }, + ] as const; + + for (const surface of surfaces) { + await page.goto(surface.route, { waitUntil: "domcontentloaded" }); + await expect( + page.getByRole("heading", { level: 1, name: surface.heading }), + ).toBeVisible(); + expect( + await scanForWcagViolations(page), + `${surface.route} should have no automatically detectable WCAG A/AA violations`, + ).toEqual([]); + } +}); + +test("the logged-out entry surface has no automatically detectable WCAG A/AA violations", async ({ + browser, +}, testInfo) => { + const context = await browser.newContext({ + baseURL: testInfo.project.use.baseURL, + }); + const page = await context.newPage(); + + try { + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.goto("/", { waitUntil: "domcontentloaded" }); + const heading = page.getByRole("heading", { name: "Welcome to Pulse" }); + await expect(heading).toBeVisible(); + await expect(heading).toHaveCSS("animation-name", "none"); + await expect(page.locator("form").first()).toHaveCSS( + "animation-name", + "none", + ); + expect(await scanForWcagViolations(page)).toEqual([]); + } finally { + await context.close(); + } +});