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

Configure Feed

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

test(web): address PR review feedback on a11y tests (ATB-34)

- Fix DOMParser comment to explain axe isPageContext() mechanism accurately
- Add DOM replacement guard after document.write() to catch silent no-ops
- Wrap axe.run() in try/catch with routeLabel for infrastructure error context
- Add routeLabel param to checkA11y; update all 6 call sites
- Reset canLockTopics/canModeratePosts/canBanUsers in beforeEach
- Add afterEach DOM cleanup via documentElement.innerHTML
- Fix path.startsWith('/topics/1') to exact match '/topics/1?offset=0&limit=25'
- Add form presence guard in new-topic test to catch silent auth fallback
- Update design doc to document DOMParser divergence and its reason

Malpercio 89629984 38703d0c

+66 -22
+65 -21
apps/web/src/__tests__/a11y.test.ts
··· 1 1 // @vitest-environment jsdom 2 - import { describe, it, expect, vi, beforeEach } from "vitest"; 2 + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 3 3 import axe from "axe-core"; 4 4 5 5 // ── Module mocks ────────────────────────────────────────────────────────────── 6 - // Must be declared before any imports that use these modules. 7 - // vi.mock is hoisted to the top of the file by Vitest's transform. 6 + // vi.mock calls are hoisted by Vitest's transform so mocks are in effect before 7 + // any module imports execute. 8 8 9 9 vi.mock("../lib/api.js", () => ({ 10 10 fetchApi: vi.fn(), ··· 30 30 31 31 // ── Import mocked modules so we can configure return values per test ────────── 32 32 import { fetchApi } from "../lib/api.js"; 33 - import { getSession, getSessionWithPermissions } from "../lib/session.js"; 33 + import { 34 + getSession, 35 + getSessionWithPermissions, 36 + canLockTopics, 37 + canModeratePosts, 38 + canBanUsers, 39 + } from "../lib/session.js"; 34 40 35 41 // ── Route factories ─────────────────────────────────────────────────────────── 36 42 import { createHomeRoutes } from "../routes/home.js"; ··· 47 53 const mockFetchApi = vi.mocked(fetchApi); 48 54 const mockGetSession = vi.mocked(getSession); 49 55 const mockGetSessionWithPermissions = vi.mocked(getSessionWithPermissions); 56 + const mockCanLockTopics = vi.mocked(canLockTopics); 57 + const mockCanModeratePosts = vi.mocked(canModeratePosts); 58 + const mockCanBanUsers = vi.mocked(canBanUsers); 50 59 51 60 // ── Shared reset ────────────────────────────────────────────────────────────── 52 61 beforeEach(() => { ··· 58 67 authenticated: false, 59 68 permissions: new Set<string>(), 60 69 }); 70 + mockCanLockTopics.mockReturnValue(false); 71 + mockCanModeratePosts.mockReturnValue(false); 72 + mockCanBanUsers.mockReturnValue(false); 73 + }); 74 + 75 + // ── DOM cleanup ─────────────────────────────────────────────────────────────── 76 + // Reset jsdom between tests so stale DOM from one test never leaks into the next. 77 + afterEach(() => { 78 + document.documentElement.innerHTML = "<head></head><body></body>"; 61 79 }); 62 80 63 81 // ── A11y helper ─────────────────────────────────────────────────────────────── ··· 65 83 // skipped automatically. These tests cover structural/semantic WCAG AA rules 66 84 // only (landmark regions, heading hierarchy, form labels, aria attributes). 67 85 // 68 - // We load HTML into the global jsdom document via document.open/write/close 69 - // rather than DOMParser because axe-core ignores DOMParser Documents and 70 - // always inspects window.document. document.open/write/close fully replaces 71 - // the global document (preserving <html lang> and <title>) so axe-core sees 72 - // the correct markup. 73 - async function checkA11y(html: string): Promise<void> { 86 + // We call axe.run() with no context argument, so axe defaults to window.document. 87 + // If we passed a DOMParser document instead (axe.run(doc, options)), axe's 88 + // internal isPageContext() would compare include[0].actualNode === 89 + // document.documentElement — a DOMParser document's root fails this check, 90 + // disabling page-level rules like html-has-lang and document-title and 91 + // producing false greens on the rules we most care about. 92 + // document.open/write/close replaces window.document in place so the default 93 + // context works correctly. 94 + async function checkA11y(html: string, routeLabel: string): Promise<void> { 74 95 document.open(); 75 96 // document.write is deprecated in browsers but is the only reliable way to 76 97 // fully replace jsdom's global document (including <html lang="en">) for ··· 79 100 // @ts-ignore — intentional use of deprecated API; see comment above 80 101 document.write(html); 81 102 document.close(); 82 - const results = await axe.run({ 83 - runOnly: { type: "tag", values: ["wcag2a", "wcag2aa"] }, 84 - }); 103 + 104 + if (!document.querySelector("html")) { 105 + throw new Error( 106 + `document.write() produced no <html> element for route "${routeLabel}" — ` + 107 + "DOM replacement failed. Previous test's DOM may still be active." 108 + ); 109 + } 110 + 111 + let results: axe.AxeResults; 112 + try { 113 + results = await axe.run({ 114 + runOnly: { type: "tag", values: ["wcag2a", "wcag2aa"] }, 115 + }); 116 + } catch (axeError) { 117 + throw new Error( 118 + `axe.run() failed for route "${routeLabel}" — infrastructure error, not a WCAG violation. ` + 119 + `axe threw: ${axeError instanceof Error ? axeError.message : String(axeError)}` 120 + ); 121 + } 122 + 85 123 const summary = results.violations 86 124 .map( 87 125 (v) => ··· 91 129 .join("\n"); 92 130 expect( 93 131 results.violations, 94 - `WCAG AA violations found:\n${summary}` 132 + `WCAG AA violations found on "${routeLabel}":\n${summary}` 95 133 ).toHaveLength(0); 96 134 } 97 135 ··· 141 179 const routes = createHomeRoutes(APPVIEW_URL); 142 180 const res = await routes.request("/"); 143 181 expect(res.status).toBe(200); 144 - await checkA11y(await res.text()); 182 + await checkA11y(await res.text(), "GET /"); 145 183 }); 146 184 147 185 it("login page /login has no violations", async () => { ··· 150 188 const routes = createLoginRoutes(APPVIEW_URL); 151 189 const res = await routes.request("/login"); 152 190 expect(res.status).toBe(200); 153 - await checkA11y(await res.text()); 191 + await checkA11y(await res.text(), "GET /login"); 154 192 }); 155 193 156 194 it("board page /boards/:id has no violations", async () => { ··· 192 230 const routes = createBoardsRoutes(APPVIEW_URL); 193 231 const res = await routes.request("/boards/1"); 194 232 expect(res.status).toBe(200); 195 - await checkA11y(await res.text()); 233 + await checkA11y(await res.text(), "GET /boards/:id"); 196 234 }); 197 235 198 236 it("topic page /topics/:id has no violations", async () => { 199 237 mockFetchApi.mockImplementation((path: string) => { 200 - if (path.startsWith("/topics/1")) { 238 + if (path === "/topics/1?offset=0&limit=25") { 201 239 return Promise.resolve({ 202 240 topicId: "1", 203 241 locked: false, ··· 255 293 const routes = createTopicsRoutes(APPVIEW_URL); 256 294 const res = await routes.request("/topics/1"); 257 295 expect(res.status).toBe(200); 258 - await checkA11y(await res.text()); 296 + await checkA11y(await res.text(), "GET /topics/:id"); 259 297 }); 260 298 261 299 it("new-topic page /new-topic (authenticated) has no violations", async () => { ··· 288 326 const routes = createNewTopicRoutes(APPVIEW_URL); 289 327 const res = await routes.request("/new-topic?boardId=1"); 290 328 expect(res.status).toBe(200); 291 - await checkA11y(await res.text()); 329 + const html = await res.text(); 330 + // Guard: ensure the authenticated form rendered (not the "Log in" fallback). 331 + // If getSession() fell back to unauthenticated, the form would be absent. 332 + expect(html, "Expected authenticated new-topic form but got login fallback").toContain( 333 + 'name="title"' 334 + ); 335 + await checkA11y(html, "GET /new-topic (authenticated)"); 292 336 }); 293 337 294 338 it("not-found page has no violations", async () => { ··· 296 340 const routes = createNotFoundRoute(APPVIEW_URL); 297 341 const res = await routes.request("/anything-that-does-not-exist"); 298 342 expect(res.status).toBe(404); 299 - await checkA11y(await res.text()); 343 + await checkA11y(await res.text(), "GET /404"); 300 344 }); 301 345 });
+1 -1
docs/plans/complete/2026-02-27-axe-core-a11y-design.md
··· 36 36 5. Run axe: `const results = await axe.run(doc, { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa'] } })` 37 37 6. Assert with a useful failure message showing which rules violated 38 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. 39 + **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. 40 40 41 41 ### Known Limitation 42 42