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([]); + }); + } +});