diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5d2b1d69..0c4a8ffd 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -2823,9 +2823,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2843,9 +2840,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2863,9 +2857,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2883,9 +2874,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2903,9 +2891,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2923,9 +2908,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/package-lock.json b/package-lock.json index 892f6a64..95e24fea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "type-check": "^0.4.0" }, "devDependencies": { + "@axe-core/playwright": "^4.11.2", "@bgotink/playwright-coverage": "^0.3.2", "@playwright/test": "^1.59.1", "@types/eslint-plugin-jsx-a11y": "^6.10.1", @@ -24,6 +25,19 @@ "vitest": "^4.1.5" } }, + "node_modules/@axe-core/playwright": { + "version": "4.11.2", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.2.tgz", + "integrity": "sha512-iP6hfNl9G0j/SEUSo8M7D80RbcDo9KRAAfDP4IT5OHB+Wm6zUHIrm8Y51BKI+Oyqduvipf9u1hcRy57zCBKzWQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.11.3" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, "node_modules/@bcoe/v8-coverage": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", @@ -1024,6 +1038,16 @@ "node": ">=12" } }, + "node_modules/axe-core": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.3.tgz", + "integrity": "sha512-zBQouZixDTbo3jMGqHKyePxYxr1e5W8UdTmBQ7sNtaA9M2bE32daxxPLS/jojhKOHxQ7LWwPjfiwf/fhaJWzlg==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", diff --git a/package.json b/package.json index ba7636bd..1561c352 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "smol-toml": "^1.6.1" }, "devDependencies": { + "@axe-core/playwright": "^4.11.2", "@bgotink/playwright-coverage": "^0.3.2", "@playwright/test": "^1.59.1", "@types/eslint-plugin-jsx-a11y": "^6.10.1", diff --git a/tests/a11y/a11y-baseline.ts b/tests/a11y/a11y-baseline.ts new file mode 100644 index 00000000..f3b268e1 --- /dev/null +++ b/tests/a11y/a11y-baseline.ts @@ -0,0 +1,15 @@ +export interface BaselineEntry { + ruleId: string; + pages: string[]; + reason: string; + ticket?: string; + expiresAt?: string; +} + +export const A11Y_BASELINE: BaselineEntry[] = []; + +export function getBaselinedRuleIds(currentPage: string): string[] { + return A11Y_BASELINE + .filter((entry) => entry.pages.some((p) => currentPage.startsWith(p))) + .map((entry) => entry.ruleId); +} diff --git a/tests/fixtures/a11y.ts b/tests/fixtures/a11y.ts new file mode 100644 index 00000000..a6a5c552 --- /dev/null +++ b/tests/fixtures/a11y.ts @@ -0,0 +1,18 @@ +import { test as base } from './auth-fixtures'; +import AxeBuilder from '@axe-core/playwright'; + +interface A11yFixtures { + makeAxeBuilder: () => AxeBuilder; +} + +export const test = base.extend({ + makeAxeBuilder: async ({ page }, use) => { + const makeAxeBuilder = () => + new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa', 'wcag22aa']) + .exclude('.chart-container canvas'); + await use(makeAxeBuilder); + }, +}); + +export { expect } from './auth-fixtures'; diff --git a/tests/utils/a11y-helpers.ts b/tests/utils/a11y-helpers.ts new file mode 100644 index 00000000..f1b49b44 --- /dev/null +++ b/tests/utils/a11y-helpers.ts @@ -0,0 +1,58 @@ +import { expect } from '../fixtures/test'; +import type { AxeResults, Result } from 'axe-core'; + +export type ViolationImpact = 'critical' | 'serious' | 'moderate' | 'minor'; + +export interface A11yAssertionOptions { + failOn?: ViolationImpact[]; + knownViolations?: string[]; +} + +const DEFAULT_FAIL_ON: ViolationImpact[] = ['critical', 'serious']; + +export function getFailingViolations( + results: AxeResults, + options: A11yAssertionOptions = {}, +): Result[] { + const failOn = options.failOn ?? DEFAULT_FAIL_ON; + const knownViolations = new Set(options.knownViolations ?? []); + + return results.violations.filter( + (v) => + failOn.includes(v.impact as ViolationImpact) && + !knownViolations.has(v.id), + ); +} + +export function formatViolation(violation: Result): string { + const nodes = violation.nodes + .map((node, i) => { + const selector = node.target.join(' '); + const html = node.html.length > 200 + ? `${node.html.slice(0, 200)}…` + : node.html; + const fix = node.failureSummary ?? ''; + return ` Node ${i + 1}: ${selector}\n HTML: ${html}\n Fix: ${fix}`; + }) + .join('\n'); + + return [ + `[${violation.impact?.toUpperCase()}] ${violation.id}: ${violation.description}`, + ` Help: ${violation.helpUrl}`, + ` Affected nodes (${violation.nodes.length}):`, + nodes, + ].join('\n'); +} + +export function expectNoA11yViolations( + results: AxeResults, + options: A11yAssertionOptions = {}, +): void { + const failing = getFailingViolations(results, options); + + const message = failing.length > 0 + ? `Found ${failing.length} accessibility violation(s):\n\n${failing.map(formatViolation).join('\n\n')}` + : ''; + + expect(failing, message).toEqual([]); +}