Compare commits

..

59 Commits

Author SHA1 Message Date
Marcel
5ce0856178 fix(ci): add IMPORT_HOST_DIR stub to compose-idempotency env file
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m37s
CI / OCR Service Tests (push) Successful in 18s
CI / Backend Unit Tests (push) Successful in 4m24s
CI / Compose Bucket Idempotency (push) Successful in 56s
CI / Unit & Component Tests (pull_request) Failing after 2m36s
CI / fail2ban Regex (push) Successful in 41s
CI / OCR Service Tests (pull_request) Successful in 17s
CI / Backend Unit Tests (pull_request) Successful in 4m20s
CI / fail2ban Regex (pull_request) Successful in 39s
CI / Compose Bucket Idempotency (pull_request) Successful in 57s
Docker Compose interpolates all variables in the full file even when
only a subset of services is requested. The backend service uses
IMPORT_HOST_DIR with :? (hard-required), causing the idempotency job
to abort before any container starts. A dummy path satisfies the parser;
the backend service is never started in this job so the path need not exist.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 10:57:30 +02:00
Marcel
582191d014 docs(adr-013): set exact baseline to 75% (confirmed in CI)
Some checks failed
CI / Backend Unit Tests (pull_request) Successful in 4m20s
CI / fail2ban Regex (pull_request) Successful in 38s
CI / Compose Bucket Idempotency (pull_request) Failing after 11s
CI / Unit & Component Tests (pull_request) Failing after 2m35s
CI / OCR Service Tests (pull_request) Successful in 16s
CI / Unit & Component Tests (push) Failing after 2m35s
CI / OCR Service Tests (push) Successful in 17s
CI / Backend Unit Tests (push) Successful in 4m18s
CI / fail2ban Regex (push) Successful in 39s
CI / Compose Bucket Idempotency (push) Failing after 11s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 09:03:45 +02:00
Marcel
118100e58d chore(coverage): drop client branches threshold 80→75, add ADR-013
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / fail2ban Regex (push) Has been cancelled
CI / Compose Bucket Idempotency (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 2m35s
CI / OCR Service Tests (pull_request) Successful in 16s
CI / Backend Unit Tests (pull_request) Successful in 4m21s
CI / fail2ban Regex (pull_request) Successful in 39s
CI / Compose Bucket Idempotency (pull_request) Failing after 11s
Branches gate was blocking CI at 75% measured coverage. The 80% floor
suffers Istanbul parent/child denominator coupling (long-tail grind, per
#496) that makes the remaining gap disproportionately costly to close.
Drop branches to 75 to match current state; leave lines/functions/
statements at 80. ADR-013 documents the rationale and the ratchet rule
for raising the gate back incrementally.

Closes #556

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 08:54:59 +02:00
Marcel
2e6cc346ab docs(adr-012): document duplicate-id hazard and patch-package backport
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m33s
CI / OCR Service Tests (pull_request) Successful in 17s
CI / Backend Unit Tests (pull_request) Successful in 4m12s
CI / fail2ban Regex (pull_request) Successful in 38s
CI / Compose Bucket Idempotency (pull_request) Failing after 11s
CI / Unit & Component Tests (push) Failing after 2m33s
CI / OCR Service Tests (push) Successful in 16s
CI / Backend Unit Tests (push) Successful in 4m16s
CI / fail2ban Regex (push) Successful in 38s
CI / Compose Bucket Idempotency (push) Failing after 11s
nightly / deploy-staging (push) Failing after 1m46s
Adds a second binding invariant section to ADR-012 covering the
duplicate-id mechanism named in #553's follow-up investigation: same
resolved module URL referenced via two distinct vi.mock id strings →
@vitest/browser-playwright leaks an orphan Playwright route → birpc-closed
crash in the next session.

Records the rule (one canonical id per mocked module, prefer the spelling
production uses, no-extension for .svelte rune modules), the in-suite
detector (no-duplicate-mock-ids.test.ts), and the patch-package backport
of vitest PR #10267 with its removal trigger.

Extends the existing Consequences enforcement list from four layers to
six, adding the duplicate-id detector and the patch-package layer.

Refs: #553 · vitest-dev/vitest#9957 · vitest-dev/vitest#10267

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 08:09:57 +02:00
Marcel
7fc1295dc0 build(patch-package): backport vitest PR #10267 for browser-playwright birpc race
Installs patch-package (^8.0.0) and a postinstall script, then applies
the diff from vitest PR #10267 against @vitest/browser-playwright@4.1.0.

What the patch changes (in dist/index.js):

- createPredicate(sessionId, url) → createPredicate(url): factory becomes
  pure, returns { url, predicate } instead of mutating sessionIds /
  idPreficates as a side-effect.
- sessionIds value type: array → Set (deduplicates resolved URLs).
- register handler now looks up any existing predicate for the
  (sessionId, resolvedUrl) pair and unroutes it BEFORE installing the
  new route. This is the actual race fix: without it, the second
  vi.mock for a duplicate-id leaks an orphan Playwright route that
  fires after birpc closes.
- clear handler iterates the Set via spread.

Why this matters even though Layer 1 normalised the only known duplicate
in our suite: every future vi.mock call is a class of race we shouldn't
have to think about. The patch closes the upstream gap at the
route-handler level, so a contributor reintroducing the duplicate-id
pattern can't reopen the race.

When to remove: when @vitest/browser-playwright ships a release
containing PR #10267. Delete patches/@vitest+browser-playwright+4.1.0.patch
and the postinstall hook (or keep the hook if other patches accumulate).

Refs: #553 · vitest-dev/vitest#9957 · vitest-dev/vitest#10267

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 08:08:28 +02:00
Marcel
0cf4a488bb test(meta): add duplicate-id vi.mock detector under __meta__/
Scans every src/**/*.svelte.{spec,test}.ts file for vi.mock first-arg
strings, canonicalises each by stripping a trailing .js/.ts after
.svelte, groups by canonical id, and fails if any canonical id is
referenced under two or more distinct raw spellings.

Mirrors the shape of src/__meta__/no-async-mock-factories.test.ts:
source-text regex scan (no AST parser dependency), red/green self-test
fixtures inline, then one corpus assertion that the whole suite is
clean.

This is the in-suite defence-in-depth layer for the duplicate-id birpc
race named in ADR-012 / #553 and fixed upstream by vitest PR #10267.
Harder to disable than ESLint (cross-file invariant ESLint cannot
express anyway) and harder to scope around than a CI grep.

Refs: #553

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 08:06:14 +02:00
Marcel
9030a7d031 test(confirm-service): normalise vi.mock spelling and remove duplicate-id mocks
Five test files mocked $lib/shared/services/confirm.svelte under BOTH
spellings (.svelte and .svelte.js) within the same file; two more mocked
only the .svelte.js form. Both resolve to the same module URL but register
two distinct Playwright route handlers in @vitest/browser-playwright. The
cleanup logic only removes one, leaving an orphan that fires when the next
session loads the module — crashing the run with
"[birpc] rpc is closed, cannot call resolveManualMock".

This is the exact trigger fixed upstream by vitest PR #10267 (issue #9957).
Normalise every confirm.svelte mock to the no-extension form, matching
production imports and the source file basename (confirm.svelte.ts).

After this commit: 8 confirm.svelte mocks across 8 spec files, all under
one canonical ID. A meta-test (next commit) prevents the duplicate-id
pattern from reappearing.

Refs: #553 · vitest-dev/vitest#9957 · vitest-dev/vitest#10267

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 08:03:43 +02:00
Marcel
feadf372a0 refactor(confirm-service): normalise import spelling to no-extension form
Production code referenced $lib/shared/services/confirm.svelte under two
spellings — 4 files with the .js extension and one without. Standardise on
the no-extension form to match Svelte 5 rune-module convention and the
source file basename (confirm.svelte.ts).

Why this matters: vitest browser mode's @vitest/browser-playwright resolves
both spellings to the same module URL but registers a separate Playwright
route per spelling. The route-cleanup logic only unregisters the latest,
leaving an orphan that crashes the next session with
"[birpc] rpc is closed, cannot call resolveManualMock". Fixed upstream in
vitest PR #10267 (merged, not yet released). Normalising the spelling
removes the trigger from our side.

Refs: #553. Companion test-file changes follow in the next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 08:02:26 +02:00
Marcel
edde9292e6 test(setup): also disable data-sveltekit-preload-code in browser tests
Some checks failed
CI / Backend Unit Tests (push) Successful in 4m17s
CI / fail2ban Regex (push) Successful in 39s
CI / Compose Bucket Idempotency (push) Failing after 11s
CI / Unit & Component Tests (push) Failing after 2m31s
CI / OCR Service Tests (push) Successful in 17s
Hover-prefetch has two surfaces in SvelteKit:
- data-sveltekit-preload-data (route loader data)
- data-sveltekit-preload-code (route JS chunks)

The original fix turned off only the loader-data side. Route-code chunks
prefetched on hover can also include manually-mocked module URLs; an
in-flight code prefetch landing after iframe teardown hits the same
Playwright route handler that resolves manual mocks, raising the
unhandled rejection. Disable both surfaces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:32:03 +02:00
Marcel
addf5c98db docs(adr-012): record the sync-factory invariant and the $app/state migration
Some checks failed
CI / Unit & Component Tests (push) Failing after 1m49s
CI / OCR Service Tests (push) Successful in 17s
CI / Backend Unit Tests (push) Successful in 4m13s
CI / Compose Bucket Idempotency (push) Has been cancelled
CI / fail2ban Regex (push) Has been cancelled
The previous revision allowed vi.mock for virtual modules on the "consumer
import is static" argument. #553 proved that argument wrong: a statically-
imported module with an async factory body whose dynamic import landed
after teardown still produced the race. The factory body — not the
consumer — is the failure surface.

- Drop the "residual exceptions" table.
- Add the binding invariant: factory bodies under `**/*.svelte.{test,spec}.ts`
  must be synchronous (no `await`, no `import(...)`).
- Document the canonical vi.hoisted + getter pattern, with file references.
- Record the $app/stores → $app/state architectural call (Markus's
  recommendation), removing one of the last two deprecated-import
  outliers.
- Record the preload-data=off hardening (Tobias's recommendation) as a
  pattern note.
- Update the Enforcement section to list all four defence layers (ESLint,
  CI grep, in-suite meta-test, CI birpc assert) and the coverage-flake-
  probe verification workflow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:14:31 +02:00
Marcel
c820884765 ci(coverage-flake-probe): add workflow_dispatch matrix job (20 parallel runs)
Verification mechanism for the 20-run acceptance criterion of issue #553.
Triggered manually via workflow_dispatch, runs the full coverage suite 20×
in parallel against a single SHA, asserts zero `[birpc] rpc is closed`
lines in every cell.

One fire, parallel cost (~one main-job's wall-clock), deterministic signal
for the teardown race. Cheaper than 20 sequential push events and tests
the same property the AC names.

Closes the verification gap raised by Tobias and Elicit in the issue
discussion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:12:04 +02:00
Marcel
67cd56acc7 ci(unit-tests): extend grep guard to async vi.mock with dynamic import
The pdfjs-dist literal grep added in 9260866f only caught one named
trigger of the birpc teardown race; the underlying mechanism (ADR 012 /
#553) is any async vi.mock factory whose body performs `await import(...)`.

Add a second PCRE-multiline grep matching that shape. Scoped to
**/*.{spec,test}.ts under frontend/src/, excluding __meta__ (which holds
the fixture strings exercising the meta-test). Defence in depth pairs with
the ESLint rule (saves at edit time) and the in-suite meta-test (catches
when tests run).

Verified locally with real GNU grep against a planted synthetic offender.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:11:09 +02:00
Marcel
5afebde382 refactor(eslint): ban async vi.mock factories with dynamic import in body
Generalise the no-restricted-syntax rule from the literal pdfjs-dist
selector (added in #535) to also catch the underlying mechanism named in
ADR-012 / #553: any `vi.mock(..., async () => { ... await import(...)
... })` produces a late birpc roundtrip during worker teardown.

Selector: vi.mock CallExpression whose second argument is an
ArrowFunctionExpression with async=true and whose subtree contains an
AwaitExpression > ImportExpression. Both rules coexist — the literal
pdfjs-dist rule still enforces the libLoader prop injection pattern
(catches sync forms too); the new rule enforces the sync-factory
invariant universally.

Demonstrated by planting a synthetic offender locally and watching
ESLint flag it with the new rule's message.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:09:07 +02:00
Marcel
636d61a81b test(meta): scan src/**/*.svelte.{test,spec}.ts for async vi.mock factories
In-suite belt-and-braces detector for the birpc teardown race named in
ADR-012 / #553. Catches `vi.mock(<arg>, async ... { ... await import(...)
... })` in any browser spec on every vitest invocation — the layer hardest
to disable or scope around (ESLint can be silenced; CI grep runs only in
CI; this test runs whenever the suite runs).

Demonstrated red→green by planting a synthetic offender locally and
watching the live-scan assertion fail; removing the offender returned it
to green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:05:43 +02:00
Marcel
3c9e40ca71 test(setup): disable SvelteKit hover prefetch in browser-mode runs
Hover-prefetch fires real fetch requests for route loader chunks; those
requests go through the same Playwright route handler that serves mocked
modules. An in-flight prefetch landing after iframe teardown can hit the
handler with a closed birpc channel, raising an unhandled rejection that
exits the run with code 1 even when every individual test was green.

Add `src/test-setup.ts` that sets `document.body.dataset.sveltekitPreloadData
= 'off'` and wire it via `setupFiles` in both `vite.config.ts` (client
project) and `vitest.client-coverage.config.ts` (Istanbul coverage config).
Add `src/__meta__/browser-preload-disabled.svelte.test.ts` asserting the
setup ran. Zero production impact.

Issue #553 secondary trigger.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:02:57 +02:00
Marcel
9f1b8b4215 fix(enrichment-block): migrate $app/stores → $app/state to eliminate birpc race
The async vi.mock factory in EnrichmentBlock.svelte.spec.ts performed an
`await import(...)` in its body — the same mechanism #535/#546 fixed for
pdfjs-dist. Issue #553: when Chromium's playwright route handler fetches
the mocked module after the worker's birpc channel has closed, the
factory's RPC roundtrip raises `[birpc] rpc is closed, cannot call
"resolveManualMock"` and the run exits 1.

Migrate EnrichmentBlock from the deprecated `$app/stores.navigating`
(store) to the modern `$app/state.navigating` (reactive proxy). The
spec uses vi.hoisted + a sync vi.mock factory with a getter that defers
the read — no dynamic import in the factory body. Delete the now-unused
__mocks__/navigatingStore.ts.

Fix path applied: $app/state migration (Markus's recommendation /
Felix's Path 2). See ADR-012.

Refs #553

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:00:44 +02:00
Marcel
89860403f6 fix(notification): remove role=link from view-all button — restores semantically honest button role
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 1m50s
CI / OCR Service Tests (pull_request) Successful in 18s
CI / Backend Unit Tests (pull_request) Successful in 4m12s
CI / fail2ban Regex (pull_request) Successful in 38s
CI / Compose Bucket Idempotency (pull_request) Failing after 10s
CI / Unit & Component Tests (push) Failing after 2m5s
CI / OCR Service Tests (push) Successful in 17s
CI / Backend Unit Tests (push) Successful in 4m14s
CI / fail2ban Regex (push) Successful in 39s
CI / Compose Bucket Idempotency (push) Failing after 12s
nightly / deploy-staging (push) Failing after 2m36s
The role=link override on a <button> creates a WCAG 4.1.2 keyboard-contract
mismatch: ARIA role=link tells AT users "press Enter to activate (Space does
nothing)", but the native <button> responds to both Enter and Space. Removes
the override so the element is announced as "button" (accurate).

Test selectors updated from getByRole('link') to getByRole('button')
accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 18:01:38 +02:00
Marcel
6b78557954 refactor(notification-tests): use vi.mocked instead of type cast in call-order test
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 17:50:55 +02:00
Marcel
bc2dd3a98a fix(notification): add role=link and touch target to view-all button
Some checks failed
CI / Backend Unit Tests (push) Successful in 4m15s
CI / fail2ban Regex (push) Successful in 39s
CI / Compose Bucket Idempotency (push) Failing after 11s
CI / OCR Service Tests (pull_request) Successful in 17s
CI / Backend Unit Tests (pull_request) Successful in 4m17s
CI / Unit & Component Tests (push) Failing after 1m48s
CI / OCR Service Tests (push) Successful in 17s
CI / Unit & Component Tests (pull_request) Failing after 2m3s
CI / fail2ban Regex (pull_request) Successful in 40s
CI / Compose Bucket Idempotency (pull_request) Failing after 11s
- role="link" restores screen reader link semantics (Leonie blocker)
- min-h-[44px] px-1 meets WCAG 2.2 §2.5.8 and our 44×48px target size
- Comment in handleViewAll explains close-before-navigate ordering
- Tests updated to getByRole('link') + new call-order assertion

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 17:43:11 +02:00
Marcel
3005782a75 docs(adr-012): correct pattern note to document button+goto, not anchor+preventDefault
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 17:43:11 +02:00
Marcel
8ccc9aba1a fix(notification): replace view-all anchor with button to prevent iframe navigation
SvelteKit's capture-phase link interceptor fires before the component's
onclick handler, so e.preventDefault() was structurally too late to stop
iframe navigation in vitest-browser. Replacing the <a href> with a
<button type="button"> removes the href entirely — the interceptor never
fires — and the existing goto() mock in tests is sufficient.

Also splits the single view-all test into two focused it() blocks and
clears mocks in afterEach to prevent cross-test mock leakage.

Fixes #551

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 17:43:11 +02:00
Marcel
d21ba8fed2 refactor(pdf-viewer-tests): extract shared fake, add loadDocument error path, fix assertions
Some checks failed
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / OCR Service Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / fail2ban Regex (pull_request) Has been cancelled
CI / Compose Bucket Idempotency (pull_request) Has been cancelled
CI / Unit & Component Tests (push) Failing after 2m3s
CI / OCR Service Tests (push) Successful in 16s
CI / Backend Unit Tests (push) Successful in 4m15s
CI / fail2ban Regex (push) Successful in 38s
CI / Compose Bucket Idempotency (push) Failing after 11s
- Extract makeFakePdfjsLib / makeFakeLibLoader to testHelpers.ts — single
  source of truth used by both PdfViewer.svelte.test.ts and
  usePdfRenderer.svelte.test.ts; removes the diverging-fidelity DRY violation
  flagged by @felixbrandt and @saraholt in the PR review
- Add 'loadDocument sets error and loading=false when getDocument().promise
  rejects' test to usePdfRenderer.svelte.test.ts — closes the error-path gap
  flagged by @felixbrandt and @saraholt
- Replace toBeInTheDocument() with toBeVisible() in the three absorbed
  spec-file tests — uniform assertion style across the loaded-state describe
  block, as flagged by @felixbrandt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 16:19:26 +02:00
Marcel
23cbb6be22 test(pdf-renderer): eliminate real pdfjs-dist loading from browser tests — use fake libLoader for all init() calls
Five tests in usePdfRenderer.svelte.test.ts called createPdfRenderer() without
a libLoader, causing init() to dynamically import pdfjs-dist in the browser.
Every dynamic import goes through Playwright's route handler, which calls
resolveManualMock via birpc to check for mocks. If the RPC closes during
teardown while one of these imports is in flight, the birpc race fires —
even though pdfjs-dist was never explicitly vi.mock()-ed.

Replace all bare createPdfRenderer() calls that invoke init() with
createPdfRenderer(makeFakeLibLoader()), identical to the pattern already
used in PdfViewer.svelte.test.ts. No real module loads, no route-handler
calls, no birpc exposure.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 16:19:26 +02:00
Marcel
9260866f47 ci(unit-tests): add early grep check for banned vi.mock pdfjs-dist pattern
Some checks failed
CI / Unit & Component Tests (push) Failing after 1m47s
CI / OCR Service Tests (push) Successful in 16s
CI / Backend Unit Tests (push) Successful in 4m11s
CI / fail2ban Regex (push) Successful in 38s
CI / Compose Bucket Idempotency (push) Failing after 11s
Adds a static grep step that runs after Lint and before the test suite.
Fails in ~1 s if any file under frontend/src/ contains the banned
vi.mock('pdfjs-dist' pattern, catching the regression before Playwright
spins up. Belt-and-suspenders with the ESLint rule (ADR 012).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 12:32:23 +02:00
Marcel
7c8811e439 refactor(eslint): ban vi.mock('pdfjs-dist', ...) in spec/test files — ADR 012
Adds a no-restricted-syntax rule scoped to *.spec.ts / *.test.ts that
flags any vi.mock call whose first argument starts with 'pdfjs-dist'.
Turns the ~2-min CI wait into an immediate lint error on save.
Updates ADR 012 Enforcement section to document the rule.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 12:32:23 +02:00
Marcel
ef592ddd0c test(pdf-viewer): consolidate spec.ts into test.ts — absorb page-counter test, delete spec
Absorbs the three tests from PdfViewer.svelte.spec.ts (nav buttons, zoom
controls, page counter) into the loaded-state describe in test.ts, then
deletes the now-empty spec file. One spec file per component.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 12:32:23 +02:00
Marcel
6c596babcb test(pdf-viewer): port PdfViewer.svelte.test.ts to libLoader prop injection — remove vi.mock
Removes both vi.mock('pdfjs-dist', …) calls that caused the birpc teardown
race (ADR 012). Replaces with static import + makeFakeLibLoader() helper
injected via the libLoader prop on every render() call.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 12:32:23 +02:00
Marcel
763e9f5708 docs(adr-012): add overlay navigation pattern note
Some checks failed
CI / Backend Unit Tests (push) Successful in 4m10s
CI / fail2ban Regex (push) Successful in 38s
CI / Compose Bucket Idempotency (push) Failing after 11s
CI / Unit & Component Tests (push) Failing after 1m50s
CI / OCR Service Tests (push) Successful in 16s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 11:35:40 +02:00
Marcel
37026bbbb8 refactor(notification): extract handleViewAll named function
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 11:35:40 +02:00
Marcel
53ecfee25e test(notification): assert href is preserved on view-all link
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 11:35:40 +02:00
Marcel
fa4f8ed661 fix(style): move transkription print styles to global CSS to suppress Tailwind noise
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 11:35:40 +02:00
Marcel
890b811bc1 fix(style): move ChronikFuerDichBox animation to global CSS to suppress Tailwind noise
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 11:35:40 +02:00
Marcel
ed91c9bcf6 fix(notification): prevent iframe navigation — use goto instead of href follow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 11:35:40 +02:00
Marcel
661e8582a2 test(notification): add goto mock and tighten selector in NotificationDropdown spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 11:35:40 +02:00
Marcel
7ee038faaf test(ocr): fix track_reactivity_loss in OcrTrainingCard spec
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 1m50s
CI / OCR Service Tests (pull_request) Successful in 16s
CI / Backend Unit Tests (pull_request) Successful in 4m11s
CI / fail2ban Regex (pull_request) Successful in 39s
CI / Compose Bucket Idempotency (pull_request) Failing after 11s
CI / Unit & Component Tests (push) Failing after 1m50s
CI / OCR Service Tests (push) Successful in 16s
CI / Backend Unit Tests (push) Successful in 4m7s
CI / fail2ban Regex (push) Successful in 37s
CI / Compose Bucket Idempotency (push) Failing after 10s
Two root causes:

1. In-flight test: resolveFetch() was the last line, leaving the async
   finally-block writing `training = false` after cleanup destroyed the
   component. Awaiting the button becoming re-enabled ensures the finally
   block settles before cleanup runs.

2. Success-dismiss test: startTraining() schedules setTimeout(5000) which
   fired after cleanup destroyed the component. vi.useFakeTimers() +
   vi.runAllTimers() scoped to the describe block drains the timer while
   the component is still alive.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 10:22:03 +02:00
Marcel
ae1688319e test(annotation): replace synchronous query().toBeNull() with async not.toBeInTheDocument()
Svelte defers DOM updates to microtasks; .query() is a synchronous
snapshot that can fire before the element disappears — making the
absence assertions in AnnotationShape and AnnotationLayer non-deterministic.

Sweeps all 4 instances across both spec files (Sara's ≤5 threshold).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 10:21:24 +02:00
Marcel
7f07180c71 docs(adr-012): add enforcement note and libLoader revisit signal
Some checks failed
CI / OCR Service Tests (push) Successful in 15s
CI / Unit & Component Tests (push) Failing after 2m4s
CI / Backend Unit Tests (push) Successful in 4m6s
CI / fail2ban Regex (push) Successful in 38s
CI / Compose Bucket Idempotency (push) Failing after 10s
- Explicitly states no lint rule is planned; CI guard is the backstop
  (addresses Elicit OQ-001 from PR #536 round 4)
- Adds a "when to revisit" note: extract shared DynamicImportLoader<T>
  if 3+ components adopt the libLoader pattern
  (addresses Markus Keller round-4 observation on PR #536)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 09:57:28 +02:00
Marcel
1ead1f293f ci(coverage): document that birpc guard covers coverage run only
Adds a comment above the assertion step so a future developer diagnosing
a birpc-related failure in `npm test` knows where to find the diagnostic.

Addresses Sara Holt + Tobias Wendt round-4 observation on PR #536.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 09:57:28 +02:00
Marcel
a693f07eca refactor(test): convert TextLayerMock to class syntax in PdfViewer spec
Prototype-style assignment was a vi.mock hoisting artifact from the old
version of the file. Rest of the codebase uses class syntax — aligning.

Addresses Felix Brandt round-4 suggestion on PR #536.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 09:57:28 +02:00
Marcel
3ae7c9da0c test(pdf-renderer): guard init() against repeated calls — libLoader must fire once
Adds idempotency test: calling init() twice must invoke libLoader only once.
Adds `if (pdfjsReady) return;` guard to satisfy the contract.

Addresses Felix Brandt round-4 suggestion on PR #536.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 09:57:28 +02:00
Marcel
729f5c66d6 ci(coverage): use grep -F for birpc guard to avoid BRE escaping
-F (fixed string) matches the literal pattern [birpc] rpc is closed
without relying on BRE bracket escaping, making the intent explicit
and immune to accidental regex interpretation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 09:57:28 +02:00
Marcel
d40f477397 ci(coverage): include coverage log in artifact upload
The birpc guard step writes to /tmp/coverage-test-<run_id>.log and exits 1
when a race is detected. Without this file in the artifact, the evidence
disappears when the runner tears down — only the exit code remained visible.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 09:57:28 +02:00
Marcel
f126634804 refactor(test): remove what-comment from makeFakePdfjsLib
The name already implies it's a fake; the comment described what, not why.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 09:57:28 +02:00
Marcel
bdadff787c test(pdf-renderer): assert init() re-throws when libLoader rejects
.catch(()=>{}) swallowed the rejection, so the test passed vacuously even
if a future refactor silently caught the error. rejects.toThrow() proves
the propagation contract holds before asserting pdfjsReady stays false.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 09:57:28 +02:00
Marcel
cf78957476 ci(coverage): harden coverage guard step
- Add explicit set -eo pipefail so npm test:coverage exit code
  propagates through the pipe (not just tee's always-0 exit)
- Scope log file to github.run_id to prevent stale-log false positives
  on retried steps sharing the same runner /tmp
- Tighten grep pattern to \[birpc\] rpc is closed to avoid matching
  unrelated log lines that happen to contain "rpc is closed"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 09:57:28 +02:00
Marcel
f8dad85020 test(pdf-renderer): document libLoader rejection leaves pdfjsReady false
Regression-protection test: init() propagates the loader rejection
before pdfjsReady is set, so the renderer stays in a safe unready state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 09:57:28 +02:00
Marcel
5cd330de74 docs(pdf-viewer): comment untrack invariant on renderer init
Without untrack, a reactive libLoader prop reference change would
reinitialise the whole renderer and lose all loaded state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 09:57:28 +02:00
Marcel
06b158bf54 refactor(pdf-viewer): export LibLoader type and update callers
Exporting LibLoader gives the type a stable, named identity.
PdfViewer.svelte and PdfViewer.svelte.spec.ts now import it directly
instead of using Parameters<typeof createPdfRenderer>[0].

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 09:57:28 +02:00
Marcel
3594204214 ci(coverage): simplify coverage step and pin shell to bash
- removes unreachable `; exit ${PIPESTATUS[0]}` — already covered by pipefail (Tobias)
- adds explicit `shell: bash` to both new steps for clarity (Tobias)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 09:57:28 +02:00
Marcel
073b6cb45d refactor(test): move PdfViewer import to top and annotate partial fake cast
- import PdfViewer left mid-file from vi.mock hoisting — no longer needed (Sara/Felix)
- adds one-line comment explaining as unknown as cast is an intentional partial fake (Felix)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 09:57:28 +02:00
Marcel
a7e0a66355 docs(adr): 012 — browser-mode test mocking strategy
Documents why vi.mock(module, factory) races with birpc teardown for
dynamically-imported modules, the libLoader injection pattern used to fix
#535, and the residual exceptions ($app/*, $env/*) that are safe to keep
as vi.mock because they are resolved statically before any test runs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 09:57:28 +02:00
Marcel
538adb43a9 ci(guard): fail unit-tests job if [birpc] rpc is closed appears in coverage run
Captures npm run test:coverage output with tee and adds an always-run step
that greps for the teardown-race fingerprint. Any future regression where a
vi.mock factory races with birpc teardown will now surface as an explicit CI
failure rather than a silent exit-1 after all tests report green (#535).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 09:57:28 +02:00
Marcel
115476453a fix(pdf-viewer): replace vi.mock(pdfjs-dist) with injected libLoader prop
Removes both vi.mock('pdfjs-dist', factory) and
vi.mock('pdfjs-dist/build/pdf.worker.min.mjs?url', factory) from
PdfViewer.svelte.spec.ts — the ManualMockedModule registrations that were
racing with vitest-browser-playwright's birpc teardown channel.

PdfViewer.svelte now accepts an optional libLoader prop (typed as
Parameters<typeof createPdfRenderer>[0]) that is passed untracked to
createPdfRenderer(). Tests supply a vi.fn() fake loader directly as a prop;
production code uses the default loader that imports the real pdfjs-dist.
The birpc route handler for pdfjs-dist is never registered, so no teardown
race is possible. Fixes #535.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 09:57:28 +02:00
Marcel
817ec44439 test(pdf-renderer): inject libLoader into createPdfRenderer to eliminate vi.mock factories
Adds an optional LibLoader parameter (defaults to the real pdfjs-dist dynamic
imports) and a failing test that verified the loader is called during init().
This is the first step toward removing ManualMockedModule registrations that
race with vitest-browser-playwright's birpc teardown (#535).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 09:57:28 +02:00
51e2d50dd0 Merge pull request 'fix(ci): replace iproute2 ip with /proc/net/route for gateway detection' (#544) from fix/nightly-caddy-reload into main
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / fail2ban Regex (push) Has been cancelled
CI / Compose Bucket Idempotency (push) Has been cancelled
2026-05-12 09:57:02 +02:00
Marcel
9c26c00eee fix(ci): replace iproute2 ip with /proc/net/route for gateway detection
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / fail2ban Regex (push) Has been cancelled
CI / Compose Bucket Idempotency (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / OCR Service Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / fail2ban Regex (pull_request) Has been cancelled
CI / Compose Bucket Idempotency (pull_request) Has been cancelled
`ip route` (iproute2) is not installed in the Gitea runner container,
causing the smoke test step to exit 127. /proc/net/route is a kernel
virtual file that is always present on Linux; awk decodes the
little-endian hex gateway field to dotted-decimal without any external
binary dependency.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 09:50:56 +02:00
Marcel
6d16be4669 fix(ci): quote \$RESOLVE in all curl calls
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 1m51s
CI / OCR Service Tests (pull_request) Successful in 18s
CI / Backend Unit Tests (pull_request) Successful in 4m1s
CI / fail2ban Regex (pull_request) Successful in 38s
CI / Compose Bucket Idempotency (pull_request) Failing after 11s
CI / Unit & Component Tests (push) Failing after 1m51s
CI / OCR Service Tests (push) Successful in 18s
CI / Backend Unit Tests (push) Successful in 4m10s
CI / fail2ban Regex (push) Successful in 38s
CI / Compose Bucket Idempotency (push) Failing after 10s
Unquoted variable expansion is safe here since the value contains
no spaces or glob characters, but quoting is the correct default
and keeps the script consistent with surrounding style.

Addresses review suggestion by Felix Brandt and Tobias Wendt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 09:26:35 +02:00
Marcel
f1032865f3 fix(ci): guard against empty HOST_IP in smoke test
If `ip route show default` returns no output the old code passed
an empty string to curl --resolve, producing a confusing error 6
("couldn't resolve host") with no indication that gateway detection
had failed.  The new guard exits immediately with a clear message.

Addresses review concern raised by Tobias Wendt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 09:26:35 +02:00
Marcel
3056311c24 fix(ci): resolve smoke test host via bridge gateway, not 127.0.0.1
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 1m50s
CI / OCR Service Tests (pull_request) Successful in 17s
CI / Backend Unit Tests (pull_request) Successful in 4m8s
CI / fail2ban Regex (pull_request) Successful in 38s
CI / Compose Bucket Idempotency (pull_request) Failing after 10s
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / fail2ban Regex (push) Has been cancelled
CI / Unit & Component Tests (push) Has started running
CI / Compose Bucket Idempotency (push) Has been cancelled
Job containers run in bridge network mode (runner-config.yaml). Inside
a bridge-networked container 127.0.0.1 is the container's own loopback;
Caddy on the host is unreachable there, causing an immediate ECONNREFUSED.

Use the Docker bridge gateway IP instead — the host's docker0 interface
where Caddy (bound on 0.0.0.0:443) is reachable from the container.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 09:10:17 +02:00
44 changed files with 1443 additions and 230 deletions

View File

@@ -36,18 +36,55 @@ jobs:
run: npm run lint
working-directory: frontend
- name: Assert no banned vi.mock patterns
shell: bash
run: |
# Literal pdfjs-dist (libLoader pattern — ADR 012)
if grep -rF "vi.mock('pdfjs-dist'" frontend/src/; then
echo "FAIL: banned vi.mock('pdfjs-dist') pattern found — see ADR 012. Use the libLoader prop injection pattern instead."
exit 1
fi
# Async factory with dynamic import in body (named mechanism — ADR 012 / #553).
# Multiline PCRE matches `vi.mock(<arg>, async ... { ... await import(...) ... })`
# across line breaks. __meta__ is excluded because it contains fixture strings
# demonstrating the very pattern this check is meant to forbid.
if grep -rPzln 'vi\.mock\([^)]+,\s*async[^{]*\{[\s\S]*?await\s+import\s*\(' \
--include='*.spec.ts' --include='*.test.ts' \
--exclude-dir='__meta__' \
frontend/src/; then
echo "FAIL: banned async vi.mock factory with dynamic import in body — see ADR 012 / #553. Use a synchronous factory + vi.hoisted instead."
exit 1
fi
- name: Run unit and component tests with coverage
run: npm run test:coverage
shell: bash
run: |
set -eo pipefail
npm run test:coverage 2>&1 | tee /tmp/coverage-test-${{ github.run_id }}.log
working-directory: frontend
env:
TZ: Europe/Berlin
# Diagnostic guard: covers the coverage run only. If `npm test` (above)
# exits 1 with a birpc error, the named pattern appears here — not there.
- name: Assert no birpc teardown race in coverage run
shell: bash
if: always()
run: |
if grep -qF "[birpc] rpc is closed" /tmp/coverage-test-${{ github.run_id }}.log 2>/dev/null; then
echo "FAIL: [birpc] rpc is closed teardown race detected in coverage run"
grep -F "[birpc] rpc is closed" /tmp/coverage-test-${{ github.run_id }}.log
exit 1
fi
- name: Upload coverage reports
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage-reports
path: frontend/coverage/
path: |
frontend/coverage/
/tmp/coverage-test-${{ github.run_id }}.log
- name: Build frontend
run: npm run build
@@ -232,6 +269,7 @@ jobs:
MAIL_HOST=mailpit
MAIL_PORT=1025
APP_MAIL_FROM=noreply@local
IMPORT_HOST_DIR=/tmp/dummy-import
EOF
- name: Bring up minio

View File

@@ -0,0 +1,64 @@
name: Coverage Flake Probe
# Manually-triggered probe for the birpc teardown race documented in ADR 012
# / #553. Runs the full coverage suite 20× in parallel against a single SHA
# and asserts zero `[birpc] rpc is closed` lines across every cell. Verifies
# the acceptance criterion that the race no longer surfaces under coverage.
on:
workflow_dispatch:
jobs:
coverage-flake-probe:
name: Coverage flake probe (run ${{ matrix.run }})
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.58.2-noble
strategy:
fail-fast: false
matrix:
run: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]
steps:
- uses: actions/checkout@v4
- name: Cache node_modules
id: node-modules-cache
uses: actions/cache@v4
with:
path: frontend/node_modules
key: node-modules-${{ hashFiles('frontend/package-lock.json') }}
- name: Install dependencies
if: steps.node-modules-cache.outputs.cache-hit != 'true'
run: npm ci
working-directory: frontend
- name: Compile Paraglide i18n
run: npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide
working-directory: frontend
- name: Run unit and component tests with coverage
shell: bash
run: |
set -eo pipefail
npm run test:coverage 2>&1 | tee /tmp/coverage-test-${{ github.run_id }}-${{ matrix.run }}.log
working-directory: frontend
env:
TZ: Europe/Berlin
- name: Assert no birpc teardown race
shell: bash
if: always()
run: |
if grep -qF "[birpc] rpc is closed" /tmp/coverage-test-${{ github.run_id }}-${{ matrix.run }}.log 2>/dev/null; then
echo "FAIL: [birpc] rpc is closed teardown race detected in run ${{ matrix.run }}"
grep -F "[birpc] rpc is closed" /tmp/coverage-test-${{ github.run_id }}-${{ matrix.run }}.log
exit 1
fi
- name: Upload coverage log on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: coverage-log-run-${{ matrix.run }}
path: /tmp/coverage-test-${{ github.run_id }}-${{ matrix.run }}.log

View File

@@ -158,28 +158,38 @@ jobs:
# public surface works. This step catches: Caddy not reloaded, HSTS
# header dropped, /actuator block bypassed.
#
# --resolve pins staging.raddatz.cloud to the runner's loopback so we
# do NOT depend on the host router doing hairpin NAT (many SOHO
# routers do not, or do so only after a firmware update). SNI still
# uses the public hostname so the cert validates correctly.
# --resolve pins staging.raddatz.cloud to the Docker bridge gateway IP
# (the host) so we do NOT depend on hairpin NAT on the host router.
# 127.0.0.1 cannot be used: job containers run in bridge network mode
# (runner-config.yaml), so 127.0.0.1 is the container's loopback, not
# the host's. The bridge gateway IS the host; Caddy binds 0.0.0.0:443
# and is therefore reachable from the container via that IP.
# SNI still uses the public hostname so the TLS cert validates correctly.
#
# Gateway detection reads /proc/net/route (always present, no package
# required) instead of `ip route` to avoid a dependency on iproute2.
# Field $2=="00000000" is the default route; field $3 is the gateway as
# a little-endian 32-bit hex value which awk decodes to dotted-decimal.
run: |
set -e
HOST="staging.raddatz.cloud"
URL="https://$HOST"
RESOLVE="--resolve $HOST:443:127.0.0.1"
echo "Smoke test: $URL (pinned to 127.0.0.1)"
curl -fsS $RESOLVE --max-time 10 "$URL/login" -o /dev/null
HOST_IP=$(awk 'NR>1 && $2=="00000000"{h=$3;printf "%d.%d.%d.%d\n",strtonum("0x"substr(h,7,2)),strtonum("0x"substr(h,5,2)),strtonum("0x"substr(h,3,2)),strtonum("0x"substr(h,1,2));exit}' /proc/net/route)
[ -n "$HOST_IP" ] || { echo "ERROR: could not detect Docker bridge gateway via /proc/net/route"; exit 1; }
RESOLVE="--resolve $HOST:443:$HOST_IP"
echo "Smoke test: $URL (pinned to $HOST_IP via bridge gateway)"
curl -fsS "$RESOLVE" --max-time 10 "$URL/login" -o /dev/null
# Pin the preload-list-eligible HSTS value, not just header presence:
# a degraded `max-age=1` or a dropped `includeSubDomains; preload` must
# fail this check rather than pass it silently.
curl -fsS $RESOLVE --max-time 10 -I "$URL/" \
curl -fsS "$RESOLVE" --max-time 10 -I "$URL/" \
| grep -Eqi 'strict-transport-security:[[:space:]]*max-age=31536000.*includeSubDomains.*preload'
# Permissions-Policy denies APIs the app does not use (camera,
# microphone, geolocation). A regression that loosens or drops the
# header now fails the smoke step.
curl -fsS $RESOLVE --max-time 10 -I "$URL/" \
curl -fsS "$RESOLVE" --max-time 10 -I "$URL/" \
| grep -Eqi 'permissions-policy:[[:space:]]*camera=\(\),[[:space:]]*microphone=\(\),[[:space:]]*geolocation=\(\)'
status=$(curl -s $RESOLVE -o /dev/null -w "%{http_code}" --max-time 10 "$URL/actuator/health")
status=$(curl -s "$RESOLVE" -o /dev/null -w "%{http_code}" --max-time 10 "$URL/actuator/health")
[ "$status" = "404" ] || { echo "expected 404 from /actuator/health, got $status"; exit 1; }
echo "All smoke checks passed"

View File

@@ -107,26 +107,28 @@ jobs:
- name: Smoke test deployed environment
# See nightly.yml — same three checks, against the prod vhost.
# --resolve pins archiv.raddatz.cloud to the runner's loopback so
# the smoke test does NOT depend on hairpin NAT on the host router.
# --resolve pins to the bridge gateway IP (the host), not 127.0.0.1
# — see nightly.yml for the full network topology explanation.
run: |
set -e
HOST="archiv.raddatz.cloud"
URL="https://$HOST"
RESOLVE="--resolve $HOST:443:127.0.0.1"
echo "Smoke test: $URL (pinned to 127.0.0.1)"
curl -fsS $RESOLVE --max-time 10 "$URL/login" -o /dev/null
HOST_IP=$(ip route show default | awk '/default/ {print $3}')
[ -n "$HOST_IP" ] || { echo "ERROR: could not detect Docker bridge gateway via 'ip route'"; exit 1; }
RESOLVE="--resolve $HOST:443:$HOST_IP"
echo "Smoke test: $URL (pinned to $HOST_IP via bridge gateway)"
curl -fsS "$RESOLVE" --max-time 10 "$URL/login" -o /dev/null
# Pin the preload-list-eligible HSTS value, not just header presence:
# a degraded `max-age=1` or a dropped `includeSubDomains; preload` must
# fail this check rather than pass it silently.
curl -fsS $RESOLVE --max-time 10 -I "$URL/" \
curl -fsS "$RESOLVE" --max-time 10 -I "$URL/" \
| grep -Eqi 'strict-transport-security:[[:space:]]*max-age=31536000.*includeSubDomains.*preload'
# Permissions-Policy denies APIs the app does not use (camera,
# microphone, geolocation). A regression that loosens or drops the
# header now fails the smoke step.
curl -fsS $RESOLVE --max-time 10 -I "$URL/" \
curl -fsS "$RESOLVE" --max-time 10 -I "$URL/" \
| grep -Eqi 'permissions-policy:[[:space:]]*camera=\(\),[[:space:]]*microphone=\(\),[[:space:]]*geolocation=\(\)'
status=$(curl -s $RESOLVE -o /dev/null -w "%{http_code}" --max-time 10 "$URL/actuator/health")
status=$(curl -s "$RESOLVE" -o /dev/null -w "%{http_code}" --max-time 10 "$URL/actuator/health")
[ "$status" = "404" ] || { echo "expected 404 from /actuator/health, got $status"; exit 1; }
echo "All smoke checks passed"

View File

@@ -0,0 +1,134 @@
# ADR 012 — Browser-Mode Test Mocking Strategy
**Status:** Accepted
**Date:** 2026-05-11 (revised 2026-05-12)
**Issues:** [#535 — original incident](https://git.raddatz.cloud/marcel/familienarchiv/issues/535) · [#553 — revision](https://git.raddatz.cloud/marcel/familienarchiv/issues/553)
---
## Context
Vitest browser-mode tests (the `client` project, run with `@vitest/browser-playwright` / Chromium) use a different module resolution path than Node-environment tests. When a spec calls `vi.mock('some-module', factory)`, vitest registers a `ManualMockedModule`. At runtime, every time Chromium requests that module, a playwright route handler intercepts the request and calls the Node worker over **birpc** (`resolveManualMock`) to evaluate the factory and return the module body.
This is safe for modules that are imported **statically** at spec module-eval time (e.g. `$app/navigation`, `$env/static/public`): those requests resolve before the first test runs and well before any teardown occurs.
It is **unsafe** for modules that are imported **dynamically** (e.g. inside an `async onMount`, inside a lazy-loaded chunk): Chromium may fetch the module after the worker's birpc channel has already closed, producing:
```
Error: [birpc] rpc is closed, cannot call "resolveManualMock"
ManualMockedModule.factory node_modules/@vitest/browser/dist/index.js:3221:34
```
This raises an unhandled rejection that exits the vitest process with code 1, even though every test in the run reported green.
`pdfjs-dist` and `pdfjs-dist/build/pdf.worker.min.mjs?url` are loaded via `await Promise.all([import('pdfjs-dist'), import('pdfjs-dist/build/pdf.worker.min.mjs?url')])` inside `usePdfRenderer.svelte.ts::init()`, which is called from `onMount`. These dynamic imports triggered the race.
---
## Decision
**Prefer prop injection over `vi.mock(module, factory)` for any module that is loaded dynamically in browser-mode specs.**
### The libLoader pattern (for external rendering libraries)
When a component depends on a large external library loaded via dynamic import, extract the import into an injectable loader function with a production default:
```typescript
// usePdfRenderer.svelte.ts
type LibLoader = () => Promise<readonly [typeof import('pdfjs-dist'), { default: string }]>;
const defaultLibLoader: LibLoader = () =>
Promise.all([import('pdfjs-dist'), import('pdfjs-dist/build/pdf.worker.min.mjs?url')]);
export function createPdfRenderer(libLoader: LibLoader = defaultLibLoader) { ... }
```
The component threads the loader as an optional prop:
```svelte
<!-- PdfViewer.svelte -->
let { url, ..., libLoader = undefined } = $props();
const renderer = untrack(() => createPdfRenderer(libLoader));
```
Tests supply a synchronous fake — no `vi.mock` needed:
```typescript
const fakePdfjs = { GlobalWorkerOptions: ..., getDocument: vi.fn(), TextLayer: class {} };
const fakeLoader = vi.fn().mockResolvedValue([fakePdfjs, { default: '' }] as const);
render(PdfViewer, { url: '...', libLoader: fakeLoader });
```
### The test-host pattern (for component behaviour)
For components that fetch data or call services, the `*.test-host.svelte` pattern threads the dependency as a prop rather than mocking the module. See `PersonMentionEditor.test-host.svelte` for the canonical example.
---
## Binding invariant: factory bodies must be synchronous (#553)
The original revision of this ADR allowed `vi.mock(virtualModule, factory)` for SvelteKit/Vite virtual modules on the argument that their consumer imports were resolved at static-import time. **That reasoning is wrong.** What matters is what the **factory body** does, not where the mocked module is consumed.
`EnrichmentBlock.svelte.spec.ts` (issue #553) was statically imported and still produced the race: its `vi.mock('$app/stores', async () => { const mod = await import(...); return mod; })` factory performed a dynamic import in its body, and that body was invoked asynchronously when Chromium fetched the manually-mocked module — sometimes after the worker's birpc channel had already closed.
**Therefore: under `**/*.svelte.{test,spec}.ts`, every `vi.mock` factory body must be synchronous. No `await`, no `import(...)`.**
If a factory needs to share state with the spec (a mutable ref, a `vi.fn`, a writable store), use `vi.hoisted()` to lift the reference above `vi.mock`'s implicit hoist:
```ts
const { mockNavigating } = vi.hoisted(() => ({
mockNavigating: { type: null as string | null }
}));
vi.mock('$app/state', () => ({
get navigating() {
return mockNavigating;
}
}));
```
The getter defers the read until consumption time; `vi.hoisted` guarantees the reference is initialised before the (also hoisted) `vi.mock` factory runs. See `DropZone.svelte.spec.ts:9`, `NotificationBell.svelte.spec.ts:6-10`, and `EnrichmentBlock.svelte.spec.ts` for canonical examples.
### Architectural follow-on: prefer `$app/state` over `$app/stores`
`$app/stores` is the deprecated subscription-based store API; `$app/state` is the modern reactive proxy. New components should import from `$app/state`. As part of #553 we migrated `EnrichmentBlock.svelte` from `$app/stores.navigating` to `$app/state.navigating` with `!!navigating.type` — matching the pattern already established in `routes/aktivitaeten/+page.svelte:117` and `routes/documents/+page.svelte:261`. Migration eliminated the *need* to mock a store at all in that spec.
**Pattern note:** When an overlay or dropdown triggers a navigation action, use `<button type="button">` with an `onclick` handler that calls `goto(path)` — do **not** use `<a href="…">` with `e.preventDefault()`. SvelteKit registers its link interceptor as a capture-phase `document` listener, so it fires before the component's bubble-phase `onclick`. By the time `e.preventDefault()` runs the router has already initiated navigation, which tears down the vitest-browser Playwright orchestrator iframe. A `<button>` carries no `href`, so the capture-phase interceptor never fires. See `NotificationDropdown.svelte` for the canonical example.
**Pattern note (#553):** Browser-mode tests run with `data-sveltekit-preload-data="off"` (set in `src/test-setup.ts` via the client project's `setupFiles`). Hover-prefetch otherwise fires real fetch requests for route loader chunks; those requests go through the same Playwright route handler that serves mocked modules. An in-flight prefetch landing after iframe teardown can hit the handler with a closed birpc channel, raising an unhandled rejection.
---
## Binding invariant: one canonical ID per mocked module (#553 — duplicate-id hazard)
The sync-factory invariant above closes one named trigger of the `[birpc] rpc is closed` race. Investigation of a follow-up flake revealed a second, independent trigger: **the same resolved module URL mocked under two distinct ID strings** across or within spec files.
`@vitest/browser-playwright` registers a Playwright `page.context().route(...)` handler per `vi.mock` call. The predicate matches on the module's resolved URL. When two `vi.mock` calls reference the same module under different IDs — for example `'$lib/foo.svelte'` and `'$lib/foo.svelte.js'` (both resolve to the same Svelte rune-module URL) — the registry stores both predicates but the cleanup map only tracks the latest. The orphan route survives session teardown. When the next session loads the same module, the orphan fires, calls `await module.resolve()` against a closed birpc channel, and crashes the run.
This is fixed upstream in [vitest PR #10267](https://github.com/vitest-dev/vitest/pull/10267) (issue [#9957](https://github.com/vitest-dev/vitest/issues/9957)). Until that fix reaches a published `@vitest/browser-playwright` release, we close the gap from two sides:
**The rule.** Every mocked module must be referenced under exactly one ID string across the entire client test suite. Pick the spelling production code uses. For Svelte 5 rune modules (`*.svelte.ts`), the canonical form is the no-extension import (`'$lib/foo.svelte'`) — matches the source file basename and matches Svelte 5 convention. Never mix `.svelte.js` and `.svelte` for the same module across specs.
**Enforcement layers** (added in #553's second cycle, extending the four-layer chain above):
5. **In-suite meta-test** at `frontend/src/__meta__/no-duplicate-mock-ids.test.ts` globs `src/**/*.svelte.{test,spec}.ts`, extracts every `vi.mock` first-arg string, canonicalises by stripping a trailing `.js`/`.ts` after `.svelte`, and fails if any canonical ID is referenced under two or more distinct spellings. Same shape as `no-async-mock-factories.test.ts`.
6. **`patch-package` backport** of PR #10267 at `frontend/patches/@vitest+browser-playwright+4.1.0.patch`. Applied automatically by the `postinstall` hook. Closes the race at the route-handler level — even if a contributor reintroduces a duplicate-ID, the patched `register` handler unroutes the existing predicate before installing the new one.
**When to remove the patch.** Once `@vitest/browser-playwright` ships a release containing PR #10267, delete `patches/@vitest+browser-playwright+4.1.0.patch`. Bump the dependency to the version containing the fix. The in-suite meta-test stays — it's a cheap permanent guard against the contributor-facing pattern, independent of upstream library version.
---
## Consequences
- New browser-mode specs that need to stub an external library **must not** use `vi.mock(externalLib, factory)`. Add a loader/factory parameter to the underlying hook or service instead.
- The CI `unit-tests` job includes a permanent grep guard that fails the build if `rpc is closed` appears in any coverage run log. This catches regressions before they reach the acceptance criterion.
- Acceptance criterion for #535: 60 consecutive green `workflow_dispatch` CI runs against `main` after the fix is merged, with zero `rpc is closed` lines in any log.
- **Enforcement (six layers, defence in depth):**
1. **ESLint `no-restricted-syntax`** in `eslint.config.js` (scoped to `**/*.{spec,test}.ts`) flags two patterns: (a) the literal `vi.mock('pdfjs-dist', ...)` — enforces the libLoader pattern — and (b) any `vi.mock(..., async () => { ... await import(...) ... })` — enforces the synchronous-factory invariant. Both messages point at this ADR. Failure surfaces at save time.
2. **CI grep guard** in `.gitea/workflows/ci.yml` runs before the test suite launches. Mirrors the ESLint patterns with `grep -Pzn`. ~10s round-trip.
3. **In-suite meta-test** at `frontend/src/__meta__/no-async-mock-factories.test.ts` globs `src/**/*.svelte.{test,spec}.ts` and asserts none match the banned pattern. Catches at every vitest invocation — the layer hardest to disable.
4. **CI birpc assert** runs after the coverage step and fails the build if `[birpc] rpc is closed` appears in any log line. Catches the symptom even if all the upstream layers were bypassed.
5. **In-suite duplicate-ID meta-test** at `frontend/src/__meta__/no-duplicate-mock-ids.test.ts` enforces the one-canonical-ID-per-module rule from the duplicate-id-hazard section above.
6. **`patch-package` backport** at `frontend/patches/@vitest+browser-playwright+4.1.0.patch` closes the upstream race itself, applied via `postinstall`. To be removed when `@vitest/browser-playwright` releases [vitest PR #10267](https://github.com/vitest-dev/vitest/pull/10267).
- **Acceptance verification:** `coverage-flake-probe.yml` is a `workflow_dispatch`-triggered matrix workflow that runs the coverage suite 20× in parallel against a single SHA and asserts zero birpc lines. One fire, parallel cost, deterministic signal — replaces accumulating 20 sequential push events.
- **When to revisit the LibLoader home:** If three or more components adopt this pattern, consider extracting a shared `$lib/types/lib-loader.ts` or a generic `DynamicImportLoader<T>` type to avoid parallel type definitions across modules.

View File

@@ -0,0 +1,92 @@
# ADR 013 — Client-Project Branch Coverage Threshold
**Status:** Accepted
**Date:** 2026-05-14
**Issues:** [#556 — threshold drop](https://git.raddatz.cloud/marcel/familienarchiv/issues/556) · [#496 — long-tail-grind tracking](https://git.raddatz.cloud/marcel/familienarchiv/issues/496)
---
## Context
The browser-mode component test suite (`vitest.client-coverage.config.ts`) enforces Istanbul coverage thresholds across `lines`, `functions`, `branches`, and `statements`. The `branches` metric was set to 80%, but the codebase sits at **75%** — below the gate — causing every CI run of `unit-tests` and `coverage-flake-probe` to fail on this check alone, even when all tests are green.
**Measured baseline (2026-05-14, branch `feat/issue-553-birpc-async-mock-factory`, head `2e6cc346`):**
```
branches: 75% (below the 80% gate — reason for this ADR)
lines: ≥ 80%
functions: ≥ 80%
statements: ≥ 80%
```
Reproducer:
```bash
cd frontend && npm ci && npx vitest run -c vitest.client-coverage.config.ts --coverage
```
### The long-tail-grind problem
In Istanbul's branch accounting, when a child component gains test coverage its branches are added to the parent's denominator. A child moving from 40% → 80% coverage can drag a parent from 78% → 72% because more branches in the call graph become reachable and must be covered. This is not a bug — it is how branch accounting works — but it means that on a large SvelteKit application the denominator grows with every coverage improvement, making an arbitrary 80% ceiling a constant grind. Per #496, the expected cost to reach 80% branches from 75% is 30100+ commits with no guarantee of stability.
### Why this layer is different
The 80% branch floor used for backend unit/integration tests is appropriate for Java service code and permission logic. Browser-mode component coverage measures Svelte template branches: conditional class bindings, `{#if}` blocks, empty/loaded/error state guards. These branches have a fundamentally different accounting model and a higher inherent denominator. This ADR **only** lowers the browser-mode component gate; the backend test coverage gates are unaffected.
### Security-relevant uncovered components
The following auth/permission-boundary components currently have low or zero branch coverage. When ratchet-up work begins (see below), these are the highest-priority targets:
- `src/routes/login/+page.svelte`
- `src/routes/forgot-password/+page.svelte`
- `src/routes/reset-password/+page.svelte`
- `src/routes/register/+page.svelte`
Note: the 75% figure already reflects the absence of coverage on these files. Lowering the gate does not create this gap — it makes the existing state legible.
---
## Decision
Drop the `branches` threshold from `80``75` in `frontend/vitest.client-coverage.config.ts`. Leave `lines`, `functions`, and `statements` at `80`.
The 75% figure matches the measured current state, allowing CI to pass while deliberate coverage improvement work (tracked in #496) continues without blocking other PRs. The asymmetry in the thresholds block is intentional and documented with an inline comment pointing here.
---
## Ratchet Rule
The branches threshold ratchets **up by 3 percentage points** when the rolling 3-PR-average client-project branches figure on `main` stays at or above `threshold + 3pp` for ≥ 30 consecutive days. Direction is **up-only** — never lower the floor below 75 without a new ADR superseding this one. Manual today (verify before any `vitest.client-coverage.config.ts` edit); a future automation issue may codify the check.
Concretely:
- When `main` sustains ≥ 78% branches across 3 consecutive PRs for 30 days → raise gate to 78%
- When `main` sustains ≥ 81% branches across 3 consecutive PRs for 30 days → raise gate back to 80%
---
## Non-goals
- **Not** raising actual branch coverage — that is #496's job, tracked separately.
- **Not** touching the server-project coverage configuration (`vitest.config.ts`) — only the client project hits the long-tail-grind pattern.
- **Not** removing or relaxing any existing test files, `skipIf` guards, or axe-playwright accessibility runs.
---
## Consequences
**Easier:**
- CI unblocked — `unit-tests` and `coverage-flake-probe` jobs pass when all tests are green
- The ratchet rule creates a concrete, observable path back to 80%
**Harder:**
- The gate now has near-zero headroom — any branch regression that drops below 75% will fail CI immediately
- The 75% floor must not be treated as a permanent ceiling; the ratchet discipline requires active attention
---
## References
- [#496 — Branch coverage long-tail grind](https://git.raddatz.cloud/marcel/familienarchiv/issues/496)
- [#556 — This threshold drop](https://git.raddatz.cloud/marcel/familienarchiv/issues/556)
- [ADR 012 — Browser-Mode Test Mocking Strategy](./012-browser-test-mocking-strategy.md)
- `frontend/vitest.client-coverage.config.ts` — thresholds block (lines 4451)

View File

@@ -72,6 +72,31 @@ export default defineConfig(
]
}
},
{
files: ['**/*.spec.ts', '**/*.test.ts'],
rules: {
'no-restricted-syntax': [
'error',
{
selector:
"CallExpression[callee.object.name='vi'][callee.property.name='mock'] > Literal[value=/^pdfjs-dist/]",
message:
"Banned: vi.mock('pdfjs-dist', factory) causes a birpc teardown race in browser-mode specs — see ADR 012. Use the libLoader prop injection pattern instead."
},
{
// ADR 012 / #553. The named mechanism: an async vi.mock factory whose
// body performs `await import(...)` produces a late birpc roundtrip
// during worker teardown. The factory body must be synchronous; if
// you need to share state between the spec and the mock, use
// `vi.hoisted` (see DropZone.svelte.spec.ts).
selector:
"CallExpression[callee.object.name='vi'][callee.property.name='mock'][arguments.1.type='ArrowFunctionExpression'][arguments.1.async=true]:has(AwaitExpression > ImportExpression)",
message:
'Banned: vi.mock(..., async () => { await import(...) }) causes a birpc teardown race in browser-mode specs — see ADR 012. Use a synchronous factory + vi.hoisted instead.'
}
]
}
},
{
plugins: { boundaries },
settings: {

View File

@@ -7,6 +7,7 @@
"": {
"name": "frontend",
"version": "0.0.1",
"hasInstallScript": true,
"dependencies": {
"@tiptap/core": "3.22.5",
"@tiptap/extension-mention": "3.22.5",
@@ -40,6 +41,7 @@
"eslint-plugin-svelte": "^3.13.0",
"globals": "^16.5.0",
"openapi-typescript": "^7.8.0",
"patch-package": "^8.0.0",
"playwright": "^1.56.1",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
@@ -3939,6 +3941,13 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@yarnpkg/lockfile": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz",
"integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==",
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -4185,6 +4194,56 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/call-bind": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
"integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"get-intrinsic": "^1.3.0",
"set-function-length": "^1.2.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -4266,6 +4325,22 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/ci-info": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
"integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/sibiraj-s"
}
],
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -4478,6 +4553,24 @@
"node": ">=0.10.0"
}
},
"node_modules/define-data-property": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0",
"es-errors": "^1.3.0",
"gopd": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -4513,6 +4606,21 @@
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.353",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz",
@@ -4546,6 +4654,26 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-module-lexer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
@@ -4553,6 +4681,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
@@ -5084,6 +5225,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/find-yarn-workspace-root": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz",
"integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"micromatch": "^4.0.2"
}
},
"node_modules/flat-cache": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
@@ -5105,6 +5256,21 @@
"dev": true,
"license": "ISC"
},
"node_modules/fs-extra": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
@@ -5140,6 +5306,45 @@
"node": ">=6.9.0"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/get-tsconfig": {
"version": "4.14.0",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
@@ -5179,6 +5384,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -5218,6 +5436,32 @@
"node": ">=8"
}
},
"node_modules/has-property-descriptors": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -5350,6 +5594,22 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-docker": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
"integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
"dev": true,
"license": "MIT",
"bin": {
"is-docker": "cli.js"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -5406,6 +5666,26 @@
"@types/estree": "*"
}
},
"node_modules/is-wsl": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
"integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-docker": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/isarray": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
"dev": true,
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -5579,6 +5859,26 @@
"dev": true,
"license": "MIT"
},
"node_modules/json-stable-stringify": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz",
"integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.8",
"call-bound": "^1.0.4",
"isarray": "^2.0.5",
"jsonify": "^0.0.1",
"object-keys": "^1.1.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/json-stable-stringify-without-jsonify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
@@ -5599,6 +5899,29 @@
"node": ">=6"
}
},
"node_modules/jsonfile": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/jsonify": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz",
"integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==",
"dev": true,
"license": "Public Domain",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -5609,6 +5932,16 @@
"json-buffer": "3.0.1"
}
},
"node_modules/klaw-sync": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz",
"integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.1.11"
}
},
"node_modules/kleur": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
@@ -6004,6 +6337,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
@@ -6160,6 +6503,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/object-keys": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/obug": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
@@ -6171,6 +6524,23 @@
],
"license": "MIT"
},
"node_modules/open": {
"version": "7.4.2",
"resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz",
"integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-docker": "^2.0.0",
"is-wsl": "^2.1.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/openapi-fetch": {
"version": "0.13.8",
"resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.13.8.tgz",
@@ -6319,6 +6689,36 @@
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/patch-package": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz",
"integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@yarnpkg/lockfile": "^1.1.0",
"chalk": "^4.1.2",
"ci-info": "^3.7.0",
"cross-spawn": "^7.0.3",
"find-yarn-workspace-root": "^2.0.0",
"fs-extra": "^10.0.0",
"json-stable-stringify": "^1.0.2",
"klaw-sync": "^6.0.0",
"minimist": "^1.2.6",
"open": "^7.4.2",
"semver": "^7.5.3",
"slash": "^2.0.0",
"tmp": "^0.2.4",
"yaml": "^2.2.2"
},
"bin": {
"patch-package": "index.js"
},
"engines": {
"node": ">=14",
"npm": ">5"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -6989,6 +7389,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
"dev": true,
"license": "MIT",
"dependencies": {
"define-data-property": "^1.1.4",
"es-errors": "^1.3.0",
"function-bind": "^1.1.2",
"get-intrinsic": "^1.2.4",
"gopd": "^1.0.1",
"has-property-descriptors": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -7034,6 +7452,16 @@
"node": ">=18"
}
},
"node_modules/slash": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
"integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@@ -7324,6 +7752,16 @@
"integrity": "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==",
"license": "MIT"
},
"node_modules/tmp": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
"integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.14"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -7486,6 +7924,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/unplugin": {
"version": "2.3.11",
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz",
@@ -8001,6 +8449,22 @@
"dev": true,
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"dev": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yaml-ast-parser": {
"version": "0.0.43",
"resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz",

