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
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

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 jsdom pragma switches this file from the default node environment to jsdom, providing document, window, and DOMParser as 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), mock fetch globally, and run axe-core against the parsed HTML response.

Dependencies#

Add to apps/web/package.json devDependencies:

  • axe-core — accessibility engine
  • jsdom — DOM implementation Vitest's jsdom environment wraps
  • vitest — currently works via pnpm hoisting but should be declared explicitly

HTML Parsing & Axe Configuration#

Each test follows this flow:

  1. Mock fetch globally with vi.stubGlobal('fetch', mockFetch)
  2. Call the route handler: const res = await routes.request('/path')
  3. Get HTML: const html = await res.text()
  4. Parse into DOM: const doc = new DOMParser().parseFromString(html, 'text/html')
  5. Run axe: const results = await axe.run(doc, { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa'] } })
  6. 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.