Adds a no-restricted-syntax rule scoped to *.spec.ts / *.test.ts that flags any vi.mock call whose first argument starts with 'pdfjs-dist'. Turns the ~2-min CI wait into an immediate lint error on save. Updates ADR 012 Enforcement section to document the rule. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
5.6 KiB
ADR 012 — Browser-Mode Test Mocking Strategy
Status: Accepted
Date: 2026-05-11
Issue: #535 — Unit & Component Tests job exits 1 from vitest-browser teardown race
Context
Vitest browser-mode tests (the client project, run with @vitest/browser-playwright / Chromium) use a different module resolution path than Node-environment tests. When a spec calls vi.mock('some-module', factory), vitest registers a ManualMockedModule. At runtime, every time Chromium requests that module, a playwright route handler intercepts the request and calls the Node worker over birpc (resolveManualMock) to evaluate the factory and return the module body.
This is safe for modules that are imported statically at spec module-eval time (e.g. $app/navigation, $env/static/public): those requests resolve before the first test runs and well before any teardown occurs.
It is unsafe for modules that are imported dynamically (e.g. inside an async onMount, inside a lazy-loaded chunk): Chromium may fetch the module after the worker's birpc channel has already closed, producing:
Error: [birpc] rpc is closed, cannot call "resolveManualMock"
❯ ManualMockedModule.factory node_modules/@vitest/browser/dist/index.js:3221:34
This raises an unhandled rejection that exits the vitest process with code 1, even though every test in the run reported green.
pdfjs-dist and pdfjs-dist/build/pdf.worker.min.mjs?url are loaded via await Promise.all([import('pdfjs-dist'), import('pdfjs-dist/build/pdf.worker.min.mjs?url')]) inside usePdfRenderer.svelte.ts::init(), which is called from onMount. These dynamic imports triggered the race.
Decision
Prefer prop injection over vi.mock(module, factory) for any module that is loaded dynamically in browser-mode specs.
The libLoader pattern (for external rendering libraries)
When a component depends on a large external library loaded via dynamic import, extract the import into an injectable loader function with a production default:
// usePdfRenderer.svelte.ts
type LibLoader = () => Promise<readonly [typeof import('pdfjs-dist'), { default: string }]>;
const defaultLibLoader: LibLoader = () =>
Promise.all([import('pdfjs-dist'), import('pdfjs-dist/build/pdf.worker.min.mjs?url')]);
export function createPdfRenderer(libLoader: LibLoader = defaultLibLoader) { ... }
The component threads the loader as an optional prop:
<!-- PdfViewer.svelte -->
let { url, ..., libLoader = undefined } = $props();
const renderer = untrack(() => createPdfRenderer(libLoader));
Tests supply a synchronous fake — no vi.mock needed:
const fakePdfjs = { GlobalWorkerOptions: ..., getDocument: vi.fn(), TextLayer: class {} };
const fakeLoader = vi.fn().mockResolvedValue([fakePdfjs, { default: '' }] as const);
render(PdfViewer, { url: '...', libLoader: fakeLoader });
The test-host pattern (for component behaviour)
For components that fetch data or call services, the *.test-host.svelte pattern threads the dependency as a prop rather than mocking the module. See PersonMentionEditor.test-host.svelte for the canonical example.
Residual exceptions
The following vi.mock(module, factory) calls in browser specs are acceptable because the mocked modules are loaded statically at spec module-eval time and cannot produce a teardown race:
| Module | Why it stays as vi.mock |
|---|---|
$app/navigation |
SvelteKit virtual module — no DI seam |
$app/forms |
SvelteKit virtual module — no DI seam |
$app/state |
SvelteKit virtual module — no DI seam |
$app/stores |
SvelteKit virtual module — no DI seam |
$env/static/public |
Vite env virtual module — no DI seam |
These modules are resolved at static import time (before any test runs). Their vi.mock factories are served by birpc synchronously during module graph resolution, not after worker teardown.
Pattern note: When an overlay or dropdown contains a navigation link (<a href="…">), use e.preventDefault() + goto(path) in the click handler instead of letting the browser follow the href. In a vitest-browser Playwright iframe there is no SvelteKit router, so a real navigation tears down the orchestrator iframe and crashes the test run. The href attribute should still be present for right-click / open-in-new-tab semantics.
Consequences
- New browser-mode specs that need to stub an external library must not use
vi.mock(externalLib, factory). Add a loader/factory parameter to the underlying hook or service instead. - The CI
unit-testsjob includes a permanent grep guard that fails the build ifrpc is closedappears in any coverage run log. This catches regressions before they reach the acceptance criterion. - Acceptance criterion for #535: 60 consecutive green
workflow_dispatchCI runs againstmainafter the fix is merged, with zerorpc is closedlines in any log. - Enforcement: An ESLint
no-restricted-syntaxrule ineslint.config.js(scoped to**/*.spec.tsand**/*.test.ts) flags anyvi.mock('pdfjs-dist', ...)call with a message referencing this ADR. This turns a ~2-minute CI wait into an immediate lint error on save. The CI birpc grep guard remains as belt-and-suspenders for the coverage run. A CI static grep step (added in issue #546) also catches the pattern before the test suite launches. - When to revisit the LibLoader home: If three or more components adopt this pattern, consider extracting a shared
$lib/types/lib-loader.tsor a genericDynamicImportLoader<T>type to avoid parallel type definitions across modules.