/** * Base class for browser-mode page test harnesses. * * Owns the generic DOM / wait / interaction plumbing every page harness needs, * so concrete harnesses (providers, attack-paths, …) only declare their own * domain vocabulary. The DOM / wait / interaction primitives are `protected` — * subclasses build their semantic API on top of them and tests don't reach * them directly. The public members are the deliberate exceptions: `user` * (harness tests spy on it) and the request-tracking assertion helpers * (`requestLog`, `countRequests`, `lastRequestBody`) that page harnesses expose * as domain vocab. * * Mount-agnostic on purpose: some pages are mounted by their harness, others * (attack-paths) are rendered by the test directly, so a `render` here would * only serve half the call sites. Mounting lives in `render-browser.tsx`, which * wraps every render in the shared app shell (`app-shell.tsx`) — both kinds of * call site reach it. * * Request tracking is opt-in via `trackRequests(worker)`, and unregisters * itself when the test ends. */ import type { SetupWorker } from "msw/browser"; import { onTestFinished, vi } from "vitest"; import { userEvent } from "vitest/browser"; type RequestStartListener = (event: { request: Request }) => void; /** * The predicate stayed falsy for the whole timeout. `waitForOrNull` swallows * only this, so a throwing predicate still surfaces as the bug it is. */ class WaitForPending extends Error {} export abstract class BrowserHarness { readonly user = userEvent; /** * Every request MSW saw since `trackRequests` was wired, for assertions. The * entry keeps a clone, so a payload assertion can read a body the app's own * fetch already consumed. */ readonly requestLog: Array<{ method: string; url: string; request: Request; }> = []; private trackedWorker: SetupWorker | null = null; private requestListener: RequestStartListener | null = null; constructor(readonly fixture: TFixture) {} // --- Request tracking (opt-in) ------------------------------------------ /** Start recording MSW requests into `requestLog`. Call once, after mounting. */ protected trackRequests(worker: SetupWorker): void { const listener: RequestStartListener = ({ request }) => { this.requestLog.push({ method: request.method, url: request.url, request: request.clone(), }); }; this.trackedWorker = worker; this.requestListener = listener; worker.events.on("request:start", listener); // The worker is module-level and shared across harnesses, so listeners // would otherwise accumulate run over run. Drop only this harness's // listener when the test ends — clearing the emitter would also silence // listeners another harness or diagnostic owns. onTestFinished(() => this.untrackRequests()); } private untrackRequests(): void { const worker = this.trackedWorker; const listener = this.requestListener; if (!worker || !listener) return; worker.events.removeListener("request:start", listener); this.trackedWorker = null; this.requestListener = null; } countRequests(method: string, pathIncludes: string): number { return this.requestLog.filter( (r) => r.method === method && r.url.includes(pathIncludes), ).length; } /** Parsed JSON body of the most recent request matching method + path. */ async lastRequestBody( method: string, pathIncludes: string, ): Promise { const entry = [...this.requestLog] .reverse() .find((r) => r.method === method && r.url.includes(pathIncludes)); return entry ? ((await entry.request.clone().json()) as T) : null; } // --- Low-level DOM ------------------------------------------------------ protected get container(): HTMLElement { return document.body; } protected q(selector: string): HTMLElement | null { return this.container.querySelector(selector); } protected byRoleName( role: string, name: RegExp, scope: ParentNode = document, ): HTMLElement | null { const explicit = Array.from( scope.querySelectorAll(`[role="${role}"]`), ).find((el) => name.test(el.textContent ?? "")); if (explicit) return explicit; // A native