Compare commits

...

4 Commits

Author SHA1 Message Date
Marcel
fa95cc5e21 test(meta): ban no-factory vi.mock of virtual modules
A vi.mock('$app/navigation') with no factory does not auto-resolve to a
__mocks__ file for SvelteKit virtual modules — it substitutes some
exports and leaves others (replaceState) bound to the live router, which
is exactly the PR #657 failure. This Node-mode source scan, mirroring
no-async-mock-factories and no-duplicate-mock-ids, fails at every vitest
invocation if any *.svelte.{spec,test}.ts reintroduces the pattern, and
forecloses ADR-012's rejected Option C. Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 20:27:19 +02:00
Marcel
7c2d1807df build(frontend): add $mocks alias for shared browser-test mock bodies
Declares $mocks -> src/__mocks__ in both vite.config.ts and
vitest.client-coverage.config.ts so shared mock modules resolve in the
client test run and the coverage job alike. Enables the sync-factory
dedup pattern from ADR-012 (vi.mock('$app/forms', () => ({ ...formsMock }))).
Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 20:27:19 +02:00
Marcel
e1bd090acd build(frontend): exact-pin @vitest/browser-playwright to 4.1.6
Drop the caret so the version cannot float off the patched release.
patches/@vitest+browser-playwright+4.1.6.patch backports vitest PR #10267
(the duplicate-mock-id birpc race, ADR-012) and only applies to 4.1.6; a
caret range could resolve to a version the patch rejects. A top-level
"//" key records the removal condition since package.json forbids
comments. Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 20:27:19 +02:00
Marcel
c56aa1c645 refactor(admin-tags): migrate tag-edit page from $app/stores to $app/state
The legacy $app/stores subscription API is replaced with the modern
$app/state reactive proxy (page.url.pathname), per ADR-012's
architectural follow-on. The two spec mocks of $app/stores are replaced
with sync-factory $app/state mocks, matching the existing convention in
aktivitaeten/documents specs. Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 20:27:19 +02:00
8 changed files with 111 additions and 17 deletions

View File

@@ -32,7 +32,7 @@
"@tailwindcss/vite": "^4.1.17", "@tailwindcss/vite": "^4.1.17",
"@types/diff": "^7.0.2", "@types/diff": "^7.0.2",
"@types/node": "^24", "@types/node": "^24",
"@vitest/browser-playwright": "^4.0.10", "@vitest/browser-playwright": "4.1.6",
"@vitest/coverage-istanbul": "^4.1.0", "@vitest/coverage-istanbul": "^4.1.0",
"@vitest/coverage-v8": "^4.1.0", "@vitest/coverage-v8": "^4.1.0",
"eslint": "^9.39.1", "eslint": "^9.39.1",

View File

@@ -3,6 +3,7 @@
"private": true, "private": true,
"version": "0.0.1", "version": "0.0.1",
"type": "module", "type": "module",
"//@vitest/browser-playwright": "Exact-pinned (no caret) to 4.1.6 so patches/@vitest+browser-playwright+4.1.6.patch (backport of vitest PR #10267, the duplicate-mock-id birpc race) keeps applying. TODO: remove this pin and the patch once @vitest/browser-playwright ships a release containing PR #10267. See docs/adr/012-browser-test-mocking-strategy.md.",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"build": "vite build", "build": "vite build",
@@ -47,7 +48,7 @@
"@tailwindcss/vite": "^4.1.17", "@tailwindcss/vite": "^4.1.17",
"@types/diff": "^7.0.2", "@types/diff": "^7.0.2",
"@types/node": "^24", "@types/node": "^24",
"@vitest/browser-playwright": "^4.0.10", "@vitest/browser-playwright": "4.1.6",
"@vitest/coverage-istanbul": "^4.1.0", "@vitest/coverage-istanbul": "^4.1.0",
"@vitest/coverage-v8": "^4.1.0", "@vitest/coverage-v8": "^4.1.0",
"eslint": "^9.39.1", "eslint": "^9.39.1",

View File

@@ -0,0 +1,85 @@
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 no-factory vi.mock anti-pattern named in
// ADR-012 (the PR #657 failure class). A `vi.mock('$app/navigation')` with no
// factory does NOT auto-resolve to an adjacent __mocks__ file the way Jest's
// __mocks__/ does: for SvelteKit virtual modules, vitest substitutes some
// exports (plain function refs like goto) but leaves others bound to the live
// implementation (replaceState, which delegates through a getter). The result
// is a partial mock that crashes when an unsubstituted export is hit.
//
// The sanctioned dedup pattern keeps the factory and shares its body:
// import * as formsMock from '$mocks/$app/forms';
// vi.mock('$app/forms', () => ({ ...formsMock }));
//
// ESLint and the CI grep guard catch the pattern earlier; this in-suite test
// catches it at every vitest invocation — the layer hardest to disable. It
// also forecloses ADR-012's rejected Option C (config-level auto-resolve).
//
// We scan source text rather than parsing AST: fast, no parser dependency,
// good enough for the named anti-pattern. The pattern matches a `vi.mock`
// call whose only argument is a string literal (no factory after a comma).
const NO_FACTORY_VI_MOCK = /vi\.mock\(\s*['"][^'"]+['"]\s*\)/;
export function hasNoFactoryViMock(source: string): boolean {
return NO_FACTORY_VI_MOCK.test(source);
}
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: hasNoFactoryViMock', () => {
it('flags a vi.mock with a string id and no factory', () => {
expect(hasNoFactoryViMock(`vi.mock('$app/navigation');`)).toBe(true);
});
it('flags a no-factory vi.mock written across multiple lines', () => {
const fixture = `vi.mock(
'$app/forms'
);`;
expect(hasNoFactoryViMock(fixture)).toBe(true);
});
it('does not flag a vi.mock with an inline factory', () => {
expect(hasNoFactoryViMock(`vi.mock('$app/forms', () => ({ enhance: () => () => {} }));`)).toBe(
false
);
});
it('does not flag a vi.mock with a shared-body spread factory', () => {
const fixture = `import * as formsMock from '$mocks/$app/forms';
vi.mock('$app/forms', () => ({ ...formsMock }));`;
expect(hasNoFactoryViMock(fixture)).toBe(false);
});
it('does not flag a vi.mock with a named factory reference', () => {
expect(hasNoFactoryViMock(`vi.mock('$app/state', factory);`)).toBe(false);
});
it('does not flag source with no vi.mock at all', () => {
expect(hasNoFactoryViMock(`const x = vi.fn();`)).toBe(false);
});
});
describe('browser specs: no no-factory vi.mock of a virtual module', () => {
it('every src/**/*.svelte.{test,spec}.ts file keeps its factory', () => {
const specFiles = findBrowserSpecs();
expect(specFiles.length).toBeGreaterThan(0);
const offenders = specFiles.filter((file) => hasNoFactoryViMock(readFileSync(file, 'utf-8')));
expect(offenders).toEqual([]);
});
});

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { replaceState } from '$app/navigation'; import { replaceState } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/state';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { createUnsavedWarning } from '$lib/shared/hooks/useUnsavedWarning.svelte'; import { createUnsavedWarning } from '$lib/shared/hooks/useUnsavedWarning.svelte';
import UnsavedWarningBanner from '$lib/shared/primitives/UnsavedWarningBanner.svelte'; import UnsavedWarningBanner from '$lib/shared/primitives/UnsavedWarningBanner.svelte';
@@ -44,7 +44,7 @@ $effect(() => {
$effect(() => { $effect(() => {
if (data.mergeSuccess) { if (data.mergeSuccess) {
replaceState($page.url.pathname, {}); replaceState(page.url.pathname, {});
} }
}); });

View File

@@ -10,12 +10,9 @@ vi.mock('$app/navigation', () => ({
goto: vi.fn(), goto: vi.fn(),
replaceState: vi.fn() replaceState: vi.fn()
})); }));
vi.mock('$app/stores', () => ({ vi.mock('$app/state', () => ({
page: { get page() {
subscribe: (fn: (v: { url: URL }) => void) => { return { url: new URL('http://localhost/admin/tags/t1') };
fn({ url: new URL('http://localhost/admin/tags/t1') });
return () => {};
}
} }
})); }));

View File

@@ -2,14 +2,11 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte'; import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser'; import { page } from 'vitest/browser';
const mockPage = { url: { pathname: '/admin/tags/t1' } }; const mockPage = { url: new URL('http://localhost/admin/tags/t1') };
vi.mock('$app/stores', () => ({ vi.mock('$app/state', () => ({
page: { get page() {
subscribe: (fn: (v: typeof mockPage) => void) => { return mockPage;
fn(mockPage);
return () => {};
}
} }
})); }));

View File

@@ -6,8 +6,15 @@ import { defineConfig } from 'vitest/config';
import { playwright } from '@vitest/browser-playwright'; import { playwright } from '@vitest/browser-playwright';
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from '@sveltejs/kit/vite';
import { viteStaticCopy } from 'vite-plugin-static-copy'; import { viteStaticCopy } from 'vite-plugin-static-copy';
import { fileURLToPath } from 'node:url';
export default defineConfig({ export default defineConfig({
resolve: {
alias: {
// Shared browser-test mock bodies, imported into sync vi.mock factories. See ADR-012.
$mocks: fileURLToPath(new URL('./src/__mocks__', import.meta.url))
}
},
optimizeDeps: { optimizeDeps: {
include: ['pdfjs-dist', '@tiptap/core', '@tiptap/starter-kit', '@tiptap/extension-mention'] include: ['pdfjs-dist', '@tiptap/core', '@tiptap/starter-kit', '@tiptap/extension-mention']
}, },

View File

@@ -4,6 +4,7 @@ import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vitest/config'; import { defineConfig } from 'vitest/config';
import { playwright } from '@vitest/browser-playwright'; import { playwright } from '@vitest/browser-playwright';
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from '@sveltejs/kit/vite';
import { fileURLToPath } from 'node:url';
// Standalone config for browser-project Istanbul coverage. // Standalone config for browser-project Istanbul coverage.
// Uses a dedicated root-level coverage block because Vitest 4 ignores // Uses a dedicated root-level coverage block because Vitest 4 ignores
@@ -11,6 +12,12 @@ import { sveltekit } from '@sveltejs/kit/vite';
// Plugins mirrored from vite.config.ts: tailwindcss, sveltekit, devtoolsJson, paraglideVitePlugin // Plugins mirrored from vite.config.ts: tailwindcss, sveltekit, devtoolsJson, paraglideVitePlugin
// Update here whenever vite.config.ts plugins change. // Update here whenever vite.config.ts plugins change.
export default defineConfig({ export default defineConfig({
resolve: {
alias: {
// Shared browser-test mock bodies, imported into sync vi.mock factories. See ADR-012.
$mocks: fileURLToPath(new URL('./src/__mocks__', import.meta.url))
}
},
optimizeDeps: { optimizeDeps: {
include: ['pdfjs-dist', '@tiptap/core', '@tiptap/starter-kit', '@tiptap/extension-mention'] include: ['pdfjs-dist', '@tiptap/core', '@tiptap/starter-kit', '@tiptap/extension-mention']
}, },