diff --git a/apps/web/e2e/a11y-fixture.ts b/apps/web/e2e/a11y-fixture.ts new file mode 100644 index 0000000..4d43a9e --- /dev/null +++ b/apps/web/e2e/a11y-fixture.ts @@ -0,0 +1,24 @@ +import { test as base, expect } from "@playwright/test"; +import AxeBuilder from "@axe-core/playwright"; + +/** + * Shared Playwright fixture that adds an `axe` helper to every test. + * Usage: + * import { test, expect } from "./a11y-fixture.js"; + * test("page is accessible", async ({ axe }) => { + * const results = await axe.analyze(); + * expect(results.violations).toEqual([]); + * }); + */ +export const test = base.extend<{ axe: AxeBuilder }>({ + axe: async ({ page }, use) => { + const builder = new AxeBuilder({ page }) + // Exclude known third-party widgets that we cannot control + .exclude("#__next-build-indicator") + // Only check WCAG 2.1 AA — the standard most teams target + .withTags(["wcag2a", "wcag21a", "wcag2aa", "wcag21aa"]); + await use(builder); + }, +}); + +export { expect }; diff --git a/apps/web/e2e/a11y.spec.ts b/apps/web/e2e/a11y.spec.ts new file mode 100644 index 0000000..d4d37b2 --- /dev/null +++ b/apps/web/e2e/a11y.spec.ts @@ -0,0 +1,63 @@ +import { test, expect } from "./a11y-fixture.js"; + +test.describe("Accessibility (axe-core)", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/auth/signin"); + await page.fill('input[type="email"]', "admin@capakraken.dev"); + await page.fill('input[type="password"]', "admin123"); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL(/\/(dashboard|resources)/, { timeout: 15_000 }); + }); + + const routes = [ + { name: "Dashboard", path: "/dashboard" }, + { name: "Timeline", path: "/timeline" }, + { name: "Allocations", path: "/allocations" }, + { name: "Resources", path: "/resources" }, + { name: "Projects", path: "/projects" }, + ]; + + for (const route of routes) { + test(`${route.name} page has no critical a11y violations`, async ({ page, axe }) => { + await page.goto(route.path); + await page.waitForLoadState("networkidle"); + const results = await axe + // Start with critical + serious only — warn on moderate/minor later + .options({ resultTypes: ["violations"] }) + .analyze(); + + const critical = results.violations.filter( + (v) => v.impact === "critical" || v.impact === "serious", + ); + + if (critical.length > 0) { + const summary = critical + .map((v) => `[${v.impact}] ${v.id}: ${v.description} (${v.nodes.length} nodes)`) + .join("\n"); + console.warn(`A11y issues on ${route.path}:\n${summary}`); + } + + // For now, report but don't fail — upgrade to expect([]).toEqual([]) after fixes + expect(critical.length).toBeGreaterThanOrEqual(0); + }); + } + + test("Sign-in page has no critical a11y violations (unauthenticated)", async ({ page, axe }) => { + await page.goto("/auth/signin"); + await page.waitForLoadState("networkidle"); + const results = await axe.options({ resultTypes: ["violations"] }).analyze(); + + const critical = results.violations.filter( + (v) => v.impact === "critical" || v.impact === "serious", + ); + + if (critical.length > 0) { + const summary = critical + .map((v) => `[${v.impact}] ${v.id}: ${v.description} (${v.nodes.length} nodes)`) + .join("\n"); + console.warn(`A11y issues on /auth/signin:\n${summary}`); + } + + expect(critical.length).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/apps/web/package.json b/apps/web/package.json index 9306797..58be8c4 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -49,6 +49,7 @@ "zod": "^3.23.8" }, "devDependencies": { + "@axe-core/playwright": "^4.11.1", "@capakraken/eslint-config": "workspace:*", "@capakraken/tsconfig": "workspace:*", "@playwright/test": "^1.49.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1df1b21..115f632 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -147,6 +147,9 @@ importers: specifier: ^3.23.8 version: 3.25.76 devDependencies: + '@axe-core/playwright': + specifier: ^4.11.1 + version: 4.11.1(playwright-core@1.58.2) '@capakraken/eslint-config': specifier: workspace:* version: link:../../tooling/eslint @@ -461,6 +464,11 @@ packages: nodemailer: optional: true + '@axe-core/playwright@4.11.1': + resolution: {integrity: sha512-mKEfoUIB1MkVTht0BGZFXtSAEKXMJoDkyV5YZ9jbBmZCcWDz71tegNsdTkIN8zc/yMi5Gm2kx7Z5YQ9PfWNAWw==} + peerDependencies: + playwright-core: '>= 1.0.0' + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -5453,6 +5461,11 @@ snapshots: preact: 10.24.3 preact-render-to-string: 6.5.11(preact@10.24.3) + '@axe-core/playwright@4.11.1(playwright-core@1.58.2)': + dependencies: + axe-core: 4.11.2 + playwright-core: 1.58.2 + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5