ATB-34: Axe-core Automated Accessibility Testing — Design#
Date: 2026-02-27 Linear: ATB-34 Status: Approved
Overview#
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.
Architecture#
Approach#
Single consolidated test file: apps/web/src/__tests__/a11y.test.ts
// @vitest-environment jsdompragma switches this file from the defaultnodeenvironment to jsdom, providingdocument,window, andDOMParseras globals.- All other test files keep running under
node— no environment change bleeds across. - Tests call route handlers directly via
app.request()(same pattern as existing tests), mockfetchglobally, and run axe-core against the parsed HTML response.
Dependencies#
Add to apps/web/package.json devDependencies:
axe-core— accessibility enginejsdom— DOM implementation Vitest's jsdom environment wrapsvitest— currently works via pnpm hoisting but should be declared explicitly
HTML Parsing & Axe Configuration#
Each test follows this flow:
- Mock
fetchglobally withvi.stubGlobal('fetch', mockFetch) - Call the route handler:
const res = await routes.request('/path') - Get HTML:
const html = await res.text() - Parse into DOM:
const doc = new DOMParser().parseFromString(html, 'text/html') - Run axe:
const results = await axe.run(doc, { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa'] } }) - Assert with a useful failure message showing which rules violated
Implementation divergence: This design specified DOMParser.parseFromString but the actual implementation uses document.open() / document.write(html) / document.close() instead. The reason: axe.run() is called with no context argument, so axe defaults to window.document. If a DOMParser document is passed explicitly, axe's internal isPageContext() check fails (it compares include[0].actualNode === document.documentElement), which disables page-level rules like html-has-lang and document-title — producing false greens on the rules we most care about. document.write() is deprecated but replaces window.document in place, keeping the default axe context correct. The call site is suppressed with @ts-ignore and an explanatory comment.
Known Limitation#
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.
Route Coverage#
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.
| Route | Auth state | Reason |
|---|---|---|
GET / |
unauthenticated | Standard landing state |
GET /login |
unauthenticated | Standard landing state |
GET /boards/1 |
unauthenticated | Standard board view |
GET /topics/1 |
unauthenticated | Standard topic view |
GET /new-topic?boardId=1 |
authenticated | Unauthenticated renders trivial "Log in" prompt; form markup is the interesting a11y surface |
GET /anything-unknown |
unauthenticated | 404 catch-all |
Fetch Mocking Strategy#
beforeEach stubs fetch with a URL-dispatching mock function. afterEach calls vi.unstubAllGlobals().
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.
const mockFetch = vi.fn().mockImplementation((url: string) => {
if (url.includes('/api/auth/session')) return Promise.resolve(sessionFixture(false));
if (url.endsWith('/forum')) return Promise.resolve(forumFixture());
// ...
return Promise.resolve({ ok: false, status: 404, json: async () => ({}) });
});
CI#
No changes required. Tests are discovered by vitest run and block merge on failure via the existing test job in .github/workflows/ci.yml.