Captures the approved design for adding axe-core + jsdom automated accessibility tests to apps/web — single consolidated test file, per-file jsdom environment pragma, one happy-path test per page route.
···1+# ATB-34: Axe-core Automated Accessibility Testing — Design
2+3+**Date:** 2026-02-27
4+**Linear:** ATB-34
5+**Status:** Approved
6+7+## Overview
8+9+Add automated WCAG AA accessibility tests to `apps/web` using `axe-core` and Vitest's built-in jsdom environment. Tests run as part of the existing `pnpm test` pipeline with no CI changes required.
10+11+## Architecture
12+13+### Approach
14+15+Single consolidated test file: `apps/web/src/__tests__/a11y.test.ts`
16+17+- `// @vitest-environment jsdom` pragma switches this file from the default `node` environment to jsdom, providing `document`, `window`, and `DOMParser` as globals.
18+- All other test files keep running under `node` — no environment change bleeds across.
19+- Tests call route handlers directly via `app.request()` (same pattern as existing tests), mock `fetch` globally, and run axe-core against the parsed HTML response.
20+21+### Dependencies
22+23+Add to `apps/web/package.json` devDependencies:
24+- `axe-core` — accessibility engine
25+- `jsdom` — DOM implementation Vitest's jsdom environment wraps
26+- `vitest` — currently works via pnpm hoisting but should be declared explicitly
27+28+## HTML Parsing & Axe Configuration
29+30+Each test follows this flow:
31+32+1. Mock `fetch` globally with `vi.stubGlobal('fetch', mockFetch)`
33+2. Call the route handler: `const res = await routes.request('/path')`
34+3. Get HTML: `const html = await res.text()`
35+4. Parse into DOM: `const doc = new DOMParser().parseFromString(html, 'text/html')`
36+5. Run axe: `const results = await axe.run(doc, { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa'] } })`
37+6. Assert with a useful failure message showing which rules violated
38+39+`DOMParser.parseFromString` is preferred over `document.write()` because it preserves attributes on the `<html>` element itself (including `lang="en"`, which axe-core checks). `document.write()` is deprecated and inconsistent.
40+41+### Known Limitation
42+43+jsdom has no CSS engine. Axe-core's `color-contrast` rules are automatically skipped. Tests cover structural/semantic WCAG AA rules only: landmark regions, heading hierarchy, form labels, `aria-*` attributes, image alt text, link purpose. Color contrast must be verified manually or via a Playwright-based test in the future.
44+45+## Route Coverage
46+47+Six full-page HTML routes get one test each. `POST /mod/action`, `POST /logout`, `POST /new-topic`, and `POST /topics/:id/reply` are excluded — they return HTML fragments or redirects, not full pages.
48+49+| Route | Auth state | Reason |
50+|---|---|---|
51+| `GET /` | unauthenticated | Standard landing state |
52+| `GET /login` | unauthenticated | Standard landing state |
53+| `GET /boards/1` | unauthenticated | Standard board view |
54+| `GET /topics/1` | unauthenticated | Standard topic view |
55+| `GET /new-topic?boardId=1` | **authenticated** | Unauthenticated renders trivial "Log in" prompt; form markup is the interesting a11y surface |
56+| `GET /anything-unknown` | unauthenticated | 404 catch-all |
57+58+## Fetch Mocking Strategy
59+60+`beforeEach` stubs `fetch` with a URL-dispatching mock function. `afterEach` calls `vi.unstubAllGlobals()`.
61+62+The mock inspects the URL and returns matching minimal fixture data — only the fields each route actually reads. Unmatched URLs return a 404 response to surface unexpected calls.
63+64+```ts
65+const mockFetch = vi.fn().mockImplementation((url: string) => {
66+ if (url.includes('/api/auth/session')) return Promise.resolve(sessionFixture(false));
67+ if (url.endsWith('/forum')) return Promise.resolve(forumFixture());
68+ // ...
69+ return Promise.resolve({ ok: false, status: 404, json: async () => ({}) });
70+});
71+```
72+73+## CI
74+75+No changes required. Tests are discovered by `vitest run` and block merge on failure via the existing `test` job in `.github/workflows/ci.yml`.