// @vitest-environment jsdom import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import axe from "axe-core"; // ── Module mocks ────────────────────────────────────────────────────────────── // vi.mock calls are hoisted by Vitest's transform so mocks are in effect before // any module imports execute. vi.mock("../lib/api.js", () => ({ fetchApi: vi.fn(), })); vi.mock("../lib/session.js", () => ({ getSession: vi.fn(), getSessionWithPermissions: vi.fn(), canLockTopics: vi.fn().mockReturnValue(false), canModeratePosts: vi.fn().mockReturnValue(false), canBanUsers: vi.fn().mockReturnValue(false), })); vi.mock("../lib/logger.js", () => ({ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn(), }, })); // ── Import mocked modules so we can configure return values per test ────────── import { fetchApi } from "../lib/api.js"; import { getSession, getSessionWithPermissions, canLockTopics, canModeratePosts, canBanUsers, } from "../lib/session.js"; // ── Route factories ─────────────────────────────────────────────────────────── import { createHomeRoutes } from "../routes/home.js"; import { createLoginRoutes } from "../routes/login.js"; import { createBoardsRoutes } from "../routes/boards.js"; import { createTopicsRoutes } from "../routes/topics.js"; import { createNewTopicRoutes } from "../routes/new-topic.js"; import { createNotFoundRoute } from "../routes/not-found.js"; // ── Constants ───────────────────────────────────────────────────────────────── const APPVIEW_URL = "http://localhost:3000"; // ── Typed mock handles ──────────────────────────────────────────────────────── const mockFetchApi = vi.mocked(fetchApi); const mockGetSession = vi.mocked(getSession); const mockGetSessionWithPermissions = vi.mocked(getSessionWithPermissions); const mockCanLockTopics = vi.mocked(canLockTopics); const mockCanModeratePosts = vi.mocked(canModeratePosts); const mockCanBanUsers = vi.mocked(canBanUsers); // ── Shared reset ────────────────────────────────────────────────────────────── beforeEach(() => { mockFetchApi.mockReset(); // Default: unauthenticated session for all routes. // Override in individual tests that need authenticated state. mockGetSession.mockResolvedValue({ authenticated: false }); mockGetSessionWithPermissions.mockResolvedValue({ authenticated: false, permissions: new Set(), }); mockCanLockTopics.mockReturnValue(false); mockCanModeratePosts.mockReturnValue(false); mockCanBanUsers.mockReturnValue(false); }); // ── DOM cleanup ─────────────────────────────────────────────────────────────── // Reset jsdom between tests so stale DOM from one test never leaks into the next. // Also remove lang from so the guard in checkA11y (html[lang]) can // confirm that document.write() actually executed in the next test. afterEach(() => { document.documentElement.innerHTML = ""; document.documentElement.removeAttribute("lang"); }); // ── A11y helper ─────────────────────────────────────────────────────────────── // NOTE: jsdom has no CSS engine, so axe-core's color-contrast rules are // skipped automatically. These tests cover structural/semantic WCAG AA rules // only (landmark regions, heading hierarchy, form labels, aria attributes). // // We call axe.run() with no context argument, so axe defaults to window.document. // If we passed a DOMParser document instead (axe.run(doc, options)), axe's // internal isPageContext() would compare include[0].actualNode === // document.documentElement — a DOMParser document's root fails this check, // disabling page-level rules like html-has-lang and document-title and // producing false greens on the rules we most care about. // document.open/write/close replaces window.document in place so the default // context works correctly. async function checkA11y(html: string, routeLabel: string): Promise { document.open(); // document.write is deprecated in browsers but is the only reliable way to // fully replace jsdom's global document (including ) for // axe-core. Alternatives like innerHTML assignment silently drop , // which causes axe to report a spurious html-has-lang violation. // @ts-ignore — intentional use of deprecated API; see comment above document.write(html); document.close(); // afterEach removes so this proves document.write() actually ran, // not just that the element still exists from the previous test's cleanup. if (!document.querySelector("html[lang]")) { throw new Error( `document.write() did not produce expected for route "${routeLabel}" — ` + "DOM replacement likely failed. Previous test's DOM may still be active." ); } let results: axe.AxeResults; try { results = await axe.run({ runOnly: { type: "tag", values: ["wcag2a", "wcag2aa"] }, }); } catch (axeError) { throw new Error( `axe.run() failed for route "${routeLabel}" — infrastructure error, not a WCAG violation. ` + `axe threw: ${axeError instanceof Error ? axeError.message : String(axeError)}` ); } const summary = results.violations .map( (v) => ` [${v.id}] ${v.description}\n` + v.nodes.map((n) => ` → ${n.html}`).join("\n") ) .join("\n"); expect( results.violations, `WCAG AA violations found on "${routeLabel}":\n${summary}` ).toHaveLength(0); } describe("WCAG AA accessibility — one happy-path test per page route", () => { it("home page / has no violations", async () => { mockFetchApi.mockImplementation((path: string) => { if (path === "/forum") { return Promise.resolve({ id: "1", did: "did:plc:forum", name: "Test Forum", description: "A test forum", indexedAt: "2024-01-01T00:00:00.000Z", }); } if (path === "/categories") { return Promise.resolve({ categories: [ { id: "1", did: "did:plc:forum", name: "General", description: null, slug: "general", sortOrder: 0, }, ], }); } if (path === "/categories/1/boards") { return Promise.resolve({ boards: [ { id: "1", did: "did:plc:forum", name: "Test Board", description: null, slug: "test", sortOrder: 0, }, ], }); } return Promise.reject(new Error(`Unexpected fetchApi call: ${path}`)); }); const routes = createHomeRoutes(APPVIEW_URL); const res = await routes.request("/"); expect(res.status).toBe(200); await checkA11y(await res.text(), "GET /"); }); it("login page /login has no violations", async () => { // getSession returns { authenticated: false } immediately with no cookie header. // No fetchApi calls are made. const routes = createLoginRoutes(APPVIEW_URL); const res = await routes.request("/login"); expect(res.status).toBe(200); await checkA11y(await res.text(), "GET /login"); }); it("board page /boards/:id has no violations", async () => { mockFetchApi.mockImplementation((path: string) => { if (path === "/boards/1") { return Promise.resolve({ id: "1", did: "did:plc:forum", uri: "at://did:plc:forum/space.atbb.forum.board/1", name: "Test Board", description: null, slug: "test", sortOrder: 0, categoryId: "1", categoryUri: null, createdAt: "2024-01-01T00:00:00.000Z", indexedAt: "2024-01-01T00:00:00.000Z", }); } if (path === "/boards/1/topics?offset=0&limit=25") { return Promise.resolve({ topics: [], total: 0, offset: 0, limit: 25 }); } if (path === "/categories/1") { return Promise.resolve({ id: "1", did: "did:plc:forum", name: "General", description: null, slug: null, sortOrder: null, forumId: null, createdAt: null, indexedAt: null, }); } return Promise.reject(new Error(`Unexpected fetchApi call: ${path}`)); }); const routes = createBoardsRoutes(APPVIEW_URL); const res = await routes.request("/boards/1"); expect(res.status).toBe(200); await checkA11y(await res.text(), "GET /boards/:id"); }); it("topic page /topics/:id has no violations", async () => { mockFetchApi.mockImplementation((path: string) => { if (path === "/topics/1?offset=0&limit=25") { return Promise.resolve({ topicId: "1", locked: false, pinned: false, post: { id: "1", did: "did:plc:user", rkey: "abc123", title: "Test Topic Title", text: "Hello world, this is a test post.", forumUri: null, boardUri: null, boardId: "1", parentPostId: null, createdAt: "2024-01-01T00:00:00.000Z", author: { did: "did:plc:user", handle: "alice.test" }, }, replies: [], total: 0, offset: 0, limit: 25, }); } if (path === "/boards/1") { return Promise.resolve({ id: "1", did: "did:plc:forum", uri: "at://did:plc:forum/space.atbb.forum.board/1", name: "Test Board", description: null, slug: null, sortOrder: null, categoryId: "1", categoryUri: null, createdAt: null, indexedAt: null, }); } if (path === "/categories/1") { return Promise.resolve({ id: "1", did: "did:plc:forum", name: "General", description: null, slug: null, sortOrder: null, forumId: null, createdAt: null, indexedAt: null, }); } return Promise.reject(new Error(`Unexpected fetchApi call: ${path}`)); }); const routes = createTopicsRoutes(APPVIEW_URL); const res = await routes.request("/topics/1"); expect(res.status).toBe(200); await checkA11y(await res.text(), "GET /topics/:id"); }); it("new-topic page /new-topic (authenticated) has no violations", async () => { // Override default unauthenticated session for this test only. mockGetSession.mockResolvedValueOnce({ authenticated: true, did: "did:plc:user", handle: "alice.test", }); mockFetchApi.mockImplementation((path: string) => { if (path === "/boards/1") { return Promise.resolve({ id: "1", did: "did:plc:forum", uri: "at://did:plc:forum/space.atbb.forum.board/1", name: "Test Board", description: null, slug: null, sortOrder: null, categoryId: "1", categoryUri: null, createdAt: null, indexedAt: null, }); } return Promise.reject(new Error(`Unexpected fetchApi call: ${path}`)); }); const routes = createNewTopicRoutes(APPVIEW_URL); const res = await routes.request("/new-topic?boardId=1"); expect(res.status).toBe(200); const html = await res.text(); // Guard: ensure the authenticated form rendered (not the "Log in" fallback). // If getSession() fell back to unauthenticated, the form would be absent. expect(html, "Expected authenticated new-topic form but got login fallback").toContain( 'name="title"' ); await checkA11y(html, "GET /new-topic (authenticated)"); }); it("not-found page has no violations", async () => { // No fetchApi or session calls — unauthenticated with no cookie. const routes = createNotFoundRoute(APPVIEW_URL); const res = await routes.request("/anything-that-does-not-exist"); expect(res.status).toBe(404); await checkA11y(await res.text(), "GET /404"); }); });