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

test(web): add WCAG AA accessibility tests for all page routes (ATB-34)

+212 -17
+212 -17
apps/web/src/__tests__/a11y.test.ts
··· 33 33 import { getSession, getSessionWithPermissions } from "../lib/session.js"; 34 34 35 35 // ── Route factories ─────────────────────────────────────────────────────────── 36 - // eslint-disable-next-line no-unused-vars 37 36 import { createHomeRoutes } from "../routes/home.js"; 38 - // eslint-disable-next-line no-unused-vars 39 37 import { createLoginRoutes } from "../routes/login.js"; 40 - // eslint-disable-next-line no-unused-vars 41 38 import { createBoardsRoutes } from "../routes/boards.js"; 42 - // eslint-disable-next-line no-unused-vars 43 39 import { createTopicsRoutes } from "../routes/topics.js"; 44 - // eslint-disable-next-line no-unused-vars 45 40 import { createNewTopicRoutes } from "../routes/new-topic.js"; 46 - // eslint-disable-next-line no-unused-vars 47 41 import { createNotFoundRoute } from "../routes/not-found.js"; 48 42 49 43 // ── Constants ───────────────────────────────────────────────────────────────── 50 - // eslint-disable-next-line no-unused-vars 51 44 const APPVIEW_URL = "http://localhost:3000"; 52 45 53 46 // ── Typed mock handles ──────────────────────────────────────────────────────── ··· 71 64 // NOTE: jsdom has no CSS engine, so axe-core's color-contrast rules are 72 65 // skipped automatically. These tests cover structural/semantic WCAG AA rules 73 66 // only (landmark regions, heading hierarchy, form labels, aria attributes). 74 - // eslint-disable-next-line no-unused-vars 67 + // 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. 75 73 async function checkA11y(html: string): Promise<void> { 76 - const doc = new DOMParser().parseFromString(html, "text/html"); 77 - const results = await axe.run(doc, { 74 + document.open(); 75 + document.write(html); 76 + document.close(); 77 + const results = await axe.run({ 78 78 runOnly: { type: "tag", values: ["wcag2a", "wcag2aa"] }, 79 79 }); 80 80 const summary = results.violations ··· 91 91 } 92 92 93 93 describe("WCAG AA accessibility — one happy-path test per page route", () => { 94 - // Tests added in subsequent tasks 95 - it.todo("home page / has no violations"); 96 - it.todo("login page /login has no violations"); 97 - it.todo("board page /boards/:id has no violations"); 98 - it.todo("topic page /topics/:id has no violations"); 99 - it.todo("new-topic page /new-topic (authenticated) has no violations"); 100 - it.todo("not-found page has no violations"); 94 + it("home page / has no violations", async () => { 95 + mockFetchApi.mockImplementation((path: string) => { 96 + if (path === "/forum") { 97 + return Promise.resolve({ 98 + id: "1", 99 + did: "did:plc:forum", 100 + name: "Test Forum", 101 + description: "A test forum", 102 + indexedAt: "2024-01-01T00:00:00.000Z", 103 + }); 104 + } 105 + if (path === "/categories") { 106 + return Promise.resolve({ 107 + categories: [ 108 + { 109 + id: "1", 110 + did: "did:plc:forum", 111 + name: "General", 112 + description: null, 113 + slug: "general", 114 + sortOrder: 0, 115 + }, 116 + ], 117 + }); 118 + } 119 + if (path === "/categories/1/boards") { 120 + return Promise.resolve({ 121 + boards: [ 122 + { 123 + id: "1", 124 + did: "did:plc:forum", 125 + name: "Test Board", 126 + description: null, 127 + slug: "test", 128 + sortOrder: 0, 129 + }, 130 + ], 131 + }); 132 + } 133 + return Promise.reject(new Error(`Unexpected fetchApi call: ${path}`)); 134 + }); 135 + 136 + const routes = createHomeRoutes(APPVIEW_URL); 137 + const res = await routes.request("/"); 138 + expect(res.status).toBe(200); 139 + await checkA11y(await res.text()); 140 + }); 141 + 142 + it("login page /login has no violations", async () => { 143 + // getSession returns { authenticated: false } immediately with no cookie header. 144 + // No fetchApi calls are made. 145 + const routes = createLoginRoutes(APPVIEW_URL); 146 + const res = await routes.request("/login"); 147 + expect(res.status).toBe(200); 148 + await checkA11y(await res.text()); 149 + }); 150 + 151 + it("board page /boards/:id has no violations", async () => { 152 + mockFetchApi.mockImplementation((path: string) => { 153 + if (path === "/boards/1") { 154 + return Promise.resolve({ 155 + id: "1", 156 + did: "did:plc:forum", 157 + uri: "at://did:plc:forum/space.atbb.forum.board/1", 158 + name: "Test Board", 159 + description: null, 160 + slug: "test", 161 + sortOrder: 0, 162 + categoryId: "1", 163 + categoryUri: null, 164 + createdAt: "2024-01-01T00:00:00.000Z", 165 + indexedAt: "2024-01-01T00:00:00.000Z", 166 + }); 167 + } 168 + if (path === "/boards/1/topics?offset=0&limit=25") { 169 + return Promise.resolve({ topics: [], total: 0, offset: 0, limit: 25 }); 170 + } 171 + if (path === "/categories/1") { 172 + return Promise.resolve({ 173 + id: "1", 174 + did: "did:plc:forum", 175 + name: "General", 176 + description: null, 177 + slug: null, 178 + sortOrder: null, 179 + forumId: null, 180 + createdAt: null, 181 + indexedAt: null, 182 + }); 183 + } 184 + return Promise.reject(new Error(`Unexpected fetchApi call: ${path}`)); 185 + }); 186 + 187 + const routes = createBoardsRoutes(APPVIEW_URL); 188 + const res = await routes.request("/boards/1"); 189 + expect(res.status).toBe(200); 190 + await checkA11y(await res.text()); 191 + }); 192 + 193 + it("topic page /topics/:id has no violations", async () => { 194 + mockFetchApi.mockImplementation((path: string) => { 195 + if (path.startsWith("/topics/1")) { 196 + return Promise.resolve({ 197 + topicId: "1", 198 + locked: false, 199 + pinned: false, 200 + post: { 201 + id: "1", 202 + did: "did:plc:user", 203 + rkey: "abc123", 204 + title: "Test Topic Title", 205 + text: "Hello world, this is a test post.", 206 + forumUri: null, 207 + boardUri: null, 208 + boardId: "1", 209 + parentPostId: null, 210 + createdAt: "2024-01-01T00:00:00.000Z", 211 + author: { did: "did:plc:user", handle: "alice.test" }, 212 + }, 213 + replies: [], 214 + total: 0, 215 + offset: 0, 216 + limit: 25, 217 + }); 218 + } 219 + if (path === "/boards/1") { 220 + return Promise.resolve({ 221 + id: "1", 222 + did: "did:plc:forum", 223 + uri: "at://did:plc:forum/space.atbb.forum.board/1", 224 + name: "Test Board", 225 + description: null, 226 + slug: null, 227 + sortOrder: null, 228 + categoryId: "1", 229 + categoryUri: null, 230 + createdAt: null, 231 + indexedAt: null, 232 + }); 233 + } 234 + if (path === "/categories/1") { 235 + return Promise.resolve({ 236 + id: "1", 237 + did: "did:plc:forum", 238 + name: "General", 239 + description: null, 240 + slug: null, 241 + sortOrder: null, 242 + forumId: null, 243 + createdAt: null, 244 + indexedAt: null, 245 + }); 246 + } 247 + return Promise.reject(new Error(`Unexpected fetchApi call: ${path}`)); 248 + }); 249 + 250 + const routes = createTopicsRoutes(APPVIEW_URL); 251 + const res = await routes.request("/topics/1"); 252 + expect(res.status).toBe(200); 253 + await checkA11y(await res.text()); 254 + }); 255 + 256 + it("new-topic page /new-topic (authenticated) has no violations", async () => { 257 + // Override default unauthenticated session for this test only. 258 + mockGetSession.mockResolvedValueOnce({ 259 + authenticated: true, 260 + did: "did:plc:user", 261 + handle: "alice.test", 262 + }); 263 + 264 + mockFetchApi.mockImplementation((path: string) => { 265 + if (path === "/boards/1") { 266 + return Promise.resolve({ 267 + id: "1", 268 + did: "did:plc:forum", 269 + uri: "at://did:plc:forum/space.atbb.forum.board/1", 270 + name: "Test Board", 271 + description: null, 272 + slug: null, 273 + sortOrder: null, 274 + categoryId: "1", 275 + categoryUri: null, 276 + createdAt: null, 277 + indexedAt: null, 278 + }); 279 + } 280 + return Promise.reject(new Error(`Unexpected fetchApi call: ${path}`)); 281 + }); 282 + 283 + const routes = createNewTopicRoutes(APPVIEW_URL); 284 + const res = await routes.request("/new-topic?boardId=1"); 285 + expect(res.status).toBe(200); 286 + await checkA11y(await res.text()); 287 + }); 288 + 289 + it("not-found page has no violations", async () => { 290 + // No fetchApi or session calls — unauthenticated with no cookie. 291 + const routes = createNotFoundRoute(APPVIEW_URL); 292 + const res = await routes.request("/anything-that-does-not-exist"); 293 + expect(res.status).toBe(404); 294 + await checkA11y(await res.text()); 295 + }); 101 296 });