View File

@@ -8,6 +8,7 @@
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || true && git -C .. config core.hooksPath .husky 2>/dev/null || true",
"postinstall": "patch-package",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
@@ -54,6 +55,7 @@
"eslint-plugin-svelte": "^3.13.0",
"globals": "^16.5.0",
"openapi-typescript": "^7.8.0",
"patch-package": "^8.0.0",
"playwright": "^1.56.1",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",

View File

@@ -0,0 +1,62 @@
diff --git a/node_modules/@vitest/browser-playwright/dist/index.js b/node_modules/@vitest/browser-playwright/dist/index.js
index 5d0d37b..821d7b4 100644
--- a/node_modules/@vitest/browser-playwright/dist/index.js
+++ b/node_modules/@vitest/browser-playwright/dist/index.js
@@ -935,7 +935,7 @@ class PlaywrightBrowserProvider {
createMocker() {
const idPreficates = new Map();
const sessionIds = new Map();
- function createPredicate(sessionId, url) {
+ function createPredicate(url) {
const moduleUrl = new URL(url, "http://localhost");
const predicate = (url) => {
if (url.searchParams.has("_vitest_original")) {
@@ -960,11 +960,7 @@ class PlaywrightBrowserProvider {
}
return true;
};
- const ids = sessionIds.get(sessionId) || [];
- ids.push(moduleUrl.href);
- sessionIds.set(sessionId, ids);
- idPreficates.set(predicateKey(sessionId, moduleUrl.href), predicate);
- return predicate;
+ return { url: moduleUrl.href, predicate };
}
function predicateKey(sessionId, url) {
return `${sessionId}:${url}`;
@@ -972,7 +968,23 @@ class PlaywrightBrowserProvider {
return {
register: async (sessionId, module) => {
const page = this.getPage(sessionId);
- await page.context().route(createPredicate(sessionId, module.url), async (route) => {
+ const { url: moduleUrl, predicate } = createPredicate(module.url);
+ const key = predicateKey(sessionId, moduleUrl);
+ // Backport of vitest PR #10267: if a route handler is already
+ // registered for this resolved module URL in this session,
+ // unroute it before installing the new one. Without this guard,
+ // duplicate-id mocks (e.g. '$lib/foo.svelte' + '$lib/foo.svelte.js')
+ // leak an orphan route whose handler crashes after the next
+ // session's birpc channel closes.
+ const existingPredicate = idPreficates.get(key);
+ if (existingPredicate) {
+ await page.context().unroute(existingPredicate);
+ }
+ const ids = sessionIds.get(sessionId) ?? new Set();
+ ids.add(moduleUrl);
+ sessionIds.set(sessionId, ids);
+ idPreficates.set(key, predicate);
+ await page.context().route(predicate, async (route) => {
if (module.type === "manual") {
const exports$1 = Object.keys(await module.resolve());
const body = createManualModuleSource(module.url, exports$1);
@@ -1033,8 +1045,8 @@ class PlaywrightBrowserProvider {
},
clear: async (sessionId) => {
const page = this.getPage(sessionId);
- const ids = sessionIds.get(sessionId) || [];
- const promises = ids.map((id) => {
+ const ids = sessionIds.get(sessionId) ?? new Set();
+ const promises = [...ids].map((id) => {
const key = predicateKey(sessionId, id);
const predicate = idPreficates.get(key);
if (predicate) {

View File

@@ -0,0 +1,20 @@
import { describe, it, expect } from 'vitest';
// Browser-mode tests must run with SvelteKit's hover-prefetch disabled.
// Hover-prefetch fires real `fetch` requests for the target route's loader
// chunks; those go through the same Playwright route handler that serves
// mocked modules. Even after `cleanup()` tears down the iframe, an in-flight
// prefetch can still hit the handler — and if the worker's birpc channel has
// closed by then, the handler raises an unhandled rejection. ADR-012 / #553.
//
// This test enforces that the test-setup file ran and switched preload-data
// off on `document.body` before any spec started rendering.
describe('browser test setup', () => {
it('disables SvelteKit loader-data prefetch on document.body', () => {
expect(document.body.dataset.sveltekitPreloadData).toBe('off');
});
it('disables SvelteKit route-code prefetch on document.body', () => {
expect(document.body.dataset.sveltekitPreloadCode).toBe('off');
});
});

View File

@@ -0,0 +1,82 @@
import { describe, it, expect } from 'vitest';
import { readdirSync, readFileSync } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
// Belt-and-braces detector for the birpc teardown race named in ADR-012 / #553.
// ESLint catches the pattern at save time, CI grep catches it before the test
// suite launches, and this in-suite test catches it at every vitest invocation —
// the layer hardest to disable or scope around.
//
// We scan source text rather than parsing AST: fast, no parser dependency,
// good enough for the named anti-pattern. The pattern matches
// `vi.mock(<arg>, async ... { ... await import(...) ... })`.
const ASYNC_MOCK_WITH_DYNAMIC_IMPORT = /vi\.mock\([^)]*,\s*async[^{]*\{[\s\S]*?await\s+import\s*\(/;
export function hasAsyncMockFactoryWithDynamicImport(source: string): boolean {
return ASYNC_MOCK_WITH_DYNAMIC_IMPORT.test(source);
}
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const SRC_ROOT = path.resolve(__dirname, '..');
function findBrowserSpecs(): string[] {
const entries = readdirSync(SRC_ROOT, { recursive: true, withFileTypes: true });
return entries
.filter(
(e) =>
e.isFile() && (e.name.endsWith('.svelte.test.ts') || e.name.endsWith('.svelte.spec.ts'))
)
.map((e) => path.join(e.parentPath ?? (e as { path: string }).path, e.name));
}
describe('scan: hasAsyncMockFactoryWithDynamicImport', () => {
it('flags async vi.mock factory with await import in body', () => {
const fixture = `vi.mock('$app/stores', async () => {
const mod = await import('./__mocks__/navigatingStore');
return { navigating: mod.navigatingStore };
});`;
expect(hasAsyncMockFactoryWithDynamicImport(fixture)).toBe(true);
});
it('does not flag sync vi.mock factory', () => {
const fixture = `vi.mock('$app/state', () => ({ navigating: { type: null } }));`;
expect(hasAsyncMockFactoryWithDynamicImport(fixture)).toBe(false);
});
it('does not flag async vi.mock factory without dynamic import', () => {
const fixture = `vi.mock('foo', async () => {
const x = await Promise.resolve(42);
return { bar: x };
});`;
expect(hasAsyncMockFactoryWithDynamicImport(fixture)).toBe(false);
});
it('does not flag dynamic import outside any vi.mock', () => {
const fixture = `async function load() {
const mod = await import('./something');
return mod.default;
}`;
expect(hasAsyncMockFactoryWithDynamicImport(fixture)).toBe(false);
});
it('flags async factory written as async function expression', () => {
const fixture = `vi.mock('foo', async function () {
const mod = await import('./bar');
return mod;
});`;
expect(hasAsyncMockFactoryWithDynamicImport(fixture)).toBe(true);
});
});
describe('browser specs: no async vi.mock factory contains await import', () => {
it('every src/**/*.svelte.{test,spec}.ts file is clean', () => {
const specFiles = findBrowserSpecs();
expect(specFiles.length).toBeGreaterThan(0);
const offenders = specFiles.filter((file) =>
hasAsyncMockFactoryWithDynamicImport(readFileSync(file, 'utf-8'))
);
expect(offenders).toEqual([]);
});
});

View File

@@ -0,0 +1,130 @@
import { describe, it, expect } from 'vitest';
import { readdirSync, readFileSync } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
// Belt-and-braces detector for the duplicate-id birpc race named in
// ADR-012 / #553. When the same resolved module URL is mocked via two
// distinct vi.mock id strings (e.g. '$lib/foo.svelte' and
// '$lib/foo.svelte.js'), @vitest/browser-playwright registers two
// Playwright routes against one cleanup slot — the orphan survives, fires
// after the next session's birpc closes, and crashes the run with
// "[birpc] rpc is closed, cannot call resolveManualMock".
//
// Fixed upstream in vitest PR #10267; until that fix reaches a published
// release, normalisation in user-land is the practical guard. This test
// catches the pattern at every vitest invocation — the layer hardest to
// disable or scope around.
const VI_MOCK_ID = /vi\.mock\(\s*['"]([^'"]+)['"]/g;
function extractMockIds(source: string): string[] {
const ids: string[] = [];
for (const match of source.matchAll(VI_MOCK_ID)) {
ids.push(match[1]);
}
return ids;
}
function canonicalise(id: string): string {
if (id.endsWith('.svelte.js')) return id.slice(0, -3);
if (id.endsWith('.svelte.ts')) return id.slice(0, -3);
return id;
}
export function findDuplicateMockIds(
specSources: Record<string, string>
): Map<string, Set<string>> {
const byCanonical = new Map<string, Set<string>>();
for (const source of Object.values(specSources)) {
for (const raw of extractMockIds(source)) {
const canonical = canonicalise(raw);
const existing = byCanonical.get(canonical) ?? new Set<string>();
existing.add(raw);
byCanonical.set(canonical, existing);
}
}
const duplicates = new Map<string, Set<string>>();
for (const [canonical, raws] of byCanonical) {
if (raws.size >= 2) duplicates.set(canonical, raws);
}
return duplicates;
}
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const SRC_ROOT = path.resolve(__dirname, '..');
function findBrowserSpecs(): string[] {
const entries = readdirSync(SRC_ROOT, { recursive: true, withFileTypes: true });
return entries
.filter(
(e) =>
e.isFile() && (e.name.endsWith('.svelte.test.ts') || e.name.endsWith('.svelte.spec.ts'))
)
.map((e) => path.join(e.parentPath ?? (e as { path: string }).path, e.name));
}
describe('scan: findDuplicateMockIds', () => {
it('flags two specs mocking the same module under .svelte and .svelte.js', () => {
const dup = findDuplicateMockIds({
'a.spec.ts': `vi.mock('$lib/foo.svelte', () => ({}));`,
'b.spec.ts': `vi.mock('$lib/foo.svelte.js', () => ({}));`
});
expect(dup.get('$lib/foo.svelte')).toEqual(new Set(['$lib/foo.svelte', '$lib/foo.svelte.js']));
});
it('does not flag two specs both using $lib/foo.svelte', () => {
const dup = findDuplicateMockIds({
'a.spec.ts': `vi.mock('$lib/foo.svelte', () => ({}));`,
'b.spec.ts': `vi.mock('$lib/foo.svelte', () => ({}));`
});
expect(dup.size).toBe(0);
});
it('does not flag $app/state and $app/stores (different modules)', () => {
const dup = findDuplicateMockIds({
'a.spec.ts': `vi.mock('$app/state', () => ({}));`,
'b.spec.ts': `vi.mock('$app/stores', () => ({}));`
});
expect(dup.size).toBe(0);
});
it('does not flag $lib/foo and $lib/bar (different canonical paths)', () => {
const dup = findDuplicateMockIds({
'a.spec.ts': `vi.mock('$lib/foo', () => ({}));`,
'b.spec.ts': `vi.mock('$lib/bar', () => ({}));`
});
expect(dup.size).toBe(0);
});
it('flags both spellings within a single file', () => {
const dup = findDuplicateMockIds({
'a.spec.ts': `
vi.mock('$lib/foo.svelte', () => ({}));
vi.mock('$lib/foo.svelte.js', () => ({}));
`
});
expect(dup.get('$lib/foo.svelte')?.size).toBe(2);
});
it('canonicalises .svelte.ts the same way as .svelte.js', () => {
const dup = findDuplicateMockIds({
'a.spec.ts': `vi.mock('$lib/foo.svelte', () => ({}));`,
'b.spec.ts': `vi.mock('$lib/foo.svelte.ts', () => ({}));`
});
expect(dup.get('$lib/foo.svelte')?.size).toBe(2);
});
});
describe('browser specs: no duplicate-id vi.mock calls across the suite', () => {
it('every mocked module is referenced under exactly one id string', () => {
const specFiles = findBrowserSpecs();
expect(specFiles.length).toBeGreaterThan(0);
const sources = Object.fromEntries(
specFiles.map((file) => [file, readFileSync(file, 'utf-8')])
);
const duplicates = findDuplicateMockIds(sources);
const report = Object.fromEntries([...duplicates].map(([k, v]) => [k, [...v]]));
expect(report).toEqual({});
});
});

View File

@@ -79,7 +79,7 @@ function href(n: NotificationItem): string {
<ul role="list" class="flex flex-col gap-2">
{#each unread as n (n.id)}
<li
class="fade-in group flex items-start gap-3 rounded-sm p-2 transition-colors hover:bg-canvas"
class="chronik-fade-in group flex items-start gap-3 rounded-sm p-2 transition-colors hover:bg-canvas"
>
<a
href={href(n)}
@@ -124,26 +124,3 @@ function href(n: NotificationItem): string {
</ul>
{/if}
</section>
<style>
.fade-in {
animation: chronik-fade-in 160ms ease-out;
}
@keyframes chronik-fade-in {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.fade-in {
animation: none;
}
}
</style>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { navigating } from '$app/stores';
import { navigating } from '$app/state';
import DashboardNeedsMetadata from './DashboardNeedsMetadata.svelte';
import UploadSuccessBanner from './UploadSuccessBanner.svelte';
@@ -18,7 +18,7 @@ interface Props {
let { topDocs, totalCount, bannerCount, onBannerClose }: Props = $props();
const showSkeleton = $derived(!!$navigating && topDocs.length === 0);
const showSkeleton = $derived(!!navigating.type && topDocs.length === 0);
const showBlock = $derived(topDocs.length > 0 || bannerCount > 0 || showSkeleton);
</script>

View File

@@ -2,19 +2,23 @@ import { describe, it, expect, afterEach, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
// The store must live in a separate module because vi.mock factories are
// hoisted and cannot reference top-level variables defined in this file.
import { navigatingStore } from './__mocks__/navigatingStore';
import EnrichmentBlock from './EnrichmentBlock.svelte';
vi.mock('$app/stores', async () => {
const mod = await import('./__mocks__/navigatingStore');
return { navigating: mod.navigatingStore };
});
// Hoist the mutable navigation reference so vi.mock's factory (also hoisted)
// can read it via a getter. Sync factory, no dynamic import: ADR-012 invariant.
const { mockNavigating } = vi.hoisted(() => ({
mockNavigating: { type: null as string | null }
}));
vi.mock('$app/state', () => ({
get navigating() {
return mockNavigating;
}
}));
afterEach(() => {
cleanup();
navigatingStore.set(null);
mockNavigating.type = null;
});
type Doc = { id: string; title: string; uploadedAt: string };
@@ -65,8 +69,8 @@ describe('EnrichmentBlock', () => {
await expect.element(page.getByTestId('dashboard-needs-metadata')).toBeInTheDocument();
});
it('renders the skeleton when $navigating is active and topDocs is empty', async () => {
navigatingStore.set({ type: 'link' });
it('renders the skeleton when navigation is active and topDocs is empty', async () => {
mockNavigating.type = 'link';
render(EnrichmentBlock, {
topDocs: [],
totalCount: 0,
@@ -76,8 +80,8 @@ describe('EnrichmentBlock', () => {
await expect.element(page.getByTestId('enrichment-block-skeleton')).toBeInTheDocument();
});
it('does not render the skeleton when topDocs is non-empty even during $navigating', async () => {
navigatingStore.set({ type: 'link' });
it('does not render the skeleton when topDocs is non-empty even during navigation', async () => {
mockNavigating.type = 'link';
render(EnrichmentBlock, {
topDocs: [doc('d1')],
totalCount: 1,

View File

@@ -1,3 +0,0 @@
import { writable } from 'svelte/store';
export const navigatingStore = writable<unknown | null>(null);

View File

@@ -107,7 +107,7 @@ describe('AnnotationLayer', () => {
});
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull();
await expect.element(page.getByTestId('annotation-delete-ann-1')).not.toBeInTheDocument();
});
it('does not show delete button when canDraw is false even if annotation is active', async () => {
@@ -120,6 +120,6 @@ describe('AnnotationLayer', () => {
});
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull();
await expect.element(page.getByTestId('annotation-delete-ann-1')).not.toBeInTheDocument();
});
});

View File

@@ -45,7 +45,7 @@ describe('AnnotationShape', () => {
onpointerleave: () => {}
});
expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull();
await expect.element(page.getByTestId('annotation-delete-ann-1')).not.toBeInTheDocument();
});
it('does not show delete button when showDelete is true but neither hovered nor active', async () => {
@@ -60,7 +60,7 @@ describe('AnnotationShape', () => {
onpointerleave: () => {}
});
expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull();
await expect.element(page.getByTestId('annotation-delete-ann-1')).not.toBeInTheDocument();
});
it('shows delete button when showDelete is true and isHovered is true', async () => {

View File

@@ -5,9 +5,6 @@ import { page } from 'vitest/browser';
vi.mock('$lib/shared/services/confirm.svelte', () => ({
getConfirmService: () => ({ confirm: async () => false })
}));
vi.mock('$lib/shared/services/confirm.svelte.js', () => ({
getConfirmService: () => ({ confirm: async () => false })
}));
const { default: TranscriptionEditView } = await import('./TranscriptionEditView.svelte');
import type { TranscriptionBlockData } from '$lib/shared/types';

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount, setContext } from 'svelte';
import { createPdfRenderer } from '$lib/document/viewer/usePdfRenderer.svelte';
import { onMount, setContext, untrack } from 'svelte';
import { createPdfRenderer, type LibLoader } from '$lib/document/viewer/usePdfRenderer.svelte';
import PdfControls from './PdfControls.svelte';
import AnnotationLayer from '$lib/document/annotation/AnnotationLayer.svelte';
import type { Annotation } from '$lib/shared/types';
@@ -21,7 +21,8 @@ let {
onDeleteAnnotationRequest,
documentFileHash,
annotationsDimmed = false,
flashAnnotationId = null
flashAnnotationId = null,
libLoader = undefined
}: {
url: string;
documentId?: string;
@@ -35,9 +36,11 @@ let {
documentFileHash?: string | null;
annotationsDimmed?: boolean;
flashAnnotationId?: string | null;
libLoader?: LibLoader;
} = $props();
const renderer = createPdfRenderer();
// untrack: libLoader prop change must not reinitialise the renderer
const renderer = untrack(() => createPdfRenderer(libLoader));
// Canvas and text layer container refs — bound via bind:this
let canvasEl = $state<HTMLCanvasElement | null>(null);

View File

@@ -1,53 +0,0 @@
import { vi, describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
// pdfjs-dist is a rendering dependency — we mock it so unit tests don't need
// a real browser PDF engine. The interesting behaviour under test here is the
// component's own UI logic (controls, page counter), not pdfjs internals.
vi.mock('pdfjs-dist', () => {
function TextLayerMock() {}
TextLayerMock.prototype.render = () => Promise.resolve();
TextLayerMock.prototype.cancel = () => {};
return {
GlobalWorkerOptions: { workerSrc: '' },
getDocument: vi.fn().mockReturnValue({
promise: Promise.resolve({
numPages: 2,
getPage: vi.fn().mockResolvedValue({
getViewport: vi.fn().mockReturnValue({ width: 595, height: 842 }),
render: vi.fn().mockReturnValue({ promise: Promise.resolve() }),
streamTextContent: vi.fn().mockReturnValue(new ReadableStream())
})
})
}),
TextLayer: TextLayerMock
};
});
vi.mock('pdfjs-dist/build/pdf.worker.min.mjs?url', () => ({ default: '' }));
import PdfViewer from './PdfViewer.svelte';
afterEach(cleanup);
describe('PdfViewer', () => {
it('shows previous and next page navigation buttons', async () => {
render(PdfViewer, { url: '/api/documents/test-id/file' });
await expect.element(page.getByRole('button', { name: /zurück/i })).toBeInTheDocument();
await expect.element(page.getByRole('button', { name: /weiter/i })).toBeInTheDocument();
});
it('shows zoom controls', async () => {
render(PdfViewer, { url: '/api/documents/test-id/file' });
await expect.element(page.getByRole('button', { name: /vergrößern/i })).toBeInTheDocument();
await expect.element(page.getByRole('button', { name: /verkleinern/i })).toBeInTheDocument();
});
it('displays the page counter once the PDF has loaded', async () => {
render(PdfViewer, { url: '/api/documents/test-id/file' });
// Mock resolves synchronously, so "1 / 2" should appear quickly
await expect.element(page.getByText(/1\s*\/\s*2/)).toBeInTheDocument();
});
});

View File

@@ -1,43 +1,20 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
vi.mock('pdfjs-dist', () => {
function TextLayerMock() {}
TextLayerMock.prototype.render = () => Promise.resolve();
TextLayerMock.prototype.cancel = () => {};
return {
GlobalWorkerOptions: { workerSrc: '' },
getDocument: vi.fn().mockReturnValue({
promise: Promise.resolve({
numPages: 2,
getPage: vi.fn().mockResolvedValue({
getViewport: vi.fn().mockReturnValue({ width: 595, height: 842 }),
render: vi.fn().mockReturnValue({ promise: Promise.resolve() }),
streamTextContent: vi.fn().mockReturnValue(new ReadableStream())
})
})
}),
TextLayer: TextLayerMock
};
});
vi.mock('pdfjs-dist/build/pdf.worker.min.mjs?url', () => ({ default: '' }));
const { default: PdfViewer } = await import('./PdfViewer.svelte');
import PdfViewer from './PdfViewer.svelte';
import { makeFakeLibLoader } from './testHelpers';
afterEach(cleanup);
describe('PdfViewer — empty / error states', () => {
it('renders the no-file placeholder when url is empty', async () => {
render(PdfViewer, { url: '' });
render(PdfViewer, { url: '', libLoader: makeFakeLibLoader() });
await expect.element(page.getByText('Keine Datei vorhanden')).toBeVisible();
});
it('does not render the controls when url is empty', async () => {
render(PdfViewer, { url: '' });
render(PdfViewer, { url: '', libLoader: makeFakeLibLoader() });
const buttons = document.querySelectorAll('button');
expect(buttons.length).toBe(0);
@@ -49,10 +26,10 @@ describe('PdfViewer — loaded state', () => {
render(PdfViewer, {
url: '/api/documents/test/file',
documentId: 'test',
annotationReloadKey: 0
annotationReloadKey: 0,
libLoader: makeFakeLibLoader()
});
// PdfControls renders its nav + zoom buttons once the document.promise resolves.
await expect.element(page.getByRole('button', { name: 'Zurück' })).toBeVisible();
await expect.element(page.getByRole('button', { name: 'Weiter' })).toBeVisible();
await expect.element(page.getByRole('button', { name: 'Vergrößern' })).toBeVisible();
@@ -63,7 +40,8 @@ describe('PdfViewer — loaded state', () => {
render(PdfViewer, {
url: '/api/documents/test/file',
documentId: 'test',
annotationsDimmed: true
annotationsDimmed: true,
libLoader: makeFakeLibLoader()
});
await vi.waitFor(() => {
@@ -96,11 +74,10 @@ describe('PdfViewer — loaded state', () => {
url: '/api/documents/test/file',
documentId: 'test',
transcribeMode: true,
documentFileHash: 'match'
documentFileHash: 'match',
libLoader: makeFakeLibLoader()
});
// transcribeMode forces showAnnotations=true; toggle button surfaces with "hide" label
// (only when annotationCount > 0).
await expect
.element(page.getByRole('button', { name: /annotierungen verbergen/i }))
.toBeVisible();
@@ -113,7 +90,8 @@ describe('PdfViewer — loaded state', () => {
render(PdfViewer, {
url: '/api/documents/test/file',
documentId: 'test',
documentFileHash: 'abc123'
documentFileHash: 'abc123',
libLoader: makeFakeLibLoader()
});
await vi.waitFor(() => {
@@ -125,7 +103,8 @@ describe('PdfViewer — loaded state', () => {
render(PdfViewer, {
url: '/api/documents/test/file',
documentId: 'test',
flashAnnotationId: 'ann-flashing'
flashAnnotationId: 'ann-flashing',
libLoader: makeFakeLibLoader()
});
await expect.element(page.getByRole('button', { name: 'Zurück' })).toBeVisible();
@@ -135,7 +114,8 @@ describe('PdfViewer — loaded state', () => {
render(PdfViewer, {
url: '/api/documents/test/file',
documentId: 'test',
blockNumbers: { 'ann-1': 1, 'ann-2': 2 }
blockNumbers: { 'ann-1': 1, 'ann-2': 2 },
libLoader: makeFakeLibLoader()
});
await expect.element(page.getByRole('button', { name: 'Zurück' })).toBeVisible();
@@ -145,7 +125,8 @@ describe('PdfViewer — loaded state', () => {
render(PdfViewer, {
url: '/api/documents/test/file',
documentId: 'test',
activeAnnotationId: 'ann-1'
activeAnnotationId: 'ann-1',
libLoader: makeFakeLibLoader()
});
await expect.element(page.getByRole('button', { name: 'Zurück' })).toBeVisible();
@@ -156,10 +137,10 @@ describe('PdfViewer — loaded state', () => {
url: '/api/documents/test/file',
documentId: 'test',
transcribeMode: true,
activeAnnotationId: 'ann-1'
activeAnnotationId: 'ann-1',
libLoader: makeFakeLibLoader()
});
// Without an annotations fetch, the visibility toggle is hidden — just assert the always-on nav.
await expect.element(page.getByRole('button', { name: 'Zurück' })).toBeVisible();
await expect.element(page.getByRole('button', { name: 'Weiter' })).toBeVisible();
});
@@ -169,7 +150,8 @@ describe('PdfViewer — loaded state', () => {
render(PdfViewer, {
url: '/api/documents/test/file',
documentId: 'test',
onAnnotationClick
onAnnotationClick,
libLoader: makeFakeLibLoader()
});
await expect.element(page.getByRole('button', { name: 'Zurück' })).toBeVisible();
@@ -199,7 +181,8 @@ describe('PdfViewer — loaded state', () => {
render(PdfViewer, {
url: '/api/documents/test/file',
documentId: 'test',
documentFileHash: 'new-hash'
documentFileHash: 'new-hash',
libLoader: makeFakeLibLoader()
});
await vi.waitFor(() => {
@@ -234,10 +217,10 @@ describe('PdfViewer — loaded state', () => {
render(PdfViewer, {
url: '/api/documents/test/file',
documentId: 'test',
documentFileHash: 'matching-hash'
documentFileHash: 'matching-hash',
libLoader: makeFakeLibLoader()
});
// Controls finish mounting, and the outdated notice stays absent.
await expect.element(page.getByRole('button', { name: 'Zurück' })).toBeVisible();
expect(document.querySelector('[data-testid="annotation-outdated-notice"]')).toBeNull();
} finally {
@@ -250,10 +233,10 @@ describe('PdfViewer — loaded state', () => {
try {
render(PdfViewer, {
url: '/api/documents/test/file',
documentId: 'test'
documentId: 'test',
libLoader: makeFakeLibLoader()
});
// PDF rendering does not depend on the annotations fetch — controls still appear.
await expect.element(page.getByRole('button', { name: 'Zurück' })).toBeVisible();
expect(document.querySelector('[data-testid="annotation-outdated-notice"]')).toBeNull();
} finally {
@@ -268,7 +251,8 @@ describe('PdfViewer — loaded state', () => {
try {
render(PdfViewer, {
url: '/api/documents/test/file',
documentId: 'test'
documentId: 'test',
libLoader: makeFakeLibLoader()
});
await expect.element(page.getByRole('button', { name: 'Zurück' })).toBeVisible();
@@ -277,4 +261,21 @@ describe('PdfViewer — loaded state', () => {
fetchSpy.mockRestore();
}
});
it('shows previous and next page navigation buttons', async () => {
render(PdfViewer, { url: '/api/documents/test-id/file', libLoader: makeFakeLibLoader() });
await expect.element(page.getByRole('button', { name: /zurück/i })).toBeVisible();
await expect.element(page.getByRole('button', { name: /weiter/i })).toBeVisible();
});
it('shows zoom controls', async () => {
render(PdfViewer, { url: '/api/documents/test-id/file', libLoader: makeFakeLibLoader() });
await expect.element(page.getByRole('button', { name: /vergrößern/i })).toBeVisible();
await expect.element(page.getByRole('button', { name: /verkleinern/i })).toBeVisible();
});
it('displays the page counter once the PDF has loaded', async () => {
render(PdfViewer, { url: '/api/documents/test-id/file', libLoader: makeFakeLibLoader() });
await expect.element(page.getByText(/1\s*\/\s*2/)).toBeVisible();
});
});

View File

@@ -0,0 +1,31 @@
import { vi } from 'vitest';
import type { LibLoader } from './usePdfRenderer.svelte';
export function makeFakePdfjsLib() {
class TextLayerMock {
render() {
return Promise.resolve();
}
cancel() {}
}
return {
GlobalWorkerOptions: { workerSrc: '' },
getDocument: vi.fn().mockReturnValue({
promise: Promise.resolve({
numPages: 2,
getPage: vi.fn().mockResolvedValue({
getViewport: vi.fn().mockReturnValue({ width: 595, height: 842 }),
render: vi.fn().mockReturnValue({ promise: Promise.resolve() }),
streamTextContent: vi.fn().mockReturnValue(new ReadableStream())
})
})
}),
TextLayer: TextLayerMock
} as unknown as typeof import('pdfjs-dist');
}
export function makeFakeLibLoader(): LibLoader {
const fakePdfjs = makeFakePdfjsLib();
return vi.fn().mockResolvedValue([fakePdfjs, { default: '' }] as const);
}

View File

@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import { createPdfRenderer } from './usePdfRenderer.svelte';
import { makeFakeLibLoader } from './testHelpers';
// Note: init() and loadDocument() require pdfjsLib (browser module).
// These tests cover pure state logic only — bounds clamping and zoom limits.
@@ -122,39 +123,36 @@ describe('createPdfRenderer', () => {
expect(r.scale).toBe(before);
});
it('init() is callable and resolves without throwing in browser env', async () => {
const r = createPdfRenderer();
it('init() sets pdfjsReady to true when loader resolves', async () => {
const r = createPdfRenderer(makeFakeLibLoader());
await expect(r.init()).resolves.toBeUndefined();
// pdfjsReady is now true
expect(r.pdfjsReady).toBe(true);
});
it('after init, loadDocument with a bogus URL sets error', async () => {
const r = createPdfRenderer();
it('after init, loadDocument completes and loading returns to false', async () => {
const r = createPdfRenderer(makeFakeLibLoader());
await r.init();
await r.loadDocument('about:invalid-pdf');
// Either error is set or loading flips back to false — both are acceptable
await r.loadDocument('/some/path');
expect(r.loading).toBe(false);
});
it('renderCurrentPage is a no-op when canvasEl is null but pdfjsLib is initialized', async () => {
const r = createPdfRenderer();
const r = createPdfRenderer(makeFakeLibLoader());
await r.init();
// Without setElements, canvasEl is null — early return
await expect(r.renderCurrentPage()).resolves.toBeUndefined();
});
it('renderCurrentPage is a no-op when textLayerEl is null', async () => {
const r = createPdfRenderer();
const r = createPdfRenderer(makeFakeLibLoader());
await r.init();
// Set only canvas, leave textLayer unset is not directly testable;
// confirm calling without elements wired returns early.
// Without setElements, textLayerEl is null — early return
await expect(r.renderCurrentPage()).resolves.toBeUndefined();
});
it('init() can be called multiple times safely', async () => {
const r = createPdfRenderer();
const r = createPdfRenderer(makeFakeLibLoader());
await r.init();
await r.init();
expect(r.pdfjsReady).toBe(true);
@@ -173,4 +171,57 @@ describe('createPdfRenderer', () => {
r.goToPage(1);
expect(r.currentPage).toBe(1);
});
it('calls injected libLoader during init and sets pdfjsReady', async () => {
const fakePdfjs = {
GlobalWorkerOptions: { workerSrc: '' },
getDocument: vi.fn(),
TextLayer: class {}
} as unknown as typeof import('pdfjs-dist');
const fakeLoader = vi.fn().mockResolvedValue([fakePdfjs, { default: '' }] as const);
const r = createPdfRenderer(fakeLoader);
await r.init();
expect(fakeLoader).toHaveBeenCalledOnce();
expect(r.pdfjsReady).toBe(true);
});
it('leaves pdfjsReady false when libLoader rejects', async () => {
const failingLoader = vi.fn().mockRejectedValue(new Error('load failed'));
const r = createPdfRenderer(failingLoader);
await expect(r.init()).rejects.toThrow('load failed');
expect(r.pdfjsReady).toBe(false);
});
it('init() is idempotent — libLoader called only once on repeated calls', async () => {
const fakePdfjs = {
GlobalWorkerOptions: { workerSrc: '' },
getDocument: vi.fn(),
TextLayer: class {}
} as unknown as typeof import('pdfjs-dist');
const fakeLoader = vi.fn().mockResolvedValue([fakePdfjs, { default: '' }] as const);
const r = createPdfRenderer(fakeLoader);
await r.init();
await r.init();
expect(fakeLoader).toHaveBeenCalledOnce();
});
it('loadDocument sets error and loading=false when getDocument().promise rejects', async () => {
const failingLib = {
GlobalWorkerOptions: { workerSrc: '' },
getDocument: vi.fn().mockReturnValue({
promise: Promise.reject(new Error('PDF not found'))
}),
TextLayer: class {
render() {
return Promise.resolve();
}
cancel() {}
}
} as unknown as typeof import('pdfjs-dist');
const r = createPdfRenderer(vi.fn().mockResolvedValue([failingLib, { default: '' }] as const));
await r.init();
await r.loadDocument('/bad/path');
expect(r.loading).toBe(false);
expect(r.error).toBe('PDF not found');
});
});

View File

@@ -1,6 +1,11 @@
import type { PDFDocumentProxy, RenderTask } from 'pdfjs-dist';
export function createPdfRenderer() {
export type LibLoader = () => Promise<readonly [typeof import('pdfjs-dist'), { default: string }]>;
const defaultLibLoader: LibLoader = () =>
Promise.all([import('pdfjs-dist'), import('pdfjs-dist/build/pdf.worker.min.mjs?url')]);
export function createPdfRenderer(libLoader: LibLoader = defaultLibLoader) {
// Reactive state — exposed via getters
let currentPage = $state(1);
let totalPages = $state(0);
@@ -18,10 +23,8 @@ export function createPdfRenderer() {
let pdfjsLib: typeof import('pdfjs-dist') | null = null;
async function init(): Promise<void> {
const [lib, { default: workerUrl }] = await Promise.all([
import('pdfjs-dist'),
import('pdfjs-dist/build/pdf.worker.min.mjs?url')
]);
if (pdfjsReady) return;
const [lib, { default: workerUrl }] = await libLoader();
lib.GlobalWorkerOptions.workerSrc = workerUrl;
pdfjsLib = lib;
pdfjsReady = true;

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js';
import { relativeTime } from '$lib/shared/utils/time';
import type { NotificationItem } from '$lib/notification/notifications.svelte';
@@ -11,6 +12,11 @@ type Props = {
};
let { notifications, onMarkRead, onMarkAllRead, onClose }: Props = $props();
function handleViewAll() {
onClose(); // close first — avoids stale dropdown during navigation transition
goto('/aktivitaeten');
}
</script>
<div
@@ -127,12 +133,12 @@ let { notifications, onMarkRead, onMarkAllRead, onClose }: Props = $props();
{/if}
<div class="border-t border-line px-4 py-2">
<a
href="/aktivitaeten"
onclick={onClose}
class="text-xs font-medium text-ink-2 transition-colors hover:text-ink"
<button
type="button"
onclick={handleViewAll}
class="min-h-[44px] px-1 text-xs font-medium text-ink-2 transition-colors hover:text-ink"
>
{m.chronik_view_all()}
</a>
</button>
</div>
</div>

View File

@@ -1,9 +1,15 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import { goto } from '$app/navigation';
import NotificationDropdown from './NotificationDropdown.svelte';
afterEach(cleanup);
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
const makeNotification = (overrides: Record<string, unknown> = {}) => ({
id: 'n1',
@@ -153,7 +159,7 @@ describe('NotificationDropdown', () => {
expect(onMarkAllRead).toHaveBeenCalledOnce();
});
it('calls onClose when the view-all link is clicked', async () => {
it('calls onClose when the view-all button is clicked', async () => {
const onClose = vi.fn();
render(NotificationDropdown, {
props: {
@@ -164,11 +170,44 @@ describe('NotificationDropdown', () => {
}
});
await page.getByRole('link').click();
await page.getByRole('button', { name: /alle aktivitäten|view all/i }).click();
expect(onClose).toHaveBeenCalledOnce();
});
it('navigates to /aktivitaeten when the view-all button is clicked', async () => {
render(NotificationDropdown, {
props: {
notifications: [],
onMarkRead: () => {},
onMarkAllRead: () => {},
onClose: () => {}
}
});
await page.getByRole('button', { name: /alle aktivitäten|view all/i }).click();
expect(goto).toHaveBeenCalledWith('/aktivitaeten');
});
it('calls onClose before navigating to /aktivitaeten', async () => {
const callOrder: string[] = [];
const onClose = vi.fn(() => callOrder.push('close'));
vi.mocked(goto).mockImplementation(() => callOrder.push('goto'));
render(NotificationDropdown, {
props: {
notifications: [],
onMarkRead: () => {},
onMarkAllRead: () => {},
onClose
}
});
await page.getByRole('button', { name: /alle aktivitäten|view all/i }).click();
expect(callOrder).toEqual(['close', 'goto']);
});
it('renders MENTION items with the mention verb text', async () => {
render(NotificationDropdown, {
props: {

View File

@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import OcrTrainingCard from './OcrTrainingCard.svelte';
@@ -74,6 +74,12 @@ describe('OcrTrainingCard — enabled state', () => {
});
describe('OcrTrainingCard — success dismiss button', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => {
vi.runAllTimers();
vi.useRealTimers();
});
it('dismiss button has 44×44px touch target (h-11 w-11)', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
@@ -108,7 +114,9 @@ describe('OcrTrainingCard — in-flight state', () => {
// While fetch is still pending the button label becomes "…"
await expect.element(page.getByRole('button', { name: '…' })).toBeInTheDocument();
// Cleanup: resolve the pending promise
resolveFetch({ ok: false });
await expect
.element(page.getByRole('button', { name: /Training starten/i }))
.not.toBeDisabled();
});
});

View File

@@ -9,7 +9,7 @@ import NotificationBell from '$lib/notification/NotificationBell.svelte';
import AppNav from './AppNav.svelte';
import UserMenu from './UserMenu.svelte';
import ConfirmDialog from '$lib/shared/primitives/ConfirmDialog.svelte';
import { provideConfirmService } from '$lib/shared/services/confirm.svelte.js';
import { provideConfirmService } from '$lib/shared/services/confirm.svelte';
import { bulkSelectionStore } from '$lib/document/bulkSelection.svelte';
let { children, data } = $props();

View File

@@ -2,7 +2,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
vi.mock('$lib/shared/services/confirm.svelte.js', () => ({
vi.mock('$lib/shared/services/confirm.svelte', () => ({
getConfirmService: () => ({ confirm: async () => false })
}));

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { m } from '$lib/paraglide/messages.js';
import { getConfirmService } from '$lib/shared/services/confirm.svelte.js';
import { getConfirmService } from '$lib/shared/services/confirm.svelte';
type FlatTag = {
id: string;

View File

@@ -16,9 +16,6 @@ vi.mock('$app/stores', () => ({
vi.mock('$lib/shared/services/confirm.svelte', () => ({
getConfirmService: () => ({ confirm: async () => false })
}));
vi.mock('$lib/shared/services/confirm.svelte.js', () => ({
getConfirmService: () => ({ confirm: async () => false })
}));
vi.mock('$app/navigation', () => ({
beforeNavigate: () => {},

View File

@@ -5,9 +5,6 @@ import { page } from 'vitest/browser';
vi.mock('$lib/shared/services/confirm.svelte', () => ({
getConfirmService: () => ({ confirm: async () => false })
}));
vi.mock('$lib/shared/services/confirm.svelte.js', () => ({
getConfirmService: () => ({ confirm: async () => false })
}));
const { default: AdminUserEditPage } = await import('./+page.svelte');

View File

@@ -12,7 +12,7 @@ import { createOcrJob } from '$lib/ocr/useOcrJob.svelte';
import { createTranscriptionBlocks } from '$lib/document/transcription/useTranscriptionBlocks.svelte';
import { createFileLoader } from '$lib/document/viewer/useFileLoader.svelte';
import { scrollToCommentFromQuery } from '$lib/shared/utils/deepLinkScroll';
import { getConfirmService } from '$lib/shared/services/confirm.svelte.js';
import { getConfirmService } from '$lib/shared/services/confirm.svelte';
let { data } = $props();

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
vi.mock('$lib/shared/services/confirm.svelte.js', () => ({
vi.mock('$lib/shared/services/confirm.svelte', () => ({
getConfirmService: () => ({ confirm: async () => false })
}));

View File

@@ -30,9 +30,6 @@ vi.mock('$app/navigation', () => ({
vi.mock('$lib/shared/services/confirm.svelte', () => ({
getConfirmService: () => ({ confirm: async () => false })
}));
vi.mock('$lib/shared/services/confirm.svelte.js', () => ({
getConfirmService: () => ({ confirm: async () => false })
}));
const { default: DocumentDetailPage } = await import('./+page.svelte');

View File

@@ -125,15 +125,3 @@ const klaerungChips = [
<p class="font-serif text-sm leading-relaxed text-ink-2">{m.richtlinien_closing_body()}</p>
</div>
</main>
<style>
@media print {
:global(.app-nav) {
display: none;
}
@page {
margin: 1.5cm;
}
}
</style>

View File

@@ -459,3 +459,34 @@
transform: translateX(350%);
}
}
@media print {
:global(.app-nav) {
display: none;
}
@page {
margin: 1.5cm;
}
}
.chronik-fade-in {
animation: chronik-fade-in 160ms ease-out;
}
@keyframes chronik-fade-in {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.chronik-fade-in {
animation: none;
}
}

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { m } from '$lib/paraglide/messages.js';
import { getConfirmService } from '$lib/shared/services/confirm.svelte.js';
import { getConfirmService } from '$lib/shared/services/confirm.svelte';
interface Props {
aliases: Array<{

View File

@@ -5,9 +5,6 @@ import { page } from 'vitest/browser';
vi.mock('$lib/shared/services/confirm.svelte', () => ({
getConfirmService: () => ({ confirm: async () => false })
}));
vi.mock('$lib/shared/services/confirm.svelte.js', () => ({
getConfirmService: () => ({ confirm: async () => false })
}));
const { default: PersonEditPage } = await import('./+page.svelte');

View File

@@ -0,0 +1,14 @@
// Disable SvelteKit hover-prefetch (both data + code) in browser-mode tests.
// ADR-012 / #553.
//
// Hover-prefetch fires real fetch requests for route loader chunks; those
// requests go through the same Playwright route handler that serves mocked
// modules. An in-flight prefetch landing after iframe teardown can hit the
// route handler with a closed birpc channel, raising an unhandled rejection
// that exits the run with code 1 even when every individual test was green.
//
// `data-sveltekit-preload-data="off"` disables loader-data prefetch;
// `data-sveltekit-preload-code="off"` disables route-code chunk prefetch.
// Both surfaces can produce late module fetches that hit the route handler.
document.body.dataset.sveltekitPreloadData = 'off';
document.body.dataset.sveltekitPreloadCode = 'off';

View File

@@ -77,6 +77,7 @@ export default defineConfig({
screenshotDirectory: 'test-results/screenshots',
screenshotFailures: true
},
setupFiles: ['./src/test-setup.ts'],
include: ['src/**/*.svelte.{test,spec}.{js,ts}'],
exclude: ['src/lib/server/**']
}

View File

@@ -32,6 +32,7 @@ export default defineConfig({
screenshotDirectory: 'test-results/screenshots',
screenshotFailures: true
},
setupFiles: ['./src/test-setup.ts'],
include: ['src/**/*.svelte.{test,spec}.{js,ts}'],
exclude: ['src/lib/server/**'],
coverage: {
@@ -43,7 +44,8 @@ export default defineConfig({
thresholds: {
lines: 80,
functions: 80,
branches: 80,
// branches at 75 — long-tail parent/child accounting grind; see docs/adr/013-client-branches-coverage-threshold.md and #496
branches: 75,
statements: 80
}
}