From a52e92ae3e5232622fe66f569fa966f1097d867c Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 12 May 2026 21:23:42 +0200 Subject: [PATCH] test(a11y): @axe-core/playwright smoke check for major pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 — wires `@axe-core/playwright` into the smoke suite so any critical/serious WCAG 2.1 A/AA violation on the main authenticated pages fails CI. tests/e2e/smoke/20-accessibility.spec.ts: Iterates 6 routes (dashboard, clients, yachts, interests, berths, admin/branding) — each navigates after login, waits for networkidle, runs AxeBuilder with WCAG2/2.1 A+AA tags, asserts no critical/serious violations. DISABLED_RULES list trims two known-noisy rules that fire on Radix primitives + design-pass-pending muted text: - tabindex (Radix focus traps) - color-contrast (muted body text, pending design pass) The list is intentionally small; new entries require a comment and an audit. Easier to widen than narrow. Run: pnpm exec playwright test --project=smoke No vitest impact (1298/1298 still green); the spec only runs on the e2e playwright project so the unit suite stays fast. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 1 + pnpm-lock.yaml | 13 ++++++ tests/e2e/smoke/20-accessibility.spec.ts | 59 ++++++++++++++++++++++++ 3 files changed, 73 insertions(+) create mode 100644 tests/e2e/smoke/20-accessibility.spec.ts diff --git a/package.json b/package.json index 17a3b01d..d311d71c 100644 --- a/package.json +++ b/package.json @@ -114,6 +114,7 @@ "zustand": "^5.0.13" }, "devDependencies": { + "@axe-core/playwright": "^4.11.3", "@eslint/eslintrc": "^3.3.5", "@hookform/devtools": "^4.4.0", "@next/bundle-analyzer": "^16.2.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 414de234..92c468b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -266,6 +266,9 @@ importers: specifier: ^5.0.13 version: 5.0.13(@types/react@19.2.14)(immer@11.1.7)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)) devDependencies: + '@axe-core/playwright': + specifier: ^4.11.3 + version: 4.11.3(playwright-core@1.60.0) '@eslint/eslintrc': specifier: ^3.3.5 version: 3.3.5 @@ -381,6 +384,11 @@ packages: '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@axe-core/playwright@4.11.3': + resolution: {integrity: sha512-h/kfksv4F0cVIDlKpT4700OehdRgpvuVskuQ2nb7/JmtWUXpe9ftHAPtwyXGvVSsa6SJ64A9ER7Zrzc/sIvC4w==} + peerDependencies: + playwright-core: '>= 1.0.0' + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -6160,6 +6168,11 @@ snapshots: '@asamuzakjp/nwsapi@2.3.9': {} + '@axe-core/playwright@4.11.3(playwright-core@1.60.0)': + dependencies: + axe-core: 4.11.4 + playwright-core: 1.60.0 + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 diff --git a/tests/e2e/smoke/20-accessibility.spec.ts b/tests/e2e/smoke/20-accessibility.spec.ts new file mode 100644 index 00000000..a147aa21 --- /dev/null +++ b/tests/e2e/smoke/20-accessibility.spec.ts @@ -0,0 +1,59 @@ +/** + * Accessibility smoke test — runs axe-core against the main authenticated + * pages and fails CI if a new WCAG 2.1 A/AA serious-or-critical violation + * shows up. Existing violations on third-party / shadcn primitives are + * pruned via the disableRules list, kept small and audited. + * + * Add new pages here as they're built; each entry is < 200ms in a smoke run. + */ + +import { expect, test } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; + +import { login } from './helpers'; + +const PAGES = [ + { path: '/port-nimara', label: 'Dashboard' }, + { path: '/port-nimara/clients', label: 'Clients list' }, + { path: '/port-nimara/yachts', label: 'Yachts list' }, + { path: '/port-nimara/interests', label: 'Interests list' }, + { path: '/port-nimara/berths', label: 'Berths list' }, + { path: '/port-nimara/admin/branding', label: 'Admin / Branding' }, +]; + +// Known third-party + shadcn primitives whose violations we accept for now. +// Tightened as the design system addresses each one. +const DISABLED_RULES = [ + // Radix's hidden focus traps occasionally produce "tabindex >= 0 should + // not be on a non-interactive element" — known shadcn dialog/sheet shape. + 'tabindex', + // Color-contrast on muted body text — design pass pending. + 'color-contrast', +]; + +test.describe('accessibility — smoke', () => { + test.beforeEach(async ({ page }) => { + await login(page, 'super_admin'); + }); + + for (const { path, label } of PAGES) { + test(`${label} has no critical/serious WCAG violations`, async ({ page }) => { + await page.goto(path); + await page.waitForLoadState('networkidle'); + const results = await new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']) + .disableRules(DISABLED_RULES) + .analyze(); + const blocking = results.violations.filter( + (v) => v.impact === 'critical' || v.impact === 'serious', + ); + if (blocking.length > 0) { + const summary = blocking + .map((v) => ` - [${v.impact}] ${v.id}: ${v.help} (${v.nodes.length} occurrences)`) + .join('\n'); + throw new Error(`Accessibility violations on ${path}:\n${summary}\n\nSee ${results.url}`); + } + expect(blocking).toEqual([]); + }); + } +});