revert(test): abandon shared-mock dedup — infeasible in vitest browser mode

CI proved cross-file sharing of a virtual-module mock body cannot work in
@vitest/browser-playwright 4.1.6: the static-import spread fails the hoist
("no top level variables"), and the await-vi.hoisted-import form fails to
parse ("Unexpected identifier 'vi'"). vi.hoisted has the same hoist
constraint as vi.mock, so there is no way to thread an external module's
body into the factory here.

Reverts Phase 1: restores the 4 $app/forms/$app/navigation specs to their
inline factories, inlines NotificationBell.spec's forms stub, deletes the
src/__mocks__/$app/* modules and the $mocks alias (vite, vitest-coverage,
kit). The no-factory-ban meta-test stays (no-factory vi.mock is still
banned). ADR-012 amended to record the infeasibility. Everything else
($app/state migration, confirm context-inject, notification refactor, the
pin, the meta-test) is unaffected. Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-03 08:21:02 +02:00
committed by marcel
parent 25b23843c9
commit 4db2e97490
12 changed files with 143 additions and 150 deletions

View File

@@ -11,9 +11,10 @@ import { fileURLToPath } from 'url';
// 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 }));
// The sanctioned form keeps an INLINE sync factory:
// vi.mock('$app/forms', () => ({ enhance(node, submit) { ... } }));
// (Sharing the body via a module imported into the factory is infeasible in
// browser mode — vitest hoists vi.mock above the import; see ADR-012.)
//
// 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
@@ -60,9 +61,10 @@ describe('scan: hasNoFactoryViMock', () => {
);
});
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 }));`;
it('does not flag a vi.mock with a multi-line inline factory', () => {
const fixture = `vi.mock('$app/forms', () => ({
enhance: (node, submit) => ({ destroy() {} })
}));`;
expect(hasNoFactoryViMock(fixture)).toBe(false);
});

View File

@@ -1,43 +0,0 @@
// Shared browser-test mock body for the SvelteKit `$app/forms` virtual module.
//
// Imported into a sync vi.mock factory via the $mocks alias:
// import * as formsMock from '$mocks/$app/forms';
// vi.mock('$app/forms', () => ({ ...formsMock }));
//
// `enhance` intercepts the form's submit event, invokes the component's
// SubmitFunction, and — when that returns a post-submit callback — calls it
// with the configurable `_formResult`. Tests drive the success/failure branch
// with `setFormResult({ type: 'failure' })`. The embedded `beforeEach` resets
// the result before every test, so isolation is structural, not per-spec.
// See ADR-012.
import { beforeEach } from 'vitest';
export type FormEnhanceResult = { type: string; data?: Record<string, unknown> };
let _formResult: FormEnhanceResult = { type: 'success' };
export function setFormResult(result: FormEnhanceResult): void {
_formResult = result;
}
export function enhance(
node: HTMLFormElement,
submit?: (opts: {
formData: FormData;
}) => (opts: { result: FormEnhanceResult; update: () => Promise<void> }) => Promise<void>
): { destroy: () => void } {
const handler = async (e: Event) => {
e.preventDefault();
const callback = submit?.({ formData: new FormData(node) } as never);
if (typeof callback === 'function') {
await callback({ result: _formResult, update: async () => {} });
}
};
node.addEventListener('submit', handler);
return { destroy: () => node.removeEventListener('submit', handler) };
}
beforeEach(() => {
_formResult = { type: 'success' };
});

View File

@@ -1,62 +0,0 @@
// Shared browser-test mock body for the SvelteKit `$app/navigation` virtual module.
//
// Imported into a sync vi.mock factory via the $mocks alias:
// import * as navMock from '$mocks/$app/navigation';
// vi.mock('$app/navigation', () => ({ ...navMock }));
//
// All navigation functions are vi.fn() stubs. `beforeNavigate` additionally
// captures the registered callback so a test can drive it through the exported
// `simulateNavigate(href)` helper — the whole capture-and-fire pattern lives
// here, not the raw callback. The embedded `beforeEach` clears the captured
// callback and the mock call histories before every test, so isolation is
// structural. See ADR-012.
import { beforeEach, vi } from 'vitest';
type BeforeNavigateCallback = (nav: {
cancel: () => void;
to: { url: { href: string } } | null;
}) => void;
let _registeredBeforeNavigate: BeforeNavigateCallback | null = null;
export const goto = vi.fn();
export const invalidate = vi.fn();
export const invalidateAll = vi.fn();
export const beforeNavigate = vi.fn((fn: BeforeNavigateCallback) => {
_registeredBeforeNavigate = fn;
});
export const afterNavigate = vi.fn();
export const preloadCode = vi.fn();
export const preloadData = vi.fn();
export const pushState = vi.fn();
export const replaceState = vi.fn();
export const disableScrollHandling = vi.fn();
export const onNavigate = vi.fn();
const _navMocks = [
goto,
invalidate,
invalidateAll,
beforeNavigate,
afterNavigate,
preloadCode,
preloadData,
pushState,
replaceState,
disableScrollHandling,
onNavigate
];
// Fire the captured beforeNavigate callback as if navigating to `href`.
// Returns the cancel spy so the test can assert whether navigation was blocked.
export function simulateNavigate(href: string | null = '/somewhere'): ReturnType<typeof vi.fn> {
const cancel = vi.fn();
_registeredBeforeNavigate?.({ cancel, to: href ? { url: { href } } : null });
return cancel;
}
beforeEach(() => {
_registeredBeforeNavigate = null;
_navMocks.forEach((mock) => mock.mockClear());
});

View File

@@ -4,12 +4,36 @@ import { page, userEvent } from 'vitest/browser';
import ChronikFuerDichBox from './ChronikFuerDichBox.svelte';
import type { NotificationItem } from '$lib/notification/notifications.svelte';
const formsMock = await vi.hoisted(() => import('$mocks/$app/forms'));
vi.mock('$app/forms', () => ({ ...formsMock }));
const mockFormResult = vi.hoisted(() => ({ type: 'success' as string }));
vi.mock('$app/forms', () => ({
enhance(
node: HTMLFormElement,
submit?: (opts: {
formData: FormData;
}) => (opts: {
result: { type: string; data?: Record<string, unknown> };
update: () => Promise<void>;
}) => Promise<void>
) {
const handler = async (e: Event) => {
e.preventDefault();
const cb = submit?.({ formData: new FormData(node) } as never);
if (typeof cb === 'function') {
await (
cb as (o: { result: typeof mockFormResult; update: () => Promise<void> }) => Promise<void>
)({ result: mockFormResult, update: async () => {} });
}
};
node.addEventListener('submit', handler);
return { destroy: () => node.removeEventListener('submit', handler) };
}
}));
afterEach(() => {
cleanup();
mockFormResult.type = 'success';
});
function notif(partial: Partial<NotificationItem>): NotificationItem {
@@ -152,7 +176,7 @@ describe('ChronikFuerDichBox', () => {
});
it('shows an accessible error banner when the dismiss action returns a failure', async () => {
formsMock.setFormResult({ type: 'failure' });
mockFormResult.type = 'failure';
render(ChronikFuerDichBox, {
unread: [notif({ id: 'err-1' })],
optimisticMarkRead: vi.fn(),

View File

@@ -3,12 +3,36 @@ import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import ChronikFuerDichBox from './ChronikFuerDichBox.svelte';
import type { NotificationItem } from '$lib/notification/notifications';
const formsMock = await vi.hoisted(() => import('$mocks/$app/forms'));
vi.mock('$app/forms', () => ({ ...formsMock }));
const mockFormResult = vi.hoisted(() => ({ type: 'success' as string }));
vi.mock('$app/forms', () => ({
enhance(
node: HTMLFormElement,
submit?: (opts: {
formData: FormData;
}) => (opts: {
result: { type: string; data?: Record<string, unknown> };
update: () => Promise<void>;
}) => Promise<void>
) {
const handler = async (e: Event) => {
e.preventDefault();
const cb = submit?.({ formData: new FormData(node) } as never);
if (typeof cb === 'function') {
await (
cb as (o: { result: typeof mockFormResult; update: () => Promise<void> }) => Promise<void>
)({ result: mockFormResult, update: async () => {} });
}
};
node.addEventListener('submit', handler);
return { destroy: () => node.removeEventListener('submit', handler) };
}
}));
afterEach(() => {
cleanup();
mockFormResult.type = 'success';
});
const mention = (overrides: Partial<NotificationItem> = {}): NotificationItem => ({
@@ -136,7 +160,7 @@ describe('ChronikFuerDichBox', () => {
});
it('shows an accessible error banner when the dismiss action returns a failure', async () => {
formsMock.setFormResult({ type: 'failure' });
mockFormResult.type = 'failure';
render(ChronikFuerDichBox, {
props: {
unread: [mention({ id: 'err-1' })],

View File

@@ -4,10 +4,17 @@ import { m } from '$lib/paraglide/messages.js';
import type { NotificationItem } from '$lib/notification/notifications';
import NotificationFixture from './notification.test-fixture.svelte';
const formsMock = await vi.hoisted(() => import('$mocks/$app/forms'));
vi.mock('$app/navigation', () => ({ goto: vi.fn(), beforeNavigate: vi.fn() }));
vi.mock('$app/forms', () => ({ ...formsMock }));
vi.mock('$app/forms', () => ({
enhance(node: HTMLFormElement, submit?: (opts: { formData: FormData }) => unknown) {
const handler = (e: Event) => {
e.preventDefault();
submit?.({ formData: new FormData(node) } as never);
};
node.addEventListener('submit', handler);
return { destroy: () => node.removeEventListener('submit', handler) };
}
}));
// NotificationBell.onMount calls store.init(), which opens an EventSource and
// fetches the unread count. Stub both so no real network or 401 → /login

View File

@@ -3,15 +3,41 @@ import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import { goto } from '$app/navigation';
import NotificationDropdown from './NotificationDropdown.svelte';
const formsMock = await vi.hoisted(() => import('$mocks/$app/forms'));
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
vi.mock('$app/forms', () => ({ ...formsMock }));
// Configurable result for the enhance mock — tests that need failure set
// mockFormResult.type = 'failure' before clicking.
const mockFormResult = vi.hoisted(() => ({ type: 'success' as string }));
// Invoke the SubmitFunction and always call the returned result callback with
// mockFormResult so tests can exercise both success and failure branches.
vi.mock('$app/forms', () => ({
enhance(
node: HTMLFormElement,
submit?: (opts: {
formData: FormData;
}) => (opts: {
result: { type: string; data?: Record<string, unknown> };
update: () => Promise<void>;
}) => Promise<void>
) {
const handler = async (e: Event) => {
e.preventDefault();
const cb = submit?.({ formData: new FormData(node) } as never);
if (typeof cb === 'function') {
await cb({ result: mockFormResult, update: async () => {} } as never);
}
};
node.addEventListener('submit', handler);
return { destroy: () => node.removeEventListener('submit', handler) };
}
}));
afterEach(() => {
cleanup();
vi.clearAllMocks();
mockFormResult.type = 'success'; // reset to default after each test
});
const makeNotification = (overrides: Record<string, unknown> = {}) => ({
@@ -209,7 +235,7 @@ describe('NotificationDropdown', () => {
});
it('shows a role=alert error banner when mark-all-read returns a failure', async () => {
formsMock.setFormResult({ type: 'failure' });
mockFormResult.type = 'failure';
render(NotificationDropdown, {
props: {
notifications: [makeNotification()],
@@ -346,7 +372,7 @@ describe('NotificationDropdown', () => {
});
it('does NOT call onClose or goto when the dismiss action returns a failure', async () => {
formsMock.setFormResult({ type: 'failure' });
mockFormResult.type = 'failure';
const onClose = vi.fn();
const n = makeNotification({ id: 'n99', actorName: 'Bob' });
render(NotificationDropdown, {

View File

@@ -1,12 +1,35 @@
import { describe, it, expect, vi } from 'vitest';
const navMock = await vi.hoisted(() => import('$mocks/$app/navigation'));
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$app/navigation', () => ({ ...navMock }));
// Capture the beforeNavigate callback so tests can simulate navigation events
let registeredBeforeNavigate:
| ((nav: { cancel: () => void; to: { url: { href: string } } | null }) => void)
| null = null;
const { simulateNavigate, goto: mockGoto } = navMock;
const mockGoto = vi.fn();
vi.mock('$app/navigation', () => ({
beforeNavigate: vi.fn((fn: typeof registeredBeforeNavigate) => {
registeredBeforeNavigate = fn;
}),
goto: mockGoto
}));
const { createUnsavedWarning } = await import('./useUnsavedWarning.svelte');
function simulateNavigate(href: string | null = '/somewhere') {
const cancel = vi.fn();
registeredBeforeNavigate?.({
cancel,
to: href ? { url: { href } } : null
});
return cancel;
}
beforeEach(() => {
registeredBeforeNavigate = null;
mockGoto.mockClear();
});
describe('createUnsavedWarning', () => {
it('isDirty starts false', () => {
const w = createUnsavedWarning();

View File

@@ -8,11 +8,6 @@ const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter(),
// $mocks resolves shared browser-test mock bodies (src/__mocks__). Declared here
// so svelte-check/tsconfig and both vite configs resolve it. See ADR-012.
alias: {
$mocks: 'src/__mocks__'
},
prerender: {
entries: ['/hilfe/transkription'],
// Disable crawl: by default SvelteKit follows nav links from

View File

@@ -6,15 +6,8 @@ import { defineConfig } from 'vitest/config';
import { playwright } from '@vitest/browser-playwright';
import { sveltekit } from '@sveltejs/kit/vite';
import { viteStaticCopy } from 'vite-plugin-static-copy';
import { fileURLToPath } from 'node:url';
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: {
include: ['pdfjs-dist', '@tiptap/core', '@tiptap/starter-kit', '@tiptap/extension-mention']
},

View File

@@ -4,7 +4,6 @@ import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vitest/config';
import { playwright } from '@vitest/browser-playwright';
import { sveltekit } from '@sveltejs/kit/vite';
import { fileURLToPath } from 'node:url';
// Standalone config for browser-project Istanbul coverage.
// Uses a dedicated root-level coverage block because Vitest 4 ignores
@@ -12,12 +11,6 @@ import { fileURLToPath } from 'node:url';
// Plugins mirrored from vite.config.ts: tailwindcss, sveltekit, devtoolsJson, paraglideVitePlugin
// Update here whenever vite.config.ts plugins change.
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: {
include: ['pdfjs-dist', '@tiptap/core', '@tiptap/starter-kit', '@tiptap/extension-mention']
},