From 6db96b6ef56785bd3bc4cc54253533c23fa22105 Mon Sep 17 00:00:00 2001 From: donghyeon-ka Date: Sat, 25 Jul 2026 21:11:23 +0900 Subject: [PATCH] feat: establish automated and manual accessibility gates --- .gitignore | 1 + artifacts/tests/a11y-manual/APP_HOME.md | 15 ++++++++ docs/accessibility/manual-checklist.md | 17 +++++++++ package.json | 3 +- playwright.config.js | 2 +- scripts/verify-a11y-manual.mjs | 25 +++++++++++++ scripts/write-a11y-report.mjs | 18 +++++++++ src/presentation/components/async-surface.jsx | 9 ++++- tests/e2e/accessibility.spec.js | 37 +++++++++++++++++++ 9 files changed, 123 insertions(+), 4 deletions(-) create mode 100644 artifacts/tests/a11y-manual/APP_HOME.md create mode 100644 docs/accessibility/manual-checklist.md create mode 100644 scripts/verify-a11y-manual.mjs create mode 100644 scripts/write-a11y-report.mjs create mode 100644 tests/e2e/accessibility.spec.js diff --git a/.gitignore b/.gitignore index f2b1e12..087872e 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ artifacts/**/*.json artifacts/**/*.xml artifacts/**/*.txt artifacts/**/*.sarif +artifacts/tests/e2e/ !artifacts/**/.gitkeep diff --git a/artifacts/tests/a11y-manual/APP_HOME.md b/artifacts/tests/a11y-manual/APP_HOME.md new file mode 100644 index 0000000..cfd99fd --- /dev/null +++ b/artifacts/tests/a11y-manual/APP_HOME.md @@ -0,0 +1,15 @@ +# APP_HOME accessibility review + +Status: pending-manual-review + +Reviewer: + +Keyboard: automated tab-order fixture passed; human review pending. + +Focus: automated visible-focus fixture passed; route-change review pending. + +Screen reader: pending. + +Reduced motion: automated media-query fixture passed; human review pending. + +Color signal: pending. diff --git a/docs/accessibility/manual-checklist.md b/docs/accessibility/manual-checklist.md new file mode 100644 index 0000000..fc4bb55 --- /dev/null +++ b/docs/accessibility/manual-checklist.md @@ -0,0 +1,17 @@ +# Manual accessibility review checklist + +Automated axe checks do not establish WCAG conformance. A human reviewer must +copy this checklist to `artifacts/tests/a11y-manual/.md`, execute it +on the release candidate, and sign it. + +- Status: `pending` or `reviewed` +- Reviewer and reviewed-at timestamp +- Keyboard: all actions reachable in logical order +- Focus: visible, route changes deterministic, modal restore verified +- Screen reader: headings, live regions, errors, and actions announced once +- Reduced motion: non-essential animation suppressed +- Color signal: every state has text/icon/structure in addition to color +- Notes and linked defect IDs + +Passing the automated threshold means only that the tested pages had zero +critical/serious axe findings under the recorded browser run. diff --git a/package.json b/package.json index 26e8757..cb373b9 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "test:component": "vitest run tests/component --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/component.xml", "test:integration": "vitest run tests/integration --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/integration.xml", "test:e2e": "playwright test", - "test:a11y": "playwright test --grep @a11y", + "test:a11y": "playwright test --grep @a11y && node scripts/write-a11y-report.mjs", + "review:a11y-manual": "node scripts/verify-a11y-manual.mjs", "test:sample-removal": "node scripts/test-sample-removal.mjs", "test:all": "pnpm test:runtime-schema && pnpm test:unit && pnpm test:component && pnpm test:integration" }, diff --git a/playwright.config.js b/playwright.config.js index 6f4a944..43ec1ab 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -13,7 +13,7 @@ export default defineConfig({ screenshot: "only-on-failure", }, webServer: { - command: "pnpm dev --host 127.0.0.1", + command: "corepack pnpm dev --host 127.0.0.1", url: "http://127.0.0.1:5173", reuseExistingServer: !process.env.CI, }, diff --git a/scripts/verify-a11y-manual.mjs b/scripts/verify-a11y-manual.mjs new file mode 100644 index 0000000..329f573 --- /dev/null +++ b/scripts/verify-a11y-manual.mjs @@ -0,0 +1,25 @@ +import { readFile } from "node:fs/promises"; + +const evidence = await readFile( + "artifacts/tests/a11y-manual/APP_HOME.md", + "utf8", +); + +const required = [ + "Status: reviewed", + "Reviewer:", + "Keyboard:", + "Focus:", + "Screen reader:", + "Reduced motion:", + "Color signal:", +]; + +const missing = required.filter((marker) => !evidence.includes(marker)); +if (missing.length > 0) { + process.stderr.write( + `Manual accessibility evidence is incomplete: ${missing.join(", ")}\n`, + ); + process.exit(1); +} +process.stdout.write("Manual accessibility evidence: PASS\n"); diff --git a/scripts/write-a11y-report.mjs b/scripts/write-a11y-report.mjs new file mode 100644 index 0000000..f7304f9 --- /dev/null +++ b/scripts/write-a11y-report.mjs @@ -0,0 +1,18 @@ +import { mkdir, writeFile } from "node:fs/promises"; + +await mkdir("artifacts/tests", { recursive: true }); +await writeFile( + "artifacts/tests/a11y.json", + `${JSON.stringify( + { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + scope: ["APP_HOME", "SAMPLE_RESOURCE_LIST", "NOT_FOUND"], + threshold: { critical: 0, serious: 0 }, + automatedStatus: "passed", + manualReview: "see artifacts/tests/a11y-manual/APP_HOME.md", + }, + null, + 2, + )}\n`, +); diff --git a/src/presentation/components/async-surface.jsx b/src/presentation/components/async-surface.jsx index b7704b0..d86223f 100644 --- a/src/presentation/components/async-surface.jsx +++ b/src/presentation/components/async-surface.jsx @@ -1,7 +1,12 @@ /** @param {{ label?: string }} props */ export function LoadingSurface({ label = "불러오는 중" }) { return ( -
+
@@ -11,7 +16,7 @@ export function LoadingSurface({ label = "불러오는 중" }) { /** @param {{ title?: string, action?: React.ReactNode }} props */ export function EmptySurface({ title = "표시할 항목이 없습니다.", action }) { return ( -
+

{title}

{action}
diff --git a/tests/e2e/accessibility.spec.js b/tests/e2e/accessibility.spec.js new file mode 100644 index 0000000..307485c --- /dev/null +++ b/tests/e2e/accessibility.spec.js @@ -0,0 +1,37 @@ +import AxeBuilder from "@axe-core/playwright"; +import { expect, test } from "@playwright/test"; + +for (const route of ["/", "/sample/resources", "/not-found"]) { + test(`@a11y ${route} has no critical or serious axe violations`, async ({ + page, + }) => { + await page.goto(route); + await expect(page.getByRole("main")).toBeVisible(); + const results = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .analyze(); + const blocking = results.violations.filter((violation) => + ["critical", "serious"].includes(violation.impact ?? ""), + ); + expect(blocking).toEqual([]); + }); +} + +test("@a11y keyboard reaches the primary route action with visible focus", async ({ + page, +}) => { + await page.goto("/"); + await page.keyboard.press("Tab"); + const action = page.getByRole("link", { name: "샘플 리소스" }); + await expect(action).toBeFocused(); + await expect(action).toHaveCSS("outline-style", "solid"); +}); + +test("@a11y reduced-motion policy disables long animation", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.goto("/"); + const duration = await page + .locator("body") + .evaluate((body) => getComputedStyle(body).animationDuration); + expect(["0s", "0.00001s", "1e-05s"]).toContain(duration); +});