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
at atb-52-css-token-extraction 350 lines 13 kB view raw
1// @vitest-environment jsdom 2import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 3import axe from "axe-core"; 4 5// ── Module mocks ────────────────────────────────────────────────────────────── 6// vi.mock calls are hoisted by Vitest's transform so mocks are in effect before 7// any module imports execute. 8 9vi.mock("../lib/api.js", () => ({ 10 fetchApi: vi.fn(), 11})); 12 13vi.mock("../lib/session.js", () => ({ 14 getSession: vi.fn(), 15 getSessionWithPermissions: vi.fn(), 16 canLockTopics: vi.fn().mockReturnValue(false), 17 canModeratePosts: vi.fn().mockReturnValue(false), 18 canBanUsers: vi.fn().mockReturnValue(false), 19})); 20 21vi.mock("../lib/logger.js", () => ({ 22 logger: { 23 debug: vi.fn(), 24 info: vi.fn(), 25 warn: vi.fn(), 26 error: vi.fn(), 27 fatal: vi.fn(), 28 }, 29})); 30 31// ── Import mocked modules so we can configure return values per test ────────── 32import { fetchApi } from "../lib/api.js"; 33import { 34 getSession, 35 getSessionWithPermissions, 36 canLockTopics, 37 canModeratePosts, 38 canBanUsers, 39} from "../lib/session.js"; 40 41// ── Route factories ─────────────────────────────────────────────────────────── 42import { createHomeRoutes } from "../routes/home.js"; 43import { createLoginRoutes } from "../routes/login.js"; 44import { createBoardsRoutes } from "../routes/boards.js"; 45import { createTopicsRoutes } from "../routes/topics.js"; 46import { createNewTopicRoutes } from "../routes/new-topic.js"; 47import { createNotFoundRoute } from "../routes/not-found.js"; 48 49// ── Constants ───────────────────────────────────────────────────────────────── 50const APPVIEW_URL = "http://localhost:3000"; 51 52// ── Typed mock handles ──────────────────────────────────────────────────────── 53const mockFetchApi = vi.mocked(fetchApi); 54const mockGetSession = vi.mocked(getSession); 55const mockGetSessionWithPermissions = vi.mocked(getSessionWithPermissions); 56const mockCanLockTopics = vi.mocked(canLockTopics); 57const mockCanModeratePosts = vi.mocked(canModeratePosts); 58const mockCanBanUsers = vi.mocked(canBanUsers); 59 60// ── Shared reset ────────────────────────────────────────────────────────────── 61beforeEach(() => { 62 mockFetchApi.mockReset(); 63 // Default: unauthenticated session for all routes. 64 // Override in individual tests that need authenticated state. 65 mockGetSession.mockResolvedValue({ authenticated: false }); 66 mockGetSessionWithPermissions.mockResolvedValue({ 67 authenticated: false, 68 permissions: new Set<string>(), 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// Also remove lang from <html> so the guard in checkA11y (html[lang]) can 78// confirm that document.write() actually executed in the next test. 79afterEach(() => { 80 document.documentElement.innerHTML = "<head></head><body></body>"; 81 document.documentElement.removeAttribute("lang"); 82}); 83 84// ── A11y helper ─────────────────────────────────────────────────────────────── 85// NOTE: jsdom has no CSS engine, so axe-core's color-contrast rules are 86// skipped automatically. These tests cover structural/semantic WCAG AA rules 87// only (landmark regions, heading hierarchy, form labels, aria attributes). 88// 89// We call axe.run() with no context argument, so axe defaults to window.document. 90// If we passed a DOMParser document instead (axe.run(doc, options)), axe's 91// internal isPageContext() would compare include[0].actualNode === 92// document.documentElement — a DOMParser document's root fails this check, 93// disabling page-level rules like html-has-lang and document-title and 94// producing false greens on the rules we most care about. 95// document.open/write/close replaces window.document in place so the default 96// context works correctly. 97async function checkA11y(html: string, routeLabel: string): Promise<void> { 98 document.open(); 99 // document.write is deprecated in browsers but is the only reliable way to 100 // fully replace jsdom's global document (including <html lang="en">) for 101 // axe-core. Alternatives like innerHTML assignment silently drop <html lang>, 102 // which causes axe to report a spurious html-has-lang violation. 103 // @ts-ignore — intentional use of deprecated API; see comment above 104 document.write(html); 105 document.close(); 106 107 // afterEach removes <html lang> so this proves document.write() actually ran, 108 // not just that the <html> element still exists from the previous test's cleanup. 109 if (!document.querySelector("html[lang]")) { 110 throw new Error( 111 `document.write() did not produce expected <html lang> for route "${routeLabel}" — ` + 112 "DOM replacement likely failed. Previous test's DOM may still be active." 113 ); 114 } 115 116 let results: axe.AxeResults; 117 try { 118 results = await axe.run({ 119 runOnly: { type: "tag", values: ["wcag2a", "wcag2aa"] }, 120 }); 121 } catch (axeError) { 122 throw new Error( 123 `axe.run() failed for route "${routeLabel}" — infrastructure error, not a WCAG violation. ` + 124 `axe threw: ${axeError instanceof Error ? axeError.message : String(axeError)}` 125 ); 126 } 127 128 const summary = results.violations 129 .map( 130 (v) => 131 ` [${v.id}] ${v.description}\n` + 132 v.nodes.map((n) => `${n.html}`).join("\n") 133 ) 134 .join("\n"); 135 expect( 136 results.violations, 137 `WCAG AA violations found on "${routeLabel}":\n${summary}` 138 ).toHaveLength(0); 139} 140 141describe("WCAG AA accessibility — one happy-path test per page route", () => { 142 it("home page / has no violations", async () => { 143 mockFetchApi.mockImplementation((path: string) => { 144 if (path === "/forum") { 145 return Promise.resolve({ 146 id: "1", 147 did: "did:plc:forum", 148 name: "Test Forum", 149 description: "A test forum", 150 indexedAt: "2024-01-01T00:00:00.000Z", 151 }); 152 } 153 if (path === "/categories") { 154 return Promise.resolve({ 155 categories: [ 156 { 157 id: "1", 158 did: "did:plc:forum", 159 name: "General", 160 description: null, 161 slug: "general", 162 sortOrder: 0, 163 }, 164 ], 165 }); 166 } 167 if (path === "/categories/1/boards") { 168 return Promise.resolve({ 169 boards: [ 170 { 171 id: "1", 172 did: "did:plc:forum", 173 name: "Test Board", 174 description: null, 175 slug: "test", 176 sortOrder: 0, 177 }, 178 ], 179 }); 180 } 181 return Promise.reject(new Error(`Unexpected fetchApi call: ${path}`)); 182 }); 183 184 const routes = createHomeRoutes(APPVIEW_URL); 185 const res = await routes.request("/"); 186 expect(res.status).toBe(200); 187 await checkA11y(await res.text(), "GET /"); 188 }); 189 190 it("login page /login has no violations", async () => { 191 // getSession returns { authenticated: false } immediately with no cookie header. 192 // No fetchApi calls are made. 193 const routes = createLoginRoutes(APPVIEW_URL); 194 const res = await routes.request("/login"); 195 expect(res.status).toBe(200); 196 await checkA11y(await res.text(), "GET /login"); 197 }); 198 199 it("board page /boards/:id has no violations", async () => { 200 mockFetchApi.mockImplementation((path: string) => { 201 if (path === "/boards/1") { 202 return Promise.resolve({ 203 id: "1", 204 did: "did:plc:forum", 205 uri: "at://did:plc:forum/space.atbb.forum.board/1", 206 name: "Test Board", 207 description: null, 208 slug: "test", 209 sortOrder: 0, 210 categoryId: "1", 211 categoryUri: null, 212 createdAt: "2024-01-01T00:00:00.000Z", 213 indexedAt: "2024-01-01T00:00:00.000Z", 214 }); 215 } 216 if (path === "/boards/1/topics?offset=0&limit=25") { 217 return Promise.resolve({ topics: [], total: 0, offset: 0, limit: 25 }); 218 } 219 if (path === "/categories/1") { 220 return Promise.resolve({ 221 id: "1", 222 did: "did:plc:forum", 223 name: "General", 224 description: null, 225 slug: null, 226 sortOrder: null, 227 forumId: null, 228 createdAt: null, 229 indexedAt: null, 230 }); 231 } 232 return Promise.reject(new Error(`Unexpected fetchApi call: ${path}`)); 233 }); 234 235 const routes = createBoardsRoutes(APPVIEW_URL); 236 const res = await routes.request("/boards/1"); 237 expect(res.status).toBe(200); 238 await checkA11y(await res.text(), "GET /boards/:id"); 239 }); 240 241 it("topic page /topics/:id has no violations", async () => { 242 mockFetchApi.mockImplementation((path: string) => { 243 if (path === "/topics/1?offset=0&limit=25") { 244 return Promise.resolve({ 245 topicId: "1", 246 locked: false, 247 pinned: false, 248 post: { 249 id: "1", 250 did: "did:plc:user", 251 rkey: "abc123", 252 title: "Test Topic Title", 253 text: "Hello world, this is a test post.", 254 forumUri: null, 255 boardUri: null, 256 boardId: "1", 257 parentPostId: null, 258 createdAt: "2024-01-01T00:00:00.000Z", 259 author: { did: "did:plc:user", handle: "alice.test" }, 260 }, 261 replies: [], 262 total: 0, 263 offset: 0, 264 limit: 25, 265 }); 266 } 267 if (path === "/boards/1") { 268 return Promise.resolve({ 269 id: "1", 270 did: "did:plc:forum", 271 uri: "at://did:plc:forum/space.atbb.forum.board/1", 272 name: "Test Board", 273 description: null, 274 slug: null, 275 sortOrder: null, 276 categoryId: "1", 277 categoryUri: null, 278 createdAt: null, 279 indexedAt: null, 280 }); 281 } 282 if (path === "/categories/1") { 283 return Promise.resolve({ 284 id: "1", 285 did: "did:plc:forum", 286 name: "General", 287 description: null, 288 slug: null, 289 sortOrder: null, 290 forumId: null, 291 createdAt: null, 292 indexedAt: null, 293 }); 294 } 295 return Promise.reject(new Error(`Unexpected fetchApi call: ${path}`)); 296 }); 297 298 const routes = createTopicsRoutes(APPVIEW_URL); 299 const res = await routes.request("/topics/1"); 300 expect(res.status).toBe(200); 301 await checkA11y(await res.text(), "GET /topics/:id"); 302 }); 303 304 it("new-topic page /new-topic (authenticated) has no violations", async () => { 305 // Override default unauthenticated session for this test only. 306 mockGetSession.mockResolvedValueOnce({ 307 authenticated: true, 308 did: "did:plc:user", 309 handle: "alice.test", 310 }); 311 312 mockFetchApi.mockImplementation((path: string) => { 313 if (path === "/boards/1") { 314 return Promise.resolve({ 315 id: "1", 316 did: "did:plc:forum", 317 uri: "at://did:plc:forum/space.atbb.forum.board/1", 318 name: "Test Board", 319 description: null, 320 slug: null, 321 sortOrder: null, 322 categoryId: "1", 323 categoryUri: null, 324 createdAt: null, 325 indexedAt: null, 326 }); 327 } 328 return Promise.reject(new Error(`Unexpected fetchApi call: ${path}`)); 329 }); 330 331 const routes = createNewTopicRoutes(APPVIEW_URL); 332 const res = await routes.request("/new-topic?boardId=1"); 333 expect(res.status).toBe(200); 334 const html = await res.text(); 335 // Guard: ensure the authenticated form rendered (not the "Log in" fallback). 336 // If getSession() fell back to unauthenticated, the form would be absent. 337 expect(html, "Expected authenticated new-topic form but got login fallback").toContain( 338 'name="title"' 339 ); 340 await checkA11y(html, "GET /new-topic (authenticated)"); 341 }); 342 343 it("not-found page has no violations", async () => { 344 // No fetchApi or session calls — unauthenticated with no cookie. 345 const routes = createNotFoundRoute(APPVIEW_URL); 346 const res = await routes.request("/anything-that-does-not-exist"); 347 expect(res.status).toBe(404); 348 await checkA11y(await res.text(), "GET /404"); 349 }); 350});