WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto

docs: ATB-34 axe-core a11y testing design

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.

+75
+75
docs/plans/2026-02-27-axe-core-a11y-design.md
··· 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`.