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
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});