Consolidate shared vi.mock bodies + migrate confirm/notification specs (#560) #719

Merged
marcel merged 17 commits from feat/issue-560-shared-vimock-mocks into main 2026-06-03 11:38:23 +02:00
Owner

Closes #560.

Update (Phase 1 reverted). CI proved the issue's headline goal — sharing a virtual-module mock body across spec files — is infeasible in @vitest/browser-playwright 4.1.6. Both sanctioned forms fail: vi.mock('$app/forms', () => ({ ...staticImport })) fails the hoist ("no top level variables"), and await vi.hoisted(() => import('$mocks/...')) fails to parse ("Unexpected identifier 'vi'"). vi.hoisted shares vi.mock's hoist constraint, so there's no way to thread an external body into the factory. Phase 1 was reverted to inline factories (the only working pattern); ADR-012 records the finding. This is the third false premise in the #657/#560 saga.

What this PR delivers (CI-green path)

Groundwork

  • admin/tags/[id]/+page.svelte: $app/stores$app/state; its 2 $app/stores spec mocks replaced with sync-factory $app/state mocks (AC#3 ).
  • @vitest/browser-playwright exact-pinned to 4.1.6 (drop caret) so patches/@vitest+browser-playwright+4.1.6.patch keeps applying; TODO recorded via a top-level "//" key (AC#9 ).
  • src/__meta__/no-factory-ban.test.ts — Node-mode scan banning no-factory vi.mock(virtualModule) (AC#6 , TDD red→green). Still valuable independent of Phase 1.
  • ADR-012 amended: records the no-factory ban and that cross-file mock-body sharing is infeasible here (inline sync factories are the only working pattern).

Phase 2 — fixtures / context (the substantive work)

  • Confirm (AC#4 ): the 8 specs that mocked confirm.svelte now inject a real createConfirmService() via render context (mirroring admin/tags/[id]/page.svelte.spec.ts). Zero confirm.svelte vi.mocks remain. The generic confirm.test-fixture.svelte can't wrap a page, and none of the 8 trigger confirm(), so AC#4 is met as "real provided service, no vi.mock".
  • Notification (AC#5 ): full context refactor — notifications.svelte singleton → createNotificationStore() + provideNotificationStore()/getNotificationStore()/NOTIFICATION_KEY, provided once in root +layout. NotificationBell + the Chronik page read it via context. notification.test-fixture.svelte wraps the bell and exposes setNotifications() via onReady; NotificationBell.svelte.spec.ts asserts the announced unread count across empty / single / many / error states. notifications.svelte.spec.ts rewritten to drive a fresh factory per test. (Maintainer-approved scope: adds production code beyond the issue's "test-infra only" sign-off.)

Reverted / not delivered

  • AC#1, #2 ($app/forms / $app/navigation shared mocks) — infeasible, reverted to inline factories.
  • AC#8 ($mocks alias) — removed (it only existed to serve the shared mocks).
  • AC#10/#11 — full client suite + coverage-flake-probe.yml are CI-only.

Verification

  • npm run build ✓ (production refactor compiles + SSR).
  • npm run lint ✓ (pre-commit, every commit).
  • Node __meta__ tests ✓ (no-factory-ban, no-duplicate-mock-ids, no-async-mock-factories).
  • svelte-check: no new errors from these changes.
  • The 5 previously-failing specs now use inline factories identical in shape to the suite's 2738 passing tests.

Decisions taken during implementation (all maintainer-confirmed)

  1. Pacing → one-shot, trust CI (browser-mode vitest not run locally).
  2. Phase 2a → context-inject real ConfirmService.
  3. Phase 2b → full context refactor of the notification store.
  4. Phase 1 → reverted as infeasible (CI-proven).

Follow-up (not in this PR): delete the dead feat/issue-560-vimock-migration remote branch from closed PR #657.

🤖 Generated with Claude Code

Closes #560. > **Update (Phase 1 reverted).** CI proved the issue's headline goal — sharing a virtual-module mock body across spec files — is **infeasible** in `@vitest/browser-playwright` 4.1.6. Both sanctioned forms fail: `vi.mock('$app/forms', () => ({ ...staticImport }))` fails the hoist (*"no top level variables"*), and `await vi.hoisted(() => import('$mocks/...'))` fails to parse (*"Unexpected identifier 'vi'"*). `vi.hoisted` shares `vi.mock`'s hoist constraint, so there's no way to thread an external body into the factory. **Phase 1 was reverted to inline factories** (the only working pattern); ADR-012 records the finding. This is the third false premise in the #657/#560 saga. ## What this PR delivers (CI-green path) **Groundwork** - `admin/tags/[id]/+page.svelte`: `$app/stores` → `$app/state`; its 2 `$app/stores` spec mocks replaced with sync-factory `$app/state` mocks (AC#3 ✅). - `@vitest/browser-playwright` exact-pinned to `4.1.6` (drop caret) so `patches/@vitest+browser-playwright+4.1.6.patch` keeps applying; TODO recorded via a top-level `"//"` key (AC#9 ✅). - `src/__meta__/no-factory-ban.test.ts` — Node-mode scan banning no-factory `vi.mock(virtualModule)` (AC#6 ✅, TDD red→green). Still valuable independent of Phase 1. - ADR-012 amended: records the no-factory ban **and** that cross-file mock-body sharing is infeasible here (inline sync factories are the only working pattern). **Phase 2 — fixtures / context (the substantive work)** - **Confirm (AC#4 ✅):** the 8 specs that mocked `confirm.svelte` now inject a real `createConfirmService()` via `render` context (mirroring `admin/tags/[id]/page.svelte.spec.ts`). Zero `confirm.svelte` `vi.mock`s remain. The generic `confirm.test-fixture.svelte` can't wrap a page, and none of the 8 trigger `confirm()`, so AC#4 is met as "real provided service, no `vi.mock`". - **Notification (AC#5 ✅):** full context refactor — `notifications.svelte` singleton → `createNotificationStore()` + `provideNotificationStore()`/`getNotificationStore()`/`NOTIFICATION_KEY`, provided once in root `+layout`. `NotificationBell` + the Chronik page read it via context. `notification.test-fixture.svelte` wraps the bell and exposes `setNotifications()` via `onReady`; `NotificationBell.svelte.spec.ts` asserts the announced unread count across **empty / single / many / error** states. `notifications.svelte.spec.ts` rewritten to drive a fresh factory per test. *(Maintainer-approved scope: adds production code beyond the issue's "test-infra only" sign-off.)* ## Reverted / not delivered - **AC#1, #2** (`$app/forms` / `$app/navigation` shared mocks) — infeasible, reverted to inline factories. - **AC#8** (`$mocks` alias) — removed (it only existed to serve the shared mocks). - **AC#10/#11** — full client suite + `coverage-flake-probe.yml` are CI-only. ## Verification - `npm run build` ✓ (production refactor compiles + SSR). - `npm run lint` ✓ (pre-commit, every commit). - Node `__meta__` tests ✓ (no-factory-ban, no-duplicate-mock-ids, no-async-mock-factories). - `svelte-check`: no new errors from these changes. - The 5 previously-failing specs now use inline factories identical in shape to the suite's 2738 passing tests. ## Decisions taken during implementation (all maintainer-confirmed) 1. Pacing → one-shot, trust CI (browser-mode vitest not run locally). 2. Phase 2a → context-inject real `ConfirmService`. 3. Phase 2b → full context refactor of the notification store. 4. Phase 1 → reverted as infeasible (CI-proven). Follow-up (not in this PR): delete the dead `feat/issue-560-vimock-migration` remote branch from closed PR #657. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
marcel added 14 commits 2026-06-02 20:28:54 +02:00
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>
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>
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>
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>
Records the 2026-06-02 revision from #560: (1) no-factory vi.mock of a
SvelteKit virtual module is forbidden (the PR #657 partial-mock failure),
guarded by a seventh enforcement layer; (2) shared mock body + per-spec
sync factory via the $mocks alias is the sanctioned dedup; (3) Option C
config-level auto-resolve is rejected. Also corrects the stale 4.1.0
patch filename to 4.1.6 and links #657. Part of #560.
The vite resolve.alias (added for the client + coverage runs) does not
reach svelte-check, which resolves paths through the generated tsconfig.
Declaring $mocks in kit.alias feeds both the generated tsconfig paths and
the sveltekit() vite plugin, so editor/type-check resolve it too. Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Single home for the non-trivial form-interceptor enhance() shared by the
four complex consumers: it intercepts submit, invokes the SubmitFunction,
and fires the returned callback with a configurable result. setFormResult()
drives the success/failure branch; an embedded beforeEach resets it before
every test so isolation is structural. Consumed via vi.mock('$app/forms',
() => ({ ...formsMock })) through the $mocks alias. Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Load-bearing first migration (ADR-012): this is the hardest case — its
enhance submit callback actually fires and reads the form result. Replaces
the duplicated 23-line interceptor factory with vi.mock('$app/forms',
() => ({ ...formsMock })) via $mocks, and the per-test mockFormResult
mutation with formsMock.setFormResult({ type: 'failure' }). The reset now
comes from the shared module's embedded beforeEach. The existing
optimisticMarkRead/optimisticMarkAllRead-on-submit assertions remain as the
positive proof the callback fired. Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes Phase 1a after the load-bearing ChronikFuerDichBox spec proved
the pattern. ChronikFuerDichBox.test and NotificationDropdown.test (rich
result-firing interceptors) keep their submit-fired assertions
(optimisticMarkRead/MarkAllRead) and use formsMock.setFormResult for the
failure branch. NotificationBell.spec used the simpler intercept-only
factory and renders no form of its own, so it adopts the shared superset
purely as a render-time stub. Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Exports the standard nav functions as vi.fn() and a beforeNavigate that
captures the registered callback. The exported simulateNavigate(href)
helper fires that callback and returns the cancel spy — the whole
capture-and-fire pattern lives in the shared module, not the raw callback.
An embedded beforeEach clears the captured callback and the mock call
histories before every test. Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replaces the local beforeNavigate-capture plumbing and simulateNavigate
helper with the shared $mocks/$app/navigation module via a sync factory.
The per-test reset now comes from the shared module's embedded beforeEach.
Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replaces the vi.mock('$lib/shared/services/confirm.svelte') stub with a
real createConfirmService() provided through render's context map, mirroring
the existing admin/tags/[id]/page.svelte.spec.ts pattern. The generic
confirm.test-fixture.svelte renders only ConfirmDialog and cannot wrap an
arbitrary page; none of these specs trigger confirm(), so the children's
getConfirmService() simply reads the provided context instead of a module
mock. No vi.mock of confirm.svelte remains in these 5 specs. Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes Phase 2a: geschichten/[id], persons/[id]/edit and admin/tags/[id]
page specs now provide a real createConfirmService() via render context
instead of mocking confirm.svelte. Zero confirm.svelte vi.mocks remain
across the client suite (AC#4). Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
refactor(notification): provide notification store via context + fixture
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 1m50s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m33s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
140b321868
Converts the module-singleton notificationStore into a context-provided
store so its specs can drive it without mocking the module. notifications.svelte
now exports createNotificationStore() (the former singleton body), plus
provideNotificationStore()/getNotificationStore()/NOTIFICATION_KEY mirroring
the confirm service. Root +layout provides it; NotificationBell and the
Chronik page read it via getNotificationStore().

Tests:
- notifications.svelte.spec drives a fresh createNotificationStore() per test
  (replacing __resetForTest/__setNavigateForTest with setNavigate()).
- notification.test-fixture.svelte wraps the bell, provides the store, and
  exposes setNotifications(items) via onReady (option b).
- NotificationBell.svelte.spec asserts the announced unread count across the
  empty / single / many / error a11y states (AC#5), stubbing EventSource+fetch.
- aktivitaeten page spec injects a real store via render context.

Per the recorded Phase-2b decision (full context refactor). Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author
Owner

🔒 Nora "NullX" Steiner — Security

LGTM — no auth/session/redirect regression. This is a clean singleton→factory refactor; the security-relevant code paths are byte-for-byte identical.

What I checked

1. The 401 → /login redirect path is unchanged.
The eventSource.onerror handler in createNotificationStore() is identical to the old top-level body — both branches (CLOSED readyState, and the errorCount >= 3 reconnect-failure branch) still call fetch('/api/notifications/unread-count') and navigate('/login') only on res.status === 401. No condition was loosened, no branch dropped. The store moving from module singleton to a per-tree context instance does not change when or whether the session-expiry redirect fires. The default navigate still hard-navigates via window.location.href, which correctly forces a full reload (clearing in-memory state) on session loss — good fail-closed behavior.

2. setNavigate() test seam is not a production risk.
It replaces the old module-level __setNavigateForTest. I grepped the whole frontend/src tree: the only non-test caller of setNavigate is notifications.svelte.spec.ts:55. No .svelte component, no route, no layout invokes it. Production code never reaches the seam, so the redirect target cannot be hijacked at runtime. Minor nit (non-blocking): it now sits on the public NotificationStore interface rather than being a clearly-quarantined __-prefixed export, so it's technically callable by any context consumer. The JSDoc /** Test-only */ mitigates this, and there's no injection vector (the store is provided once in root +layout, all consumers are first-party). I'd accept it as-is; if you want belt-and-suspenders, a __-prefix or a dev-only guard would make the test-only contract enforced rather than documented.

3. No data exposure / context-scoping concern.
provideNotificationStore() is called once in the root +layout.svelte; consumers (NotificationBell, aktivitaeten/+page.svelte) read via getNotificationStore(), which fails closed with a thrown error if the context is missing — no silent null-store fallback. Per-tree instancing actually reduces cross-session bleed risk vs. a module singleton (relevant in SSR, though this store is client-driven). Notification payloads are unchanged and still gated by the same /api/notifications* endpoints server-side.

4. Supply chain: the @vitest/browser-playwright exact-pin is benign and well-documented.
^4.0.10 → exact 4.1.6 (no caret) so patches/@vitest+browser-playwright+4.1.6.patch keeps applying. The patch content is unchanged in this PR. Pinning a dev-only test dependency to an exact version is a positive supply-chain hygiene move — it removes the floating-range window where a malicious/broken minor could slip in, and the inline package.json comment + ADR-012 reference document the TODO to unpin once vitest PR #10267 ships. Dev-only (devDependencies), so zero production attack surface. The accompanying vi.mock synchronous-factory rule (ADR-012) is a test-correctness constraint, not a security control.

Blockers

None.

Suggestions

  • (Optional, non-blocking) Consider __-prefixing setNavigate / setNotifications or gating them behind a dev-mode check so the test-only contract is enforced by the type surface rather than only by JSDoc convention.

Verdict: ship it. No injection, no auth bypass, no IDOR, no session-handling regression, no risky dependency drift.

## 🔒 Nora "NullX" Steiner — Security ✅ **LGTM** — no auth/session/redirect regression. This is a clean singleton→factory refactor; the security-relevant code paths are byte-for-byte identical. ### What I checked **1. The 401 → `/login` redirect path is unchanged.** The `eventSource.onerror` handler in `createNotificationStore()` is identical to the old top-level body — both branches (CLOSED readyState, and the `errorCount >= 3` reconnect-failure branch) still call `fetch('/api/notifications/unread-count')` and `navigate('/login')` only on `res.status === 401`. No condition was loosened, no branch dropped. The store moving from module singleton to a per-tree context instance does not change *when* or *whether* the session-expiry redirect fires. The default `navigate` still hard-navigates via `window.location.href`, which correctly forces a full reload (clearing in-memory state) on session loss — good fail-closed behavior. **2. `setNavigate()` test seam is not a production risk.** It replaces the old module-level `__setNavigateForTest`. I grepped the whole `frontend/src` tree: the only non-test caller of `setNavigate` is `notifications.svelte.spec.ts:55`. No `.svelte` component, no route, no layout invokes it. Production code never reaches the seam, so the redirect target cannot be hijacked at runtime. Minor nit (non-blocking): it now sits on the public `NotificationStore` interface rather than being a clearly-quarantined `__`-prefixed export, so it's technically callable by any context consumer. The JSDoc `/** Test-only */` mitigates this, and there's no injection vector (the store is provided once in root `+layout`, all consumers are first-party). I'd accept it as-is; if you want belt-and-suspenders, a `__`-prefix or a dev-only guard would make the test-only contract enforced rather than documented. **3. No data exposure / context-scoping concern.** `provideNotificationStore()` is called once in the root `+layout.svelte`; consumers (`NotificationBell`, `aktivitaeten/+page.svelte`) read via `getNotificationStore()`, which fails closed with a thrown error if the context is missing — no silent null-store fallback. Per-tree instancing actually *reduces* cross-session bleed risk vs. a module singleton (relevant in SSR, though this store is client-driven). Notification payloads are unchanged and still gated by the same `/api/notifications*` endpoints server-side. **4. Supply chain: the `@vitest/browser-playwright` exact-pin is benign and well-documented.** `^4.0.10` → exact `4.1.6` (no caret) so `patches/@vitest+browser-playwright+4.1.6.patch` keeps applying. The patch content is unchanged in this PR. Pinning a dev-only test dependency to an exact version is a *positive* supply-chain hygiene move — it removes the floating-range window where a malicious/broken minor could slip in, and the inline `package.json` comment + ADR-012 reference document the TODO to unpin once vitest PR #10267 ships. Dev-only (`devDependencies`), so zero production attack surface. The accompanying `vi.mock` synchronous-factory rule (ADR-012) is a test-correctness constraint, not a security control. ### Blockers None. ### Suggestions - (Optional, non-blocking) Consider `__`-prefixing `setNavigate` / `setNotifications` or gating them behind a dev-mode check so the test-only contract is enforced by the type surface rather than only by JSDoc convention. Verdict: ship it. No injection, no auth bypass, no IDOR, no session-handling regression, no risky dependency drift.
Author
Owner

🏛️ Markus Keller (@mkeller) — Architect

⚠️ Approved with concerns — the context-provider design is sound and the SSE refcount semantics survive the refactor, but the production NotificationStore interface carries two test-only methods, which the very same PR proves is avoidable.

I reviewed the module/layer boundaries, the context-provider lifecycle, the $mocks alias topology, and ADR-012 accuracy. The mock-dedup infra itself is clean and well-fenced; my comments are almost entirely about the notification refactor that landed alongside it.


What I checked and like

  • Context-provider design is correct. provideNotificationStore() runs once in the root +layout.svelte; NotificationBell and the Chronik page (aktivitaeten/+page.svelte) both read via getNotificationStore(). The singleton-in-module-scope was an implicit global; moving it onto Svelte context makes the dependency explicit and per-tree, which is the right boundary. This also mirrors the existing provideConfirmService() pattern already in the same layout — consistency wins.
  • SSE refcount semantics are preserved. refCount/errorCount/eventSource are now per-store closure state instead of module globals, but both consumers still pair init() on mount with destroy() on destroy (NotificationBell.svelte onMount/onDestroy; aktivitaeten/+page.svelte lines 37–42). The refCount > 1 guard and the refCount === 0 teardown are unchanged. With a single store instance provided at root and N consumers calling init/destroy, the refcount still collapses to one EventSource and tears it down only when the last consumer unmounts. No regression in the lifecycle — verified the balance on both call sites.
  • $mocks alias declared in 3 places is the right number, not one too many. Each declaration serves a distinct resolver: svelte.config.js kit.alias (generates the paths entry in .svelte-kit/tsconfig.json so tsc/svelte-check resolve it — confirmed it's emitted), vite.config.ts resolve.alias (main browser-test runtime), and vitest.client-coverage.config.ts resolve.alias (the coverage job, which is a standalone config that does not extend the base). Collapsing these would require the coverage config to import the base, which it deliberately doesn't. ADR-012 only documents "both vite configs" and omits the svelte.config.js one — see the ADR nit below.

Blockers

None. The boundaries are intact and the docs largely track the code.


Suggestions (nice-to-have)

1. setNotifications/setNavigate on the production NotificationStore interface — the same PR shows the clean alternative.
notifications.svelte.ts:17-20 puts two /** Test-only */ methods on the shipped interface. This is a leaked seam: production code can now mutate the store's internal state and reroute its 401 redirect, and nothing at the type level stops it. The counter-example is in this very diff — the confirm-mock migration (e.g. admin/tags/[id]/page.svelte.test.ts) injects the real createConfirmService() through context with zero test-only methods on ConfirmService (verified: it exposes only confirm()). The notification specs already added notification.test-fixture.svelte calling provideNotificationStore() — that fixture is the proper seam. I'd push the two setters off the interface and have the fixture/specs reach the seed-and-navigate behaviour another way (a test-only subtype, or threading an injectable fetch/navigate as a createNotificationStore(opts) param, which is also how ADR-012's own libLoader pattern argues for prop injection over post-hoc setters). I'm not blocking because the SSE/EventSource surface genuinely is harder to fake than ConfirmService — but the asymmetry within one PR is exactly the kind of thing a future reader will cargo-cult the wrong direction.

2. getNotificationStore() try/catch around getContext is dead defence. notifications.svelte.ts:148-160 wraps getContext in try/catch and then also null-checks, throwing the identical message from both paths. getContext doesn't throw when a key is absent — it returns undefined. The catch arm is unreachable; collapse to the null-check alone. Minor, but duplicated error strings rot independently.

3. ADR-012 §"Sanctioned dedup" undercounts the alias. Line 157 states the $mocks alias "is declared in both vite.config.ts and vitest.client-coverage.config.ts." It's actually three places — svelte.config.js kit.alias is load-bearing for type resolution and is the one a contributor is most likely to forget when adding a new resolver. ADRs are the codebase's memory; this one should name all three and say why each exists (TS resolution / test runtime / coverage runtime). One-line fix, but it's the difference between the ADR preventing the next "why doesn't $mocks resolve in svelte-check" hour-long detour or not.

4. ADR-012 scope note. The ADR is titled and framed as a test-mocking strategy, but this PR's largest blast radius is a production refactor (module singleton → context provider for notifications). That's a deliberate maintainer call and I support the direction — but the ADR doesn't mention it at all. Either add a short paragraph ("the notification store was migrated to the same context-provider shape as ConfirmService to make it injectable without test-only setters") or drop a one-liner in the PR description pointing at where that decision is recorded. Right now the architectural decision with the most lasting consequence in this PR has no ADR footprint.

None of these block merge. Fix #1 and #3 before the pattern propagates; #2 and #4 are housekeeping.

## 🏛️ Markus Keller (@mkeller) — Architect ⚠️ **Approved with concerns** — the context-provider design is sound and the SSE refcount semantics survive the refactor, but the production `NotificationStore` interface carries two test-only methods, which the very same PR proves is avoidable. I reviewed the module/layer boundaries, the context-provider lifecycle, the `$mocks` alias topology, and ADR-012 accuracy. The mock-dedup infra itself is clean and well-fenced; my comments are almost entirely about the notification refactor that landed alongside it. --- ### What I checked and like - **Context-provider design is correct.** `provideNotificationStore()` runs once in the root `+layout.svelte`; `NotificationBell` and the Chronik page (`aktivitaeten/+page.svelte`) both read via `getNotificationStore()`. The singleton-in-module-scope was an implicit global; moving it onto Svelte context makes the dependency explicit and per-tree, which is the right boundary. This also mirrors the existing `provideConfirmService()` pattern already in the same layout — consistency wins. - **SSE refcount semantics are preserved.** `refCount`/`errorCount`/`eventSource` are now per-store closure state instead of module globals, but both consumers still pair `init()` on mount with `destroy()` on destroy (`NotificationBell.svelte` onMount/onDestroy; `aktivitaeten/+page.svelte` lines 37–42). The `refCount > 1` guard and the `refCount === 0` teardown are unchanged. With a single store instance provided at root and N consumers calling init/destroy, the refcount still collapses to one EventSource and tears it down only when the last consumer unmounts. No regression in the lifecycle — verified the balance on both call sites. - **`$mocks` alias declared in 3 places is the right number, not one too many.** Each declaration serves a distinct resolver: `svelte.config.js` `kit.alias` (generates the `paths` entry in `.svelte-kit/tsconfig.json` so `tsc`/svelte-check resolve it — confirmed it's emitted), `vite.config.ts` `resolve.alias` (main browser-test runtime), and `vitest.client-coverage.config.ts` `resolve.alias` (the coverage job, which is a standalone config that does not extend the base). Collapsing these would require the coverage config to import the base, which it deliberately doesn't. ADR-012 only documents "both vite configs" and omits the svelte.config.js one — see the ADR nit below. --- ### Blockers None. The boundaries are intact and the docs largely track the code. --- ### Suggestions (nice-to-have) **1. `setNotifications`/`setNavigate` on the production `NotificationStore` interface — the same PR shows the clean alternative.** `notifications.svelte.ts:17-20` puts two `/** Test-only */` methods on the shipped interface. This is a leaked seam: production code can now mutate the store's internal state and reroute its 401 redirect, and nothing at the type level stops it. The counter-example is *in this very diff* — the confirm-mock migration (e.g. `admin/tags/[id]/page.svelte.test.ts`) injects the **real** `createConfirmService()` through context with **zero** test-only methods on `ConfirmService` (verified: it exposes only `confirm()`). The notification specs already added `notification.test-fixture.svelte` calling `provideNotificationStore()` — that fixture is the proper seam. I'd push the two setters off the interface and have the fixture/specs reach the seed-and-navigate behaviour another way (a test-only subtype, or threading an injectable `fetch`/`navigate` as a `createNotificationStore(opts)` param, which is also how `ADR-012`'s own libLoader pattern argues for prop injection over post-hoc setters). I'm not blocking because the SSE/EventSource surface genuinely is harder to fake than ConfirmService — but the asymmetry within one PR is exactly the kind of thing a future reader will cargo-cult the wrong direction. **2. `getNotificationStore()` try/catch around `getContext` is dead defence.** `notifications.svelte.ts:148-160` wraps `getContext` in try/catch and then *also* null-checks, throwing the identical message from both paths. `getContext` doesn't throw when a key is absent — it returns `undefined`. The catch arm is unreachable; collapse to the null-check alone. Minor, but duplicated error strings rot independently. **3. ADR-012 §"Sanctioned dedup" undercounts the alias.** Line 157 states the `$mocks` alias "is declared in **both** `vite.config.ts` and `vitest.client-coverage.config.ts`." It's actually three places — `svelte.config.js` `kit.alias` is load-bearing for type resolution and is the one a contributor is most likely to forget when adding a new resolver. ADRs are the codebase's memory; this one should name all three and say *why each exists* (TS resolution / test runtime / coverage runtime). One-line fix, but it's the difference between the ADR preventing the next "why doesn't `$mocks` resolve in svelte-check" hour-long detour or not. **4. ADR-012 scope note.** The ADR is titled and framed as a *test-mocking* strategy, but this PR's largest blast radius is a production refactor (module singleton → context provider for notifications). That's a deliberate maintainer call and I support the direction — but the ADR doesn't mention it at all. Either add a short paragraph ("the notification store was migrated to the same context-provider shape as ConfirmService to make it injectable without test-only setters") or drop a one-liner in the PR description pointing at where that decision is recorded. Right now the architectural decision with the most lasting consequence in this PR has no ADR footprint. None of these block merge. Fix #1 and #3 before the pattern propagates; #2 and #4 are housekeeping.
Author
Owner

🎨 Leonie Voss — UX / Accessibility Review

⚠️ Solid a11y assertions, but the "single" case has a tautology gap and the error state deserves a second look.

This PR is overwhelmingly test-infra. No visual or layout changes reach production DOM, and the __mocks__/fixture files never render, so they're a11y-neutral. I focused on the new NotificationBell a11y assertions (AC#5) and the two production touches.

What I checked

  • NotificationBell.svelte production change — pure notificationStoregetNotificationStore() import swap. The live region is unchanged and well-built: persistent <span aria-live="polite" aria-atomic="true"> that stays mounted and just toggles .hidden, so SR live-region history is preserved across count changes. title and aria-label are both bound to the same bellLabel derived — good, consistent announcement. No regression here.
  • admin/tags/[id] $app/stores$app/state$page.url.pathnamepage.url.pathname only, inside a non-rendering replaceState effect. No a11y surface.
  • aktivitaeten/+page.svelte — import + comment churn only; the aria-live="polite" / aria-busy feed region is untouched.
  • The spec correctly asserts title === aria-label in all four states and verifies the badge .hidden toggle + exact count text. That's the right shape.

Blockers

None. Nothing here blocks merge.

Suggestions

  1. The "single" test is tautological and hides a real grammar bug (High). The spec asserts:

    expect(btn.getAttribute('title')).toBe(m.notification_bell_unread_label({ count: 1 }));
    

    This compares the rendered title against the same message function with the same arg — it can never fail regardless of grammar, so it does not actually verify a correct singular announcement. And there is a defect to catch: notification_bell_unread_label is a flat interpolation with no Paraglide plural matcher, so count: 1 renders "1 unread notifications" / "1 ungelesene Benachrichtigungen" / "1 notificaciones sin leer" — plural noun for a single item. Screen-reader users hear ungrammatical output, and it reads as broken to the senior audience.
    Fix (two parts): (a) give the message a plural form, e.g. Paraglide match: {count, plural, one {1 unread notification} other {# unread notifications}} across de/en/es.json; (b) assert the literal expected string in the test (toBe('1 unread notification')) instead of round-tripping the message fn, so the singular form is genuinely pinned.

  2. The "error" state announces a silent recovery, not the failure — confirm that's intended (Medium). On a failed unread-count load the bell degrades to count 0 and hides the badge (aria-live announces nothing). For this widget that's defensible: a notification count is non-critical, and shouting "couldn't load notifications" on every transient SSE/fetch blip via a live region would be noise — aria-live="polite" on a count badge is the wrong channel for an error anyway. So the behavior is fine, but the test comment ("announces a valid, non-urgent state") slightly oversells it — nothing is announced; the badge is simply absent. If product ever wants the failure surfaced, it belongs in the dropdown body (a role="status" empty/error line on open), not the bell badge. No change required now; just naming the boundary so it's a conscious choice.

  3. Unverified a11y gap: aria-expanded / focus-return on the bell (Low). AC#5 covers the announced count, but the bell's other a11y contract — aria-expanded toggling with open, focus returning to the bell button on Escape/close — isn't asserted in the new spec. Not in scope for this PR, but worth a follow-up test so the keyboard/SR open-close loop is regression-guarded alongside the count.

Net: the assertions test the right surface, but suggestion 1 is worth folding in before merge — the singular case currently green-lights a real grammatical defect.

## 🎨 Leonie Voss — UX / Accessibility Review ⚠️ **Solid a11y assertions, but the "single" case has a tautology gap and the error state deserves a second look.** This PR is overwhelmingly test-infra. No visual or layout changes reach production DOM, and the `__mocks__`/fixture files never render, so they're a11y-neutral. I focused on the new `NotificationBell` a11y assertions (AC#5) and the two production touches. ### What I checked - **`NotificationBell.svelte` production change** — pure `notificationStore` → `getNotificationStore()` import swap. The live region is unchanged and well-built: persistent `<span aria-live="polite" aria-atomic="true">` that stays mounted and just toggles `.hidden`, so SR live-region history is preserved across count changes. `title` and `aria-label` are both bound to the same `bellLabel` derived — good, consistent announcement. No regression here. ✅ - **`admin/tags/[id]` `$app/stores` → `$app/state`** — `$page.url.pathname` → `page.url.pathname` only, inside a non-rendering `replaceState` effect. No a11y surface. ✅ - **`aktivitaeten/+page.svelte`** — import + comment churn only; the `aria-live="polite"` / `aria-busy` feed region is untouched. ✅ - The spec correctly asserts `title === aria-label` in all four states and verifies the badge `.hidden` toggle + exact count text. That's the right shape. ### Blockers None. Nothing here blocks merge. ### Suggestions 1. **The "single" test is tautological and hides a real grammar bug (High).** The spec asserts: ```ts expect(btn.getAttribute('title')).toBe(m.notification_bell_unread_label({ count: 1 })); ``` This compares the rendered title against the *same* message function with the *same* arg — it can never fail regardless of grammar, so it does **not** actually verify a correct singular announcement. And there *is* a defect to catch: `notification_bell_unread_label` is a flat interpolation with no Paraglide plural matcher, so `count: 1` renders **"1 unread notifications"** / **"1 ungelesene Benachrichtigungen"** / **"1 notificaciones sin leer"** — plural noun for a single item. Screen-reader users hear ungrammatical output, and it reads as broken to the senior audience. *Fix (two parts):* (a) give the message a plural form, e.g. Paraglide match: `{count, plural, one {1 unread notification} other {# unread notifications}}` across `de/en/es.json`; (b) assert the *literal expected string* in the test (`toBe('1 unread notification')`) instead of round-tripping the message fn, so the singular form is genuinely pinned. 2. **The "error" state announces a silent recovery, not the failure — confirm that's intended (Medium).** On a failed unread-count load the bell degrades to count 0 and hides the badge (`aria-live` announces nothing). For *this* widget that's defensible: a notification count is non-critical, and shouting "couldn't load notifications" on every transient SSE/fetch blip via a live region would be noise — `aria-live="polite"` on a count badge is the wrong channel for an error anyway. So the behavior is fine, but the test comment ("announces a valid, non-urgent state") slightly oversells it — nothing is *announced*; the badge is simply absent. If product ever wants the failure surfaced, it belongs in the dropdown body (a `role="status"` empty/error line on open), not the bell badge. No change required now; just naming the boundary so it's a conscious choice. 3. **Unverified a11y gap: `aria-expanded` / focus-return on the bell (Low).** AC#5 covers the announced count, but the bell's other a11y contract — `aria-expanded` toggling with `open`, focus returning to the bell button on Escape/close — isn't asserted in the new spec. Not in scope for this PR, but worth a follow-up test so the keyboard/SR open-close loop is regression-guarded alongside the count. Net: the assertions test the *right surface*, but suggestion 1 is worth folding in before merge — the singular case currently green-lights a real grammatical defect.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

⚠️ Approved with concerns — clean, well-factored dedup with real TDD evidence (the no-factory ban is genuine red→green). The shared-mock module design is exactly the right altitude. Two things I want addressed before merge, and a handful of suggestions.

Blockers

1. The no-factory-ban regex has a false-negative gap it advertises as a guarantee.
frontend/src/__meta__/no-factory-ban.test.ts:35NO_FACTORY_VI_MOCK = /vi\.mock\(\s*['"][^'"]+['"]\s*\)/. The whole point of this meta-test is to foreclose the PR #657 class. But it only matches when the closing paren immediately follows the string. A trailing-comma no-factory call — vi.mock('$app/navigation', ) — slips straight through, and so does vi.mock('$app/navigation' /* note */). Either is a one-keystroke regression that reintroduces the exact partial-mock crash the ADR documents. Add a red test for the trailing-comma form first (it should currently pass = prove the hole), then widen the pattern to treat "string arg followed by ) with only whitespace/comma/comment between" as an offender. The persona rule here: a guard test that can be trivially bypassed gives false confidence, which is worse than no guard.

Suggestions

2. The NotificationBell a11y "error" state test does not test what its name claims — and the green is partly accidental.
NotificationBell.svelte.spec.ts:123"degrades to a zero announced count when the unread-count load fails". In the success tests, await tick() (a setTimeout(0) macrotask) flushes init()'s fetchUnreadCount microtask chain (await fetchawait res.json()unreadCount = 0) before setNotifications(...) runs, so the ordering is race-free for those — good. But the error test asserts title === notification_bell_label() and the badge is hidden, which is the exact same assertion as the empty state (line 82). The count is 0 because the store initializes to 0, not because the rejected fetch was observed — fetchUnreadCount's catch just logs and leaves unreadCount untouched. So this test would still pass if the error path were deleted entirely. It is not modelling "error" as a distinct a11y state; it is re-asserting the initial state with a thrown fetch that nothing waits on. Either (a) drive a real non-zero count first, then trigger an error and assert it degrades to a meaningful state, or (b) be honest and rename it — "error" as graceful-degradation-to-0 is indistinguishable from "empty" and the test name oversells it.

3. Context-map boilerplate is now copy-pasted across ~17 spec files.
context: new Map([[CONFIRM_KEY, createConfirmService()]]) repeats on nearly every render(...) call in the migrated confirm specs (e.g. admin/users/[id]/page.svelte.test.ts, 8+ times in one file). This PR's whole thesis is "extract the duplicated test setup into a shared helper" — yet the confirm migration introduces a new duplication of the same shape it's removing for $app/forms. A one-liner renderWithConfirm(Component, props) (or a shared confirmContext() returning the Map) would apply the same DRY discipline you applied to the mock bodies. Not a blocker, but it's inconsistent to dedup one pattern while hand-rolling another in the same changeset.

4. getNotificationStore() swallows the real error in its catch.
notifications.svelte.ts:147-160getContext is wrapped in try/catch that discards the caught value and throws a hardcoded message, and then there's a second identical throw for the !store case. Two copies of the same error string. getContext doesn't throw when a key is absent (it returns undefined), so the try/catch is dead defensive scaffolding — the if (!store) branch is the one that actually fires. Drop the try/catch, keep the single if (!store) throw. Less code, one source of truth for the message.

What I liked

  • ADR-012 revision is exemplary — it records why Option C was rejected and pins the patch version with a TODO-to-remove. That's the "comments explain why" rule applied to architecture.
  • createNotificationStore() + provide/get is the correct Svelte 5 context idiom; killing the module-level singleton removes cross-test state bleed at the root. The setNavigate seam for the 401 side-effect is a clean test-injection point rather than a global stub.
  • simulateNavigate in the shared nav mock is genuinely consumed (useUnsavedWarning.svelte.test.ts) — no dead helper shipped.
  • Real TDD on the meta-test: the hasNoFactoryViMock scan has its own positive/negative unit cases before the file-glob assertion. That's the discipline I want to see.
## 👨‍💻 Felix Brandt — Senior Fullstack Developer ⚠️ **Approved with concerns** — clean, well-factored dedup with real TDD evidence (the no-factory ban is genuine red→green). The shared-mock module design is exactly the right altitude. Two things I want addressed before merge, and a handful of suggestions. ### Blockers **1. The `no-factory-ban` regex has a false-negative gap it advertises as a guarantee.** `frontend/src/__meta__/no-factory-ban.test.ts:35` — `NO_FACTORY_VI_MOCK = /vi\.mock\(\s*['"][^'"]+['"]\s*\)/`. The whole point of this meta-test is to foreclose the PR #657 class. But it only matches when the closing paren immediately follows the string. A trailing-comma no-factory call — `vi.mock('$app/navigation', )` — slips straight through, and so does `vi.mock('$app/navigation' /* note */)`. Either is a one-keystroke regression that reintroduces the exact partial-mock crash the ADR documents. Add a red test for the trailing-comma form first (it should currently pass = prove the hole), then widen the pattern to treat "string arg followed by `)` with only whitespace/comma/comment between" as an offender. The persona rule here: a guard test that can be trivially bypassed gives false confidence, which is worse than no guard. ### Suggestions **2. The NotificationBell a11y "error" state test does not test what its name claims — and the green is partly accidental.** `NotificationBell.svelte.spec.ts:123` — *"degrades to a zero announced count when the unread-count load fails"*. In the success tests, `await tick()` (a `setTimeout(0)` macrotask) flushes `init()`'s `fetchUnreadCount` microtask chain (`await fetch` → `await res.json()` → `unreadCount = 0`) **before** `setNotifications(...)` runs, so the ordering is race-free for those — good. But the error test asserts `title === notification_bell_label()` and the badge is `hidden`, which is the *exact same assertion as the empty state* (line 82). The count is 0 because the store **initializes** to 0, not because the rejected fetch was observed — `fetchUnreadCount`'s `catch` just logs and leaves `unreadCount` untouched. So this test would still pass if the error path were deleted entirely. It is not modelling "error" as a distinct a11y state; it is re-asserting the initial state with a thrown fetch that nothing waits on. Either (a) drive a real non-zero count first, *then* trigger an error and assert it degrades to a meaningful state, or (b) be honest and rename it — "error" as graceful-degradation-to-0 is indistinguishable from "empty" and the test name oversells it. **3. Context-map boilerplate is now copy-pasted across ~17 spec files.** `context: new Map([[CONFIRM_KEY, createConfirmService()]])` repeats on nearly every `render(...)` call in the migrated confirm specs (e.g. `admin/users/[id]/page.svelte.test.ts`, 8+ times in one file). This PR's whole thesis is "extract the duplicated test setup into a shared helper" — yet the confirm migration introduces a *new* duplication of the same shape it's removing for `$app/forms`. A one-liner `renderWithConfirm(Component, props)` (or a shared `confirmContext()` returning the Map) would apply the same DRY discipline you applied to the mock bodies. Not a blocker, but it's inconsistent to dedup one pattern while hand-rolling another in the same changeset. **4. `getNotificationStore()` swallows the real error in its catch.** `notifications.svelte.ts:147-160` — `getContext` is wrapped in `try/catch` that discards the caught value and throws a hardcoded message, and then there's a *second* identical throw for the `!store` case. Two copies of the same error string. `getContext` doesn't throw when a key is absent (it returns `undefined`), so the `try/catch` is dead defensive scaffolding — the `if (!store)` branch is the one that actually fires. Drop the try/catch, keep the single `if (!store) throw`. Less code, one source of truth for the message. ### What I liked - ADR-012 revision is exemplary — it records *why* Option C was rejected and pins the patch version with a TODO-to-remove. That's the "comments explain why" rule applied to architecture. - `createNotificationStore()` + `provide`/`get` is the correct Svelte 5 context idiom; killing the module-level singleton removes cross-test state bleed at the root. The `setNavigate` seam for the 401 side-effect is a clean test-injection point rather than a global stub. - `simulateNavigate` in the shared nav mock is genuinely consumed (`useUnsavedWarning.svelte.test.ts`) — no dead helper shipped. - Real TDD on the meta-test: the `hasNoFactoryViMock` scan has its own positive/negative unit cases before the file-glob assertion. That's the discipline I want to see.
marcel added 1 commit 2026-06-03 07:52:58 +02:00
fix(test): load shared mocks via vi.hoisted, not a static import
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m39s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 3m37s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m3s
f13999d006
CI caught that vi.mock('$app/forms', () => ({ ...formsMock })) with a
static `import * as formsMock` fails: vitest hoists vi.mock above the
import, so the factory references an uninitialised binding
("no top level variables inside"). Load the shared mock module via
`const formsMock = await vi.hoisted(() => import('$mocks/...'))` instead —
the factory may reference a vi.hoisted binding, and the dynamic import runs
at collection time (not in the lazily-invoked factory), so it stays clear
of ADR-012's birpc race and the no-async-mock-factories guard. Applies to
all 5 shared-mock consumers ($app/forms x4, $app/navigation x1). Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
marcel added 1 commit 2026-06-03 08:21:21 +02:00
revert(test): abandon shared-mock dedup — infeasible in vitest browser mode
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m42s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m31s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
cdfcb02903
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>
Author
Owner

⚙️ Reproducible, the pin is wired correctly — DevOps

Verdict: — The exact-pin is consistent end to end and CI will reliably re-apply the patch. The Phase-1 alias revert left the configs clean. Ship it.

I reviewed this purely from the "will builds be reproducible and will CI apply the patch" angle, since that's the only DevOps-relevant change that survived the revert.

What's correct (the load-bearing stuff)

Version triple is consistent — patch will always apply.

  • frontend/package.json:51"@vitest/browser-playwright": "4.1.6" (caret dropped, exact pin)
  • frontend/package-lock.json:4787 → resolved "version": "4.1.6" with a real integrity hash
  • frontend/patches/@vitest+browser-playwright+4.1.6.patch → filename target matches

patch-package matches a patch file to the installed version by filename. Before this PR, a caret range meant a 4.1.7+ release would silently install, the 4.1.6.patch would no longer match, patch-package would skip it (warn, not fail), and the duplicate-mock-id birpc race would resurface in CI with no hard signal. The exact pin closes that gap. This is the right fix.

npm ci applies the patch in CI. patch-package runs via "postinstall": "patch-package" (package.json:13), and npm fires postinstall on npm ci. Both consumers — .gitea/workflows/ci.yml:29 and .gitea/workflows/coverage-flake-probe.yml:33 — use npm ci, and patch-package is a devDependency (package.json:61), so it's present in a fresh install. Belt-and-suspenders: ci.yml:148-154 greps the coverage log for [birpc] rpc is closed and fails the job if the race reappears — i.e. CI actively proves the patch is effective, not just that it applied. Nice.

Configs are clean after the alias revert. No $mocks alias and no dangling fileURLToPath import in vite.config.ts, vitest.client-coverage.config.ts, or svelte.config.js. The remaining **/__mocks__/** entries are just coverage exclude globs — a glob matching a now-nonexistent dir is a harmless no-op, not a leftover of the reverted alias.

The "//" comment key is safe. npm treats any key it doesn't recognize as inert, and "//"(-prefixed) keys are the documented convention for package.json comments. "//@vitest/browser-playwright" is read by nobody and serializes fine. It correctly records the unpin TODO + the ADR pointer.

Blockers

None.

Suggestions (non-blocking)

  1. Renovate will fight this pin. An exact pin is the correct tactical choice, but if Renovate (or Dependabot) is active on this repo, it'll keep opening bump PRs for @vitest/browser-playwright. Consider a packageRules entry pinning/ignoring this package until PR #10267 ships upstream, so the TODO doesn't get auto-bumped out from under the patch. The "//" note helps a human, but automation won't read it.
  2. Make the patch a hard gate, not a warning. patch-package exits 0 with a warning when a patch fails to match (e.g. a future accidental bump). Adding --error-on-fail to the postinstall ("postinstall": "patch-package --error-on-fail") turns a silently-unapplied security/correctness patch into a failed npm ci in CI — fail loud, fail early. Low effort, removes a whole class of silent drift.

Neither blocks merge.

## ⚙️ Reproducible, the pin is wired correctly — DevOps **Verdict: ✅** — The exact-pin is consistent end to end and CI will reliably re-apply the patch. The Phase-1 alias revert left the configs clean. Ship it. I reviewed this purely from the "will builds be reproducible and will CI apply the patch" angle, since that's the only DevOps-relevant change that survived the revert. ### What's correct (the load-bearing stuff) **Version triple is consistent — patch will always apply.** - `frontend/package.json:51` → `"@vitest/browser-playwright": "4.1.6"` (caret dropped, exact pin) - `frontend/package-lock.json:4787` → resolved `"version": "4.1.6"` with a real integrity hash - `frontend/patches/@vitest+browser-playwright+4.1.6.patch` → filename target matches patch-package matches a patch file to the *installed* version by filename. Before this PR, a caret range meant a 4.1.7+ release would silently install, the `4.1.6.patch` would no longer match, patch-package would skip it (warn, not fail), and the duplicate-mock-id birpc race would resurface in CI with no hard signal. The exact pin closes that gap. This is the right fix. **`npm ci` applies the patch in CI.** `patch-package` runs via `"postinstall": "patch-package"` (`package.json:13`), and npm fires `postinstall` on `npm ci`. Both consumers — `.gitea/workflows/ci.yml:29` and `.gitea/workflows/coverage-flake-probe.yml:33` — use `npm ci`, and `patch-package` is a devDependency (`package.json:61`), so it's present in a fresh install. Belt-and-suspenders: `ci.yml:148-154` greps the coverage log for `[birpc] rpc is closed` and fails the job if the race reappears — i.e. CI actively proves the patch is effective, not just that it applied. Nice. **Configs are clean after the alias revert.** No `$mocks` alias and no dangling `fileURLToPath` import in `vite.config.ts`, `vitest.client-coverage.config.ts`, or `svelte.config.js`. The remaining `**/__mocks__/**` entries are just coverage `exclude` globs — a glob matching a now-nonexistent dir is a harmless no-op, not a leftover of the reverted alias. **The `"//"` comment key is safe.** npm treats any key it doesn't recognize as inert, and `"//"`(-prefixed) keys are the documented convention for package.json comments. `"//@vitest/browser-playwright"` is read by nobody and serializes fine. It correctly records the unpin TODO + the ADR pointer. ### Blockers None. ### Suggestions (non-blocking) 1. **Renovate will fight this pin.** An exact pin is the correct tactical choice, but if Renovate (or Dependabot) is active on this repo, it'll keep opening bump PRs for `@vitest/browser-playwright`. Consider a `packageRules` entry pinning/ignoring this package until PR #10267 ships upstream, so the TODO doesn't get auto-bumped out from under the patch. The `"//"` note helps a human, but automation won't read it. 2. **Make the patch a hard gate, not a warning.** patch-package exits 0 with a warning when a patch fails to match (e.g. a future accidental bump). Adding `--error-on-fail` to the postinstall (`"postinstall": "patch-package --error-on-fail"`) turns a silently-unapplied security/correctness patch into a failed `npm ci` in CI — fail loud, fail early. Low effort, removes a whole class of silent drift. Neither blocks merge.
Author
Owner

📋 PR #719 — Requirements Engineer

Verdict: Approve. The residual scope is coherent, the AC#1/#2/#8 reversal is CI-proven and properly recorded (ADR-012 revision + PR body), and the two substantive pieces (confirm injection, notification context refactor) are fully traceable to the issue's intent. Delivered ≈ promised once the four maintainer-confirmed decisions are accepted as the governing contract.

I assessed this as a brownfield requirements audit: did each acceptance criterion get Met / Reinterpreted / Reverted-infeasible / Deferred, is each deviation justified and recorded, and is what ships worth shipping.

AC-by-AC traceability

AC Intent Status Evidence
#1 __mocks__/$app/forms.ts shared interceptor + setFormResult + $mocks import 🔁 Reverted-infeasible CI proved both sanctioned forms (static-import spread; async vi.hoisted import) fail in @vitest/browser-playwright 4.1.6. Recorded in ADR-012 rev 2026-06-02 ("third false premise") + PR body. Specs keep inline factories. Justified.
#2 __mocks__/$app/navigation.ts + simulateNavigate via $mocks 🔁 Reverted-infeasible Same root cause; same record. No __mocks__/ dir exists, no $mocks ref remains (verified). Justified.
#3 admin/tags/[id]/+page.svelte$app/state; 2 $app/stores mocks gone Met +page.svelte swapped; zero $app/stores refs under admin/tags/ (verified); the 2 spec mocks now sync-factory $app/state.
#4 confirm.test-fixture.svelte used by every ex-confirm.svelte mocker; no vi.mock left ⚖️ Reinterpreted (defensible) 8 specs now inject a real createConfirmService() via render context: new Map([[CONFIRM_KEY, …]]). Zero confirm.svelte vi.mocks remain (verified). The fixture can't wrap a page and none of the 8 call confirm(), so "real provided service, no vi.mock" satisfies the AC's intent (kill the mock, use the real seam). Defensible and arguably cleaner than the fixture.
#5 notification.test-fixture.svelte (option-b setNotifications); both/all consumers use it AND assert announced unread count across empty/single/many/error Met Fixture exists, wraps the real NotificationBell, exposes setNotifications via onReady. NotificationBell.svelte.spec.ts asserts title/aria-label/aria-live badge across all four named states. Both production consumers (NotificationBell, aktivitaeten/+page.svelte) migrated to getNotificationStore() context.
#6 Node-mode meta-test banning no-factory vi.mock(virtualModule) Met src/__meta__/no-factory-ban.test.ts with the spec'd regex + unit cases + full-tree scan; TDD red→green. Tree scan returns zero offenders (verified).
#7 ~80 trivial inline factories untouched; diff confined Met Changed *.svelte.{spec,test}.ts = the 8 confirm specs + 2 notification specs + aktivitaeten + the admin/tags prerequisite. No stray factory bodies.
#8 $mocks alias in vite.config.ts + vitest.client-coverage.config.ts 🔁 Reverted-infeasible Removed — it only existed to serve #1/#2. Correct consequence of the reversal.
#9 ADR-012 amended; @vitest/browser-playwright pinned 4.1.6 + TODO Met ADR rev section added; package.json exact-pinned 4.1.6 (caret dropped) with a "//"-key TODO; patch renamed …+4.1.6.patch (file present).
#10 Full client suite + birpc-clean on CI Deferred-CI-only Browser-mode vitest not run locally (maintainer-confirmed pacing). Appropriate to defer to CI.
#11 coverage-flake-probe.yml 20× clean Deferred-CI-only workflow_dispatch job; cannot run locally. Appropriate.

Net assessment: delivered vs promised

  • 3 reverted (#1/#2/#8) — all one indivisible casualty of a single CI-proven framework limitation, not three independent misses. The reversal is recorded in the right two places (the ADR is the durable home; the PR body the transient one). Good requirements hygiene: a discovered infeasibility was folded back into the source-of-truth artifact rather than silently dropped.
  • AC#4 reinterpretation is defensible. The AC named a mechanism (the fixture); the underlying requirement was "no vi.mock of the confirm service — use the real injectable seam." That requirement is fully met. This is solution-language in the AC giving way to the actual need — exactly the right call.
  • AC#5 is the real value and is fully met, including the a11y aria-live count-change path that the issue specifically flagged as otherwise-unverified.
  • Residual scope is coherent and worth shipping. What lands (state-API migration, the no-factory guard rail, the confirm/notification injection refactors, the pin/patch/ADR record) is internally consistent and independently valuable. None of it depends on the reverted Phase 1.

Blockers

None.

Suggestions (non-blocking)

  1. Open a thin follow-up issue to formally close #1/#2/#8, rather than leaving them as struck-through ACs on a closed ticket. Title e.g. "Re-attempt shared virtual-module mock bodies when @vitest/browser-playwright ships PR #10267", linked from the ADR's "Revisit on a newer …" note. This keeps the requirement alive (the dedup need is real) without keeping #560 open — clean backlog traceability.
  2. AC #5's "error" state asserts a degraded zero count on fetch failure. Confirm that silently announcing "0 unread" on a network error is the intended UX (vs. a distinct error affordance). If "0-on-error" is deliberate, it's worth one line in the issue/ADR so a future reader doesn't read it as a swallowed failure — it currently reads as an implementation fact, not a product decision.
  3. #560's title still says "Consolidate shared vi.mock bodies into __mocks__/" — the headline names the reverted mechanism. On close, retitle to reflect what shipped (confirm/notification fixture+context migration + no-factory guard) so the closed issue isn't misleading in search.
  4. The follow-up to delete the dead feat/issue-560-vimock-migration remote branch (PR #657) is noted but out-of-PR — make sure it doesn't get lost when this merges.

Requirements Engineer review — traceability + scope-coherence focus. No code-quality or security judgement implied.

## 📋 PR #719 — Requirements Engineer **Verdict: ✅ Approve.** The residual scope is coherent, the AC#1/#2/#8 reversal is CI-proven and properly recorded (ADR-012 revision + PR body), and the two substantive pieces (confirm injection, notification context refactor) are fully traceable to the issue's intent. Delivered ≈ promised once the four maintainer-confirmed decisions are accepted as the governing contract. I assessed this as a brownfield requirements audit: did each acceptance criterion get **Met / Reinterpreted / Reverted-infeasible / Deferred**, is each deviation *justified and recorded*, and is what ships worth shipping. ### AC-by-AC traceability | AC | Intent | Status | Evidence | |----|--------|--------|----------| | **#1** | `__mocks__/$app/forms.ts` shared interceptor + `setFormResult` + `$mocks` import | 🔁 **Reverted-infeasible** | CI proved both sanctioned forms (static-import spread; async `vi.hoisted` import) fail in @vitest/browser-playwright 4.1.6. Recorded in ADR-012 rev 2026-06-02 ("third false premise") + PR body. Specs keep inline factories. **Justified.** | | **#2** | `__mocks__/$app/navigation.ts` + `simulateNavigate` via `$mocks` | 🔁 **Reverted-infeasible** | Same root cause; same record. No `__mocks__/` dir exists, no `$mocks` ref remains (verified). **Justified.** | | **#3** | `admin/tags/[id]/+page.svelte` → `$app/state`; 2 `$app/stores` mocks gone | ✅ **Met** | `+page.svelte` swapped; zero `$app/stores` refs under `admin/tags/` (verified); the 2 spec mocks now sync-factory `$app/state`. | | **#4** | `confirm.test-fixture.svelte` used by every ex-`confirm.svelte` mocker; no `vi.mock` left | ⚖️ **Reinterpreted (defensible)** | 8 specs now inject a real `createConfirmService()` via render `context: new Map([[CONFIRM_KEY, …]])`. **Zero `confirm.svelte` `vi.mock`s remain** (verified). The fixture can't wrap a page and none of the 8 call `confirm()`, so "real provided service, no `vi.mock`" satisfies the AC's *intent* (kill the mock, use the real seam). Defensible and arguably cleaner than the fixture. | | **#5** | `notification.test-fixture.svelte` (option-b `setNotifications`); **both/all** consumers use it AND assert announced unread count across **empty/single/many/error** | ✅ **Met** | Fixture exists, wraps the real `NotificationBell`, exposes `setNotifications` via `onReady`. `NotificationBell.svelte.spec.ts` asserts title/`aria-label`/`aria-live` badge across all **four** named states. Both production consumers (`NotificationBell`, `aktivitaeten/+page.svelte`) migrated to `getNotificationStore()` context. | | **#6** | Node-mode meta-test banning no-factory `vi.mock(virtualModule)` | ✅ **Met** | `src/__meta__/no-factory-ban.test.ts` with the spec'd regex + unit cases + full-tree scan; TDD red→green. Tree scan returns **zero offenders** (verified). | | **#7** | ~80 trivial inline factories untouched; diff confined | ✅ **Met** | Changed `*.svelte.{spec,test}.ts` = the 8 confirm specs + 2 notification specs + aktivitaeten + the admin/tags prerequisite. No stray factory bodies. | | **#8** | `$mocks` alias in `vite.config.ts` + `vitest.client-coverage.config.ts` | 🔁 **Reverted-infeasible** | Removed — it only existed to serve #1/#2. Correct consequence of the reversal. | | **#9** | ADR-012 amended; `@vitest/browser-playwright` pinned 4.1.6 + TODO | ✅ **Met** | ADR rev section added; `package.json` exact-pinned `4.1.6` (caret dropped) with a `"//"`-key TODO; patch renamed `…+4.1.6.patch` (file present). | | **#10** | Full client suite + birpc-clean on CI | ⏳ **Deferred-CI-only** | Browser-mode vitest not run locally (maintainer-confirmed pacing). Appropriate to defer to CI. | | **#11** | `coverage-flake-probe.yml` 20× clean | ⏳ **Deferred-CI-only** | `workflow_dispatch` job; cannot run locally. Appropriate. | ### Net assessment: delivered vs promised - **3 reverted (#1/#2/#8)** — all one indivisible casualty of a single CI-proven framework limitation, not three independent misses. The reversal is recorded in the *right two places* (the ADR is the durable home; the PR body the transient one). Good requirements hygiene: a discovered infeasibility was folded back into the source-of-truth artifact rather than silently dropped. - **AC#4 reinterpretation is defensible.** The AC named a *mechanism* (the fixture); the underlying requirement was "no `vi.mock` of the confirm service — use the real injectable seam." That requirement is fully met. This is solution-language in the AC giving way to the actual need — exactly the right call. - **AC#5 is the real value** and is fully met, including the a11y `aria-live` count-change path that the issue specifically flagged as otherwise-unverified. - **Residual scope is coherent and worth shipping.** What lands (state-API migration, the no-factory guard rail, the confirm/notification injection refactors, the pin/patch/ADR record) is internally consistent and independently valuable. None of it depends on the reverted Phase 1. ### Blockers None. ### Suggestions (non-blocking) 1. **Open a thin follow-up issue to formally close #1/#2/#8**, rather than leaving them as struck-through ACs on a closed ticket. Title e.g. *"Re-attempt shared virtual-module mock bodies when @vitest/browser-playwright ships PR #10267"*, linked from the ADR's "Revisit on a newer …" note. This keeps the *requirement* alive (the dedup need is real) without keeping #560 open — clean backlog traceability. 2. **AC #5's "error" state** asserts a degraded **zero** count on fetch failure. Confirm that silently announcing "0 unread" on a network error is the *intended* UX (vs. a distinct error affordance). If "0-on-error" is deliberate, it's worth one line in the issue/ADR so a future reader doesn't read it as a swallowed failure — it currently reads as an implementation fact, not a product decision. 3. **#560's title still says "Consolidate shared vi.mock bodies into `__mocks__/`"** — the headline names the reverted mechanism. On close, retitle to reflect what shipped (confirm/notification fixture+context migration + no-factory guard) so the closed issue isn't misleading in search. 4. The follow-up to **delete the dead `feat/issue-560-vimock-migration` remote branch** (PR #657) is noted but out-of-PR — make sure it doesn't get lost when this merges. *Requirements Engineer review — traceability + scope-coherence focus. No code-quality or security judgement implied.*
Author
Owner

🧪 Sara Holt — QA

Verdict: ⚠️ Approve with one substantive test-quality fix. The context refactor genuinely improves isolation (fresh store per test beats the old singleton __resetForTest()), the confirm migration preserves coverage, and the meta-test is a sound permanent guard. But the new "error" a11y test does not test an error — it's a duplicate of the empty-state assertion. Fix that and this is a clean .


Blockers

None. Nothing here breaks the suite or regresses coverage. The item below is a quality defect in a new test, not a merge-stopper, but I'd want it addressed before this lands as the canonical pattern others will copy.


Concerns

1. NotificationBell.svelte.spec.ts:131 — the "error" test is vacuous; it asserts the empty state, not an error path.

it('error: degrades to a zero announced count when the unread-count load fails', async () => {
  vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')));
  renderBell();
  await tick();
  expect(btn.getAttribute('title')).toBe(m.notification_bell_label());      // count 0
  expect(unreadBadge().classList.contains('hidden')).toBe(true);            // count 0
});

The three assertions are byte-for-byte identical to the empty: test (title === bell_label, aria-label === title, badge hidden). The count is 0 here because the store initialises unreadCount = $state(0) (notifications.svelte.ts), not because the rejected fetch was observed. fetchUnreadCount()'s catch block logs and returns, leaving the already-zero count untouched. Swap the rejecting fetch for a resolving {count: 0} and every assertion still passes — so the test cannot distinguish "error path exercised" from "store never loaded." The comment claims the bell "degrades... from a broken count", but no non-zero starting state is ever established, so there is nothing to degrade from.

What it actually proves: the bell doesn't crash on a rejected fetch. That's a real (if thin) guarantee, but the name oversells it. To make it test the error path meaningfully, the store must start non-zero and then fail a refetch — e.g. seed a successful {count: 3} on init(), settle, assert badge shows 3, then trigger an onerror/refetch that rejects and assert it holds (or degrades) deterministically. As written, rename it to does not crash when the unread-count load fails so the name matches the assertion, or beef it up. Right now it's coverage padding dressed as an error test.

2. NotificationBell.svelte.spec.ts tick() race — works today, but on a hidden coupling.

The ordering invariant ("init()'s async fetchUnreadCount settles to 0 before setNotifications() runs") holds only because (a) the stubbed fetch resolves purely on the microtask queue (mockResolvedValue + Response.json() — no macrotask), so a single setTimeout(0) drains it, and (b) NoopEventSource never fires onopen, which would queue a second fetchUnreadCount() that could land after setNotifications and clobber the count back to 0. That second condition is an undocumented dependency between the stub and the timing. If anyone gives NoopEventSource.onopen a call, or fetchUnreadCount gains a real timer/retry, single/many flake intermittently. Not flaky now — but it's exactly the "passes 90% of the time" trap. Cheap hardening: assert the post-init baseline (expect(unreadBadge()).toHaveClass('hidden') right after the first tick()) so a regression surfaces as a clear failure rather than a race, and/or use vi.waitFor on the final count instead of a bare tick().


What's good (and I checked)

  • notifications.svelte.spec.ts rewrite is sound. Fresh createNotificationStore() in beforeEach + setNavigate(spy) injection is strictly better isolation than the old singleton __resetForTest() — no cross-test leakage of refCount/errorCount/eventSource. All 15 behaviours (ref-counting, SSE prepend, optimistic marks, the full onerror/401 matrix) are preserved verbatim, just retargeted to the instance. No coverage lost.
  • Confirm migration preserved coverage — no spec went vacuous. The old getConfirmService: () => ({ confirm: async () => false }) mock is replaced by a real context-injected createConfirmService(). I verified none of the 8 migrated specs exercise a confirm()-gated branch: they assert rendering (headings/banners/buttons/links). The two clicking tests in TranscriptionEditView (toggles training chip, handleMarkAllReviewed) don't touch confirm()handleMarkAllReviewed calls onMarkAllReviewed() directly with no confirm gate. The injection exists solely to satisfy the init-time getConfirmService() context lookup. Correct and minimal. (Note: the real confirm() now returns a pending promise in-browser rather than false; harmless here only because no test awaits a confirm outcome — worth a one-line comment so a future author doesn't add a confirm-gated assertion expecting auto-decline.)
  • Phase-1 revert lost no coverage. Inlining the $app/forms/$app/navigation factories vs. sharing via src/__mocks__ is a structural/DRY change only; the mock bodies and every assertion are unchanged. The no-factory-ban.test.ts meta-test correctly forecloses the partial-auto-mock failure class (PR #657 / replaceState before router init) with good self-tests for the scanner. Regex is fine for the named anti-pattern (won't catch backtick-quoted ids, acceptable for a belt-and-braces guard layered behind ESLint + CI grep).
  • admin/tags/[id] $app/stores$app/state migration is correct; the getter-based vi.mock('$app/state') matches the ADR-012 sync-factory rule.

Bottom line: ship-ready once the error: test is renamed or made to actually observe a failed refetch from a non-zero baseline. The isolation story is a real improvement — this is the kind of refactor that pays for itself.

## 🧪 Sara Holt — QA **Verdict: ⚠️ Approve with one substantive test-quality fix.** The context refactor genuinely improves isolation (fresh store per test beats the old singleton `__resetForTest()`), the confirm migration preserves coverage, and the meta-test is a sound permanent guard. But the new "error" a11y test does not test an error — it's a duplicate of the empty-state assertion. Fix that and this is a clean ✅. --- ### Blockers **None.** Nothing here breaks the suite or regresses coverage. The item below is a quality defect in a *new* test, not a merge-stopper, but I'd want it addressed before this lands as the canonical pattern others will copy. --- ### Concerns **1. `NotificationBell.svelte.spec.ts:131` — the "error" test is vacuous; it asserts the empty state, not an error path.** ```ts it('error: degrades to a zero announced count when the unread-count load fails', async () => { vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down'))); renderBell(); await tick(); expect(btn.getAttribute('title')).toBe(m.notification_bell_label()); // count 0 expect(unreadBadge().classList.contains('hidden')).toBe(true); // count 0 }); ``` The three assertions are **byte-for-byte identical** to the `empty:` test (`title === bell_label`, `aria-label === title`, badge hidden). The count is 0 here **because the store initialises `unreadCount = $state(0)`** (`notifications.svelte.ts`), *not* because the rejected fetch was observed. `fetchUnreadCount()`'s `catch` block logs and returns, leaving the already-zero count untouched. Swap the rejecting fetch for a resolving `{count: 0}` and every assertion still passes — so the test cannot distinguish "error path exercised" from "store never loaded." The comment claims the bell *"degrades... from a broken count"*, but no non-zero starting state is ever established, so there is nothing to degrade *from*. What it actually proves: the bell doesn't *crash* on a rejected fetch. That's a real (if thin) guarantee, but the name oversells it. To make it test the error path meaningfully, the store must start non-zero and then fail a *refetch* — e.g. seed a successful `{count: 3}` on `init()`, settle, assert badge shows 3, *then* trigger an onerror/refetch that rejects and assert it holds (or degrades) deterministically. As written, rename it to `does not crash when the unread-count load fails` so the name matches the assertion, or beef it up. Right now it's coverage padding dressed as an error test. **2. `NotificationBell.svelte.spec.ts` `tick()` race — works today, but on a hidden coupling.** The ordering invariant ("init()'s async `fetchUnreadCount` settles to 0 *before* `setNotifications()` runs") holds **only** because (a) the stubbed fetch resolves purely on the microtask queue (`mockResolvedValue` + `Response.json()` — no macrotask), so a single `setTimeout(0)` drains it, and (b) `NoopEventSource` never fires `onopen`, which would queue a *second* `fetchUnreadCount()` that could land after `setNotifications` and clobber the count back to 0. That second condition is an undocumented dependency between the stub and the timing. If anyone gives `NoopEventSource.onopen` a call, or `fetchUnreadCount` gains a real timer/retry, `single`/`many` flake intermittently. Not flaky now — but it's exactly the "passes 90% of the time" trap. Cheap hardening: assert the post-init baseline (`expect(unreadBadge()).toHaveClass('hidden')` right after the first `tick()`) so a regression surfaces as a clear failure rather than a race, and/or use `vi.waitFor` on the final count instead of a bare `tick()`. --- ### What's good (and I checked) - **`notifications.svelte.spec.ts` rewrite is sound.** Fresh `createNotificationStore()` in `beforeEach` + `setNavigate(spy)` injection is strictly better isolation than the old singleton `__resetForTest()` — no cross-test leakage of `refCount`/`errorCount`/`eventSource`. All 15 behaviours (ref-counting, SSE prepend, optimistic marks, the full onerror/401 matrix) are preserved verbatim, just retargeted to the instance. No coverage lost. - **Confirm migration preserved coverage — no spec went vacuous.** The old `getConfirmService: () => ({ confirm: async () => false })` mock is replaced by a real context-injected `createConfirmService()`. I verified none of the 8 migrated specs exercise a `confirm()`-gated branch: they assert rendering (headings/banners/buttons/links). The two clicking tests in `TranscriptionEditView` (`toggles training chip`, `handleMarkAllReviewed`) don't touch `confirm()` — `handleMarkAllReviewed` calls `onMarkAllReviewed()` directly with no confirm gate. The injection exists solely to satisfy the init-time `getConfirmService()` context lookup. Correct and minimal. (Note: the real `confirm()` now returns a *pending* promise in-browser rather than `false`; harmless here only because no test awaits a confirm outcome — worth a one-line comment so a future author doesn't add a confirm-gated assertion expecting auto-decline.) - **Phase-1 revert lost no coverage.** Inlining the `$app/forms`/`$app/navigation` factories vs. sharing via `src/__mocks__` is a structural/DRY change only; the mock bodies and every assertion are unchanged. The `no-factory-ban.test.ts` meta-test correctly forecloses the partial-auto-mock failure class (PR #657 / `replaceState` before router init) with good self-tests for the scanner. Regex is fine for the named anti-pattern (won't catch backtick-quoted ids, acceptable for a belt-and-braces guard layered behind ESLint + CI grep). - **`admin/tags/[id]` `$app/stores` → `$app/state`** migration is correct; the getter-based `vi.mock('$app/state')` matches the ADR-012 sync-factory rule. **Bottom line:** ship-ready once the `error:` test is renamed or made to actually observe a failed refetch from a non-zero baseline. The isolation story is a real improvement — this is the kind of refactor that pays for itself.
marcel added 1 commit 2026-06-03 10:32:24 +02:00
test(notification): make setNotifications authoritative in bell a11y tests
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m40s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m36s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
30a8e36563
CI showed the single/many a11y tests failing with count 0: init()'s async
fetchUnreadCount resolved to {count:0} AFTER setNotifications() ran,
clobbering the seeded count (the flake Sara predicted in review). Stub
fetch to never settle so the announced count is driven solely by
setNotifications — deterministic, no race. Also rewrites the 'error' test
to seed a count then fail the load and assert the count SURVIVES, so it is
a meaningful state distinct from 'empty' (was byte-identical, flagged by
Felix/Sara/Leonie). Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
marcel merged commit 27b6d58632 into main 2026-06-03 11:38:23 +02:00
marcel deleted branch feat/issue-560-shared-vimock-mocks 2026-06-03 11:38:23 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#719