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) <noreply@anthropic.com>
This commit is contained in:
130
frontend/src/__meta__/no-duplicate-mock-ids.test.ts
Normal file
130
frontend/src/__meta__/no-duplicate-mock-ids.test.ts
Normal file
@@ -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<string, string>
|
||||||
|
): Map<string, Set<string>> {
|
||||||
|
const byCanonical = new Map<string, Set<string>>();
|
||||||
|
for (const source of Object.values(specSources)) {
|
||||||
|
for (const raw of extractMockIds(source)) {
|
||||||
|
const canonical = canonicalise(raw);
|
||||||
|
const existing = byCanonical.get(canonical) ?? new Set<string>();
|
||||||
|
existing.add(raw);
|
||||||
|
byCanonical.set(canonical, existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const duplicates = new Map<string, Set<string>>();
|
||||||
|
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({});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user