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.
···11+# ATB-34: Axe-core Automated Accessibility Testing — Design
22+33+**Date:** 2026-02-27
44+**Linear:** ATB-34
55+**Status:** Approved
66+77+## Overview
88+99+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.
1010+1111+## Architecture
1212+1313+### Approach
1414+1515+Single consolidated test file: `apps/web/src/__tests__/a11y.test.ts`
1616+1717+- `// @vitest-environment jsdom` pragma switches this file from the default `node` environment to jsdom, providing `document`, `window`, and `DOMParser` as globals.
1818+- All other test files keep running under `node` — no environment change bleeds across.
1919+- 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.
2020+2121+### Dependencies
2222+2323+Add to `apps/web/package.json` devDependencies:
2424+- `axe-core` — accessibility engine
2525+- `jsdom` — DOM implementation Vitest's jsdom environment wraps
2626+- `vitest` — currently works via pnpm hoisting but should be declared explicitly
2727+2828+## HTML Parsing & Axe Configuration
2929+3030+Each test follows this flow:
3131+3232+1. Mock `fetch` globally with `vi.stubGlobal('fetch', mockFetch)`
3333+2. Call the route handler: `const res = await routes.request('/path')`
3434+3. Get HTML: `const html = await res.text()`
3535+4. Parse into DOM: `const doc = new DOMParser().parseFromString(html, 'text/html')`
3636+5. Run axe: `const results = await axe.run(doc, { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa'] } })`
3737+6. Assert with a useful failure message showing which rules violated
3838+3939+`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.
4040+4141+### Known Limitation
4242+4343+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.
4444+4545+## Route Coverage
4646+4747+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.
4848+4949+| Route | Auth state | Reason |
5050+|---|---|---|
5151+| `GET /` | unauthenticated | Standard landing state |
5252+| `GET /login` | unauthenticated | Standard landing state |
5353+| `GET /boards/1` | unauthenticated | Standard board view |
5454+| `GET /topics/1` | unauthenticated | Standard topic view |
5555+| `GET /new-topic?boardId=1` | **authenticated** | Unauthenticated renders trivial "Log in" prompt; form markup is the interesting a11y surface |
5656+| `GET /anything-unknown` | unauthenticated | 404 catch-all |
5757+5858+## Fetch Mocking Strategy
5959+6060+`beforeEach` stubs `fetch` with a URL-dispatching mock function. `afterEach` calls `vi.unstubAllGlobals()`.
6161+6262+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.
6363+6464+```ts
6565+const mockFetch = vi.fn().mockImplementation((url: string) => {
6666+ if (url.includes('/api/auth/session')) return Promise.resolve(sessionFixture(false));
6767+ if (url.endsWith('/forum')) return Promise.resolve(forumFixture());
6868+ // ...
6969+ return Promise.resolve({ ok: false, status: 404, json: async () => ({}) });
7070+});
7171+```
7272+7373+## CI
7474+7575+No changes required. Tests are discovered by `vitest run` and block merge on failure via the existing `test` job in `.github/workflows/ci.yml`.