diff --git a/package-lock.json b/package-lock.json index 98f1101c1..27fc24b2b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "zod": "^4.4.3" }, "devDependencies": { + "@axe-core/playwright": "^4.12.1", "@next/bundle-analyzer": "^16.2.10", "@tailwindcss/postcss": "^4.3.2", "@types/node": "^26.1.1", @@ -62,6 +63,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@axe-core/playwright": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.12.1.tgz", + "integrity": "sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.12.1" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -4008,9 +4022,9 @@ } }, "node_modules/axe-core": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.0.tgz", - "integrity": "sha512-FTavr/7Ba0IptwGOPxnQvdyW2tAsdLBMTBXz7rKH6xJ2skpyxpBxyHkDdBs4lf69yRqYpkqCdfhnwS8YULGOmg==", + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz", + "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==", "dev": true, "license": "MPL-2.0", "engines": { diff --git a/package.json b/package.json index da84b2f3d..554c9abc1 100644 --- a/package.json +++ b/package.json @@ -145,6 +145,7 @@ "uuid": "^11.1.1" }, "devDependencies": { + "@axe-core/playwright": "^4.12.1", "@next/bundle-analyzer": "^16.2.10", "@tailwindcss/postcss": "^4.3.2", "@types/node": "^26.1.1", diff --git a/tests/ui-accessibility.spec.ts b/tests/ui-accessibility.spec.ts index 185421a70..319768c14 100644 --- a/tests/ui-accessibility.spec.ts +++ b/tests/ui-accessibility.spec.ts @@ -1,4 +1,5 @@ -import { expect, test, type Page } from "playwright/test"; +import AxeBuilder from "@axe-core/playwright"; +import { expect, test, type Page, type TestInfo } from "playwright/test"; const readySetupChecks = [ { id: "env", label: ".env.local configured", status: "ready", detail: "Test environment ready." }, @@ -70,6 +71,29 @@ async function openScopeControl(page: Page) { }); } +const axeWcagTags = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]; +// Advisory gate: only fail on the impacts a clinician-facing release must not ship. +// Lower-impact findings stay visible in the attached report without blocking the lane. +const axeBlockingImpacts = new Set(["critical", "serious"]); + +async function expectNoBlockingAxeViolations(page: Page, testInfo: TestInfo, options?: { disableRules?: string[] }) { + const builder = new AxeBuilder({ page }).withTags(axeWcagTags); + if (options?.disableRules?.length) builder.disableRules(options.disableRules); + const results = await builder.analyze(); + + await testInfo.attach("axe-violations", { + body: JSON.stringify(results.violations, null, 2), + contentType: "application/json", + }); + + const blocking = results.violations.filter((violation) => axeBlockingImpacts.has(violation.impact ?? "")); + const summary = blocking.map( + (violation) => + `${violation.id} (${violation.impact}): ${violation.help} — ${violation.nodes.length} node(s), see ${violation.helpUrl}`, + ); + expect(summary, "axe found critical/serious WCAG A/AA violations").toEqual([]); +} + test.describe("Clinical KB accessibility media smoke", () => { test.describe.configure({ timeout: 60_000 }); @@ -106,4 +130,27 @@ test.describe("Clinical KB accessibility media smoke", () => { await expectDashboardUsable(page); await expectNoPageHorizontalOverflow(page); }); + + test("dashboard passes axe WCAG A/AA scan with default colors", async ({ page }, testInfo) => { + await page.setViewportSize({ width: 1280, height: 800 }); + await mockMinimalDashboardApi(page); + await gotoApp(page); + + // Scan only after the usability gate: the shell double-renders during hydration and a + // premature scan reports duplicated-landmark false positives. + await expectDashboardUsable(page); + await expectNoBlockingAxeViolations(page, testInfo); + }); + + test("dashboard passes axe WCAG A/AA scan with forced colors", async ({ page }, testInfo) => { + await page.emulateMedia({ forcedColors: "active" }); + await page.setViewportSize({ width: 390, height: 844 }); + await mockMinimalDashboardApi(page); + await gotoApp(page); + + await expectDashboardUsable(page); + // color-contrast is unreliable under forced-colors emulation (the OS palette overrides + // author colors); contrast is asserted by the default-colors scan instead. + await expectNoBlockingAxeViolations(page, testInfo, { disableRules: ["color-contrast"] }); + }); });