From 0cf4a488bb61d986c700f11c50f58798572f998a Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 13 May 2026 08:06:14 +0200 Subject: [PATCH] test(meta): add duplicate-id vi.mock detector under __meta__/ Scans every src/**/*.svelte.{spec,test}.ts file for vi.mock first-arg strings, canonicalises each by stripping a trailing .js/.ts after .svelte, groups by canonical id, and fails if any canonical id is referenced under two or more distinct raw spellings. Mirrors the shape of src/__meta__/no-async-mock-factories.test.ts: source-text regex scan (no AST parser dependency), red/green self-test fixtures inline, then one corpus assertion that the whole suite is clean. This is the in-suite defence-in-depth layer for the duplicate-id birpc race named in ADR-012 / #553 and fixed upstream by vitest PR #10267. Harder to disable than ESLint (cross-file invariant ESLint cannot express anyway) and harder to scope around than a CI grep. Refs: #553 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__meta__/no-duplicate-mock-ids.test.ts | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 frontend/src/__meta__/no-duplicate-mock-ids.test.ts diff --git a/frontend/src/__meta__/no-duplicate-mock-ids.test.ts b/frontend/src/__meta__/no-duplicate-mock-ids.test.ts new file mode 100644 index 00000000..451f8985 --- /dev/null +++ b/frontend/src/__meta__/no-duplicate-mock-ids.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect } from 'vitest'; +import { readdirSync, readFileSync } from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +// Belt-and-braces detector for the duplicate-id birpc race named in +// ADR-012 / #553. When the same resolved module URL is mocked via two +// distinct vi.mock id strings (e.g. '$lib/foo.svelte' and +// '$lib/foo.svelte.js'), @vitest/browser-playwright registers two +// Playwright routes against one cleanup slot — the orphan survives, fires +// after the next session's birpc closes, and crashes the run with +// "[birpc] rpc is closed, cannot call resolveManualMock". +// +// Fixed upstream in vitest PR #10267; until that fix reaches a published +// release, normalisation in user-land is the practical guard. This test +// catches the pattern at every vitest invocation — the layer hardest to +// disable or scope around. + +const VI_MOCK_ID = /vi\.mock\(\s*['"]([^'"]+)['"]/g; + +function extractMockIds(source: string): string[] { + const ids: string[] = []; + for (const match of source.matchAll(VI_MOCK_ID)) { + ids.push(match[1]); + } + return ids; +} + +function canonicalise(id: string): string { + if (id.endsWith('.svelte.js')) return id.slice(0, -3); + if (id.endsWith('.svelte.ts')) return id.slice(0, -3); + return id; +} + +export function findDuplicateMockIds( + specSources: Record +): Map> { + const byCanonical = new Map>(); + for (const source of Object.values(specSources)) { + for (const raw of extractMockIds(source)) { + const canonical = canonicalise(raw); + const existing = byCanonical.get(canonical) ?? new Set(); + existing.add(raw); + byCanonical.set(canonical, existing); + } + } + const duplicates = new Map>(); + for (const [canonical, raws] of byCanonical) { + if (raws.size >= 2) duplicates.set(canonical, raws); + } + return duplicates; +} + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const SRC_ROOT = path.resolve(__dirname, '..'); + +function findBrowserSpecs(): string[] { + const entries = readdirSync(SRC_ROOT, { recursive: true, withFileTypes: true }); + return entries + .filter( + (e) => + e.isFile() && (e.name.endsWith('.svelte.test.ts') || e.name.endsWith('.svelte.spec.ts')) + ) + .map((e) => path.join(e.parentPath ?? (e as { path: string }).path, e.name)); +} + +describe('scan: findDuplicateMockIds', () => { + it('flags two specs mocking the same module under .svelte and .svelte.js', () => { + const dup = findDuplicateMockIds({ + 'a.spec.ts': `vi.mock('$lib/foo.svelte', () => ({}));`, + 'b.spec.ts': `vi.mock('$lib/foo.svelte.js', () => ({}));` + }); + expect(dup.get('$lib/foo.svelte')).toEqual(new Set(['$lib/foo.svelte', '$lib/foo.svelte.js'])); + }); + + it('does not flag two specs both using $lib/foo.svelte', () => { + const dup = findDuplicateMockIds({ + 'a.spec.ts': `vi.mock('$lib/foo.svelte', () => ({}));`, + 'b.spec.ts': `vi.mock('$lib/foo.svelte', () => ({}));` + }); + expect(dup.size).toBe(0); + }); + + it('does not flag $app/state and $app/stores (different modules)', () => { + const dup = findDuplicateMockIds({ + 'a.spec.ts': `vi.mock('$app/state', () => ({}));`, + 'b.spec.ts': `vi.mock('$app/stores', () => ({}));` + }); + expect(dup.size).toBe(0); + }); + + it('does not flag $lib/foo and $lib/bar (different canonical paths)', () => { + const dup = findDuplicateMockIds({ + 'a.spec.ts': `vi.mock('$lib/foo', () => ({}));`, + 'b.spec.ts': `vi.mock('$lib/bar', () => ({}));` + }); + expect(dup.size).toBe(0); + }); + + it('flags both spellings within a single file', () => { + const dup = findDuplicateMockIds({ + 'a.spec.ts': ` + vi.mock('$lib/foo.svelte', () => ({})); + vi.mock('$lib/foo.svelte.js', () => ({})); + ` + }); + expect(dup.get('$lib/foo.svelte')?.size).toBe(2); + }); + + it('canonicalises .svelte.ts the same way as .svelte.js', () => { + const dup = findDuplicateMockIds({ + 'a.spec.ts': `vi.mock('$lib/foo.svelte', () => ({}));`, + 'b.spec.ts': `vi.mock('$lib/foo.svelte.ts', () => ({}));` + }); + expect(dup.get('$lib/foo.svelte')?.size).toBe(2); + }); +}); + +describe('browser specs: no duplicate-id vi.mock calls across the suite', () => { + it('every mocked module is referenced under exactly one id string', () => { + const specFiles = findBrowserSpecs(); + expect(specFiles.length).toBeGreaterThan(0); + const sources = Object.fromEntries( + specFiles.map((file) => [file, readFileSync(file, 'utf-8')]) + ); + const duplicates = findDuplicateMockIds(sources); + const report = Object.fromEntries([...duplicates].map(([k, v]) => [k, [...v]])); + expect(report).toEqual({}); + }); +});