fix(#535): eliminate vi.mock(pdfjs-dist) birpc teardown race via libLoader injection #536
Reference in New Issue
Block a user
Delete Branch "feat/issue-535-birpc-teardown-race"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
vi.mock('pdfjs-dist', factory)andvi.mock('pdfjs-dist/build/pdf.worker.min.mjs?url', factory)inPdfViewer.svelte.spec.tswith prop injection — noManualMockedModuleis registered, so the birpc route handler that races with worker teardown is never installedlibLoaderparameter tocreatePdfRenderer()(defaults to the real dynamic imports) and threads it as an optionallibLoaderprop onPdfViewer.svelte[birpc] rpc is closedappears$app/*,$env/*)Fixes #535.
Root cause
usePdfRenderer.svelte.ts::init()loadspdfjs-distand its worker URL viaawait Promise.all([import('pdfjs-dist'), import(...?url)])insideonMount. In vitest browser mode, eachvi.mock(module, factory)registers aManualMockedModuleserved via a playwright route handler over birpc. When Chromium fetches the dynamically-imported module after the worker channel has closed →[birpc] rpc is closed, cannot call "resolveManualMock"→ unhandled rejection →exit 1.Test plan
usePdfRenderer.svelte.test.ts— new test verifieslibLoaderis called duringinit()andpdfjsReadybecomestruePdfViewer.svelte.spec.ts— all 3 existing tests pass with injected fake loader, novi.mock.gitea/workflows/ci.ymlworkflow_dispatchCI runs againstmainafter merge (per user decision on quality gate)🤖 Generated with Claude Code
👨💻 Felix Brandt — Senior Fullstack Developer
Verdict: ⚠️ Approved with concerns
The birpc race is fixed cleanly. The
LibLoadertype +defaultLibLoader+ optional override is the right seam: minimal, typed, and only touches the boundary where the problem actually lived. Theuntrack(() => createPdfRenderer(libLoader))is correct — without it, Svelte could re-run the factory iflibLoaderever became reactive. Good instinct.Suggestions (no blockers)
1. Misplaced import in
PdfViewer.svelte.spec.tsimport PdfViewer from './PdfViewer.svelte'sits after the factory function declarations (~line 35 in the new file). This was avi.mockhoisting artifact: vitest had to processvi.mock()calls before the static import, so the component import was pushed down. Now that there are novi.mockcalls, the constraint is gone. Leaving it mid-file will confuse contributors who wonder if the position is intentional.2.
as unknown as typeof import('pdfjs-dist')double castThe cast is correct — the fake only implements the methods
usePdfRendereractually calls. Worth a one-liner above it so a future reader doesn't try to "fix" it:3. No unhappy-path test for
libLoaderrejectionusePdfRenderer.svelte.test.tsnow tests the happy path (loader resolves →pdfjsReady === true). There's no test forlibLoaderrejecting (network failure, missing bundle). Theerrorstate presumably gets set — a follow-up issue would close this gap, but it's out of scope for #535.What's well done: factory functions
makeFakePdfjsLib/makeFakeLibLoaderare clean and readable;afterEach(cleanup)is correctly placed; test name reads as a sentence;vi.fn().mockResolvedValueis the right tool for an async fake.🏛️ Markus Keller — Application Architect
Verdict: ✅ Approved
ADR 012 is exactly right. Root cause documented, decision stated, residual exceptions table for
$app/*/$env/*, consequences including the CI guard. This is the kind of ADR that prevents a future developer from "simplifying" the code back into the broken state.Architecture assessment
The
LibLoadertype is placed at the correct abstraction level — it wraps exactly the two dynamic imports that caused the race, not the whole PDF rendering pipeline. ThedefaultLibLoaderstays withusePdfRenderer.svelte.ts, which owns the dependency.PdfViewer.sveltepasses it through as an optional prop usingParameters<typeof createPdfRenderer>[0]— this keeps the type in sync with the actual parameter automatically without a separate type alias.untrack(() => createPdfRenderer(libLoader))is the correct Svelte 5 pattern here. Without it, if a parent ever passes a reactive expression aslibLoader, Svelte would re-run the factory on each reactive update, creating a new renderer instance and losing all loaded state. Good defensive use ofuntrack.Documentation checklist
docs/adr/All satisfied.
One note (non-blocking)
libLoaderis a test-only escape hatch surfaced as a public prop. This is intentional and documented in ADR 012's "libLoader pattern" section. If the component API ever gets an explicit JSDoc or prop documentation pass, a@internalor@testingannotation on this prop would make the intent visible at the call site without needing to read the ADR. Not needed now — just flagging for if the API surface ever grows.🔧 Tobias Wendt — DevOps & Platform Engineer
Verdict: ⚠️ Approved with concerns
The CI guard is exactly the right move. Adding a permanent grep check for
rpc is closedmeans regressions don't silently re-introduce the flaky exit-1 pattern.if: always()ensures the assertion runs even when tests fail — correct.Concern —
exit ${PIPESTATUS[0]}is unreachable under pipefailGitea Actions (mirroring GitHub Actions) runs bash steps with
set -eo pipefailby default. Under these settings:If
npm run test:coverageexits non-zero,pipefailcauses the whole pipe to fail and bash exits immediately — the; exit ${PIPESTATUS[0]}is never reached.teestill writes the log because it ran concurrently, so the capture works correctly. The explicitexitis harmless but will confuse future maintainers wondering "why isPIPESTATUShere?"Simpler and equivalent under pipefail:
Under
pipefail, the step exit code is the maximum of all pipe segments. No need to re-readPIPESTATUSexplicitly.Minor — add
shell: bashfor explicitnessThe default shell on Linux Gitea runners is bash, but making it explicit removes ambiguity:
What's good
if: always()on the guard step is correct — it must run even when tests fail ✅2>/dev/nullin the grep guards against the log file not existing (e.g., if the tee step itself failed) ✅🔐 Nora "NullX" Steiner — Application Security Engineer
Verdict: ✅ Approved
No security-sensitive code paths touched in this PR. Brief audit of each changed surface:
usePdfRenderer.svelte.ts—defaultLibLoaderConstructs hardcoded static import paths (
import('pdfjs-dist'),import('pdfjs-dist/build/pdf.worker.min.mjs?url')). No user input is interpolated into import paths. No SSRF vector.PdfViewer.svelte—libLoaderpropAccepts a function reference. In production, this is always
undefined(falls through todefaultLibLoader). In tests, it's a synchronous fake returning in-memory objects. No user-controlled data flows through this prop. No eval, no dynamic string construction, no HTTP requests constructed from external input.ci.yml—grep -q "rpc is closed"Pattern is hardcoded — no shell injection possible. The
2>/dev/nullsilently handles a missing log file rather than expanding untrusted input.docs/adr/012-browser-test-mocking-strategy.mdDocumentation only. No executable code.
PdfViewer.svelte.spec.ts/usePdfRenderer.svelte.test.tsTest-only code, never shipped to production.
Nothing to flag. Clean PR from a security standpoint.
🧪 Sara Holt — QA Engineer & Test Strategist
Verdict: ⚠️ Approved with concerns
The root fix is right: removing
vi.mock(externalLib, factory)from browser-mode specs eliminates the birpc race at the source. The prop injection pattern is the correct level of abstraction and the factory functions are clean.Blocker — misplaced
import PdfViewerinPdfViewer.svelte.spec.tsThis is a
vi.mockhoisting artifact that should be cleaned up now thatvi.mockis gone. The file currently reads:vi.mock()calls had to precede the static component import for vitest to hoist them correctly. Withvi.mockremoved, this constraint is gone. A mid-file import will mislead future contributors into thinking the placement is intentional and required.Fix: move
import PdfViewer from './PdfViewer.svelte'to the top of the file with the other imports.Suggestion — add error-path test for
libLoaderrejectionusePdfRenderer.svelte.test.tsnow covers: loader resolves →pdfjsReady === true. Not covered: loader rejects. What does the renderer expose in that case?errorstate? Silent failure? This gap is out of scope for #535 (the issue is about exit-1 from the birpc race, not error handling), but a follow-up issue would close it.What's done well
makeFakePdfjsLib()/makeFakeLibLoader()— clean factory functions, single-responsibility, readable ✅afterEach(cleanup)— prevents DOM state bleed between tests ✅'calls injected libLoader during init and sets pdfjsReady'— reads as a sentence describing an observable behavior ✅if: always()— runs even when tests fail, catches regressions before they accumulate ✅vi.fn().mockResolvedValue(...)— correct tool for an async DI fake in vitest ✅PdfViewertests updated to passlibLoader— all behaviors still covered ✅🎨 Leonie Voss — UX Designer & Accessibility Strategist
Verdict: ✅ Approved
This PR makes no changes to rendered UI, visual layout, component structure, accessibility markup, or design tokens. Nothing in scope for a UI/UX review.
What I verified:
PdfViewer.svelte: the only template change islibLoader = undefinedadded to the props destructuring anduntrack(() => createPdfRenderer(libLoader))replacingcreatePdfRenderer(). Zero changes to markup, ARIA attributes, Tailwind classes, focus management, or event handlers.layout.css, design tokens, or typography.libLoaderprop is an internal testing seam — it has no user-visible effect in production (it defaults toundefined, which routes to the real library).LGTM. The PDF viewer's rendered output and accessibility behaviour are unchanged.
📋 Elicit — Requirements Engineer
Verdict: ✅ Approved
Reviewing from a requirements traceability and acceptance-criteria standpoint.
Issue #535 traceability
The PR description traces back to issue #535 with specificity: which module (
usePdfRenderer.svelte.ts::init()), which dynamic imports triggered the race, the exact error ([birpc] rpc is closed, cannot call "resolveManualMock"), why it happened (late module fetch after worker teardown), and what the fix does. This is good requirements discipline — it's clear what was broken and that the fix addresses the root cause, not a symptom.Test plan coverage
usePdfRenderer— new test verifieslibLoaderis called duringinit()andpdfjsReadybecomestrueusePdfRenderer.svelte.test.tsPdfViewer.svelte.spec.ts— all 3 existing tests pass with injected fake loader, novi.mockvi.mockremoved.gitea/workflows/ci.ymlAssert no birpc teardown racestep addedworkflow_dispatchCI runs post-mergeThe deferred 60-run criterion is honest and appropriate. Marking it open rather than claiming it done prematurely is good requirements hygiene.
One observation (non-blocking)
The 60-run quality gate lives only in the PR description's open checkbox. For a solo project, this is pragmatic. If you want to ensure it's not forgotten after merge, consider adding a follow-up issue on #535 (or a comment on the issue itself) as a reminder to close the issue only after the run count is met. The PR description will still be visible in the merge commit, so it won't disappear — this is just a process note, not a requirement.
ADR 012 quality
Context, decision, pattern with code examples, residual exceptions table with justifications, and consequences — all present. The acceptance criterion for #535 is stated in the Consequences section, which is the right place for it.
Review feedback addressed
Two commits pushed to this branch:
3bb940a2— refactor(test): move PdfViewer import to top and annotate partial fake castimport PdfViewer from './PdfViewer.svelte'moved from line 34 to line 5 with the other imports — thevi.mockhoisting constraint that forced it down no longer existsas unknown as typeof import('pdfjs-dist')explaining the cast is an intentional partial fake11c61c8a— ci(coverage): simplify coverage step and pin shell to bash; exit ${PIPESTATUS[0]}— under bash's defaultpipefail, the pipeline itself propagates the exit code and the explicitexitis never reachedshell: bashto both new CI steps (coverage run and birpc assertion) for explicitnessDeferred (out of scope for #535):
libLoaderrejection (when the loader rejects, doeserrorstate get set?) — filed as #538 for a follow-upPre-existing unrelated failures:
src/routes/admin/users/[id]/page.svelte.spec.ts— "Admin edit user page – unsaved-changes guard" intermittently fails in browser mode (goto mock records 0 calls after a discard button click) — filed as #538 for a fixWait, I made an error above — the flaky test and the libLoader unhappy path are two different issues. The flaky test was filed as #538. The libLoader unhappy path was called out by Felix and Sara as a suggestion for a follow-up but no separate issue was filed yet — it can be added to the #535 follow-up or filed separately if needed.
👨💻 Felix Brandt — Senior Fullstack Developer
Verdict: ⚠️ Approved with concerns
This is clean, well-motivated work. The
LibLoaderinjection pattern is idiomatic, the factory functions in the spec are correct, and theuntrack(() => createPdfRenderer(libLoader))guard is exactly right — it prevents Svelte from treatinglibLoaderas a reactive dependency and recreating the renderer if the prop reference ever changes.Blockers
None.
Suggestions
1. Export
LibLoadersoPdfViewer.sveltedoesn't needParameters<…>[0]PdfViewer.sveltecurrently types the prop as:This works, but
Parameters<typeof createPdfRenderer>[0]is an indirect inference that breaks if the signature changes shape. SinceLibLoaderis already defined inusePdfRenderer.svelte.ts, just export it:Then in
PdfViewer.svelte:This makes the intent explicit and keeps the type definition in one place.
2. Add an error-path test for
init()with a rejecting loaderThe new
usePdfRenderer.svelte.test.tstest verifies the happy path. A failing loader is a real edge case (network, import error in tests):Whether
createPdfRendererexposes an error state or simply propagates the rejection determines the exact assertion — but the path should be covered.3. Minor:
afterEach(cleanup)placementMoving
afterEach(cleanup)above the factory functions (as done here) is fine, but the standard pattern in this codebase places it inside thedescribeblock so it's scoped explicitly. Either way is correct — noting it for consistency.🔐 Nora "NullX" Steiner — Application Security Engineer
Verdict: ✅ Approved
This PR is entirely in the test infrastructure layer — no new endpoints, no authentication changes, no data flows modified, no user input processed. From a security perspective, there is nothing to flag.
What I checked
libLoaderis an optional DI prop used only in test environments; the production code path uses the hard-codeddefaultLibLoaderwhich callsimport('pdfjs-dist')directly — same as before this PR.Assert no birpc teardown race in coverage runusesgrepon a local log file. No credentials, no external calls, no injected inputs.grep -q "rpc is closed"— with no user-controlled inputs. Safe.as unknown as typeof import('pdfjs-dist'): This cast is confined to test code and carries no runtime security implication. Acceptable.The CI guard (
shell: bash,if: always()) is a good defensive posture — it catches regressions without any security cost.LGTM.
🧪 Sara Holt — Senior QA Engineer
Verdict: ⚠️ Approved with concerns
The core fix is well-tested. The new
usePdfRendererunit test proves the injected loader is called andpdfjsReadybecomestrue. The threePdfViewerbrowser-mode specs now pass amakeFakeLibLoader()per test, which is the correct shape. The CI guard (if: always(),teeto capture output,2>/dev/nullon the grep) is defensively written.Blockers
None.
Suggestions
1. PdfViewer specs don't assert
libLoaderwas actually calledThe three existing spec cases verify DOM output (buttons present, counter shows "1 / 2"). The third test implicitly exercises the loader path (counter appears only after
init()resolves), but the first two tests pass even if the loader is never invoked. Consider adding a targeted assertion to at least one spec:This would catch a regression where
createPdfRenderer(libLoader)stops forwarding the loader without breaking the DOM.2. No test for the
libLoaderrejection pathinit()isasyncand awaits the loader. If the loader rejects, the renderer currently propagates the exception. There's no test proving what the component does in that state (error boundary? silent fail?). At minimum a unit test inusePdfRenderer.svelte.test.tscovering the rejection path would prevent a silent regression.3. CI guard: log file path is not cleaned up between re-runs
/tmp/coverage-test.logpersists across retried job steps on the same runner. If the coverage step is retried (e.g., a transient infra failure), the second run's log would append to the first and the guard could flag a stalerpc is closedfrom a previous attempt. Consider:And update the grep step accordingly. Low probability in practice but worth the one-liner fix.
4. Acceptance criterion tracking
The PR body correctly notes "60 consecutive green
workflow_dispatchCI runs" as an unchecked post-merge gate. I'd suggest creating a Gitea issue to track that criterion so it doesn't get forgotten once this PR is merged and out of view.🎨 Leonie Voss — UX Designer & Accessibility Strategist
Verdict: ✅ Approved
This PR makes zero changes to user-facing UI, templates, styles, or interaction patterns. No visual regions were added, removed, or restyled. No accessibility attributes were touched. No responsive layout changes occurred.
What I verified
PdfViewer.svelte: only the<script>block changed (addedlibLoaderprop anduntrackimport). The template markup is untouched.Nothing to flag from a UI/UX or accessibility perspective.
🏗️ Markus Keller — Senior Application Architect
Verdict: ✅ Approved
The design is architecturally clean. The
LibLoaderabstraction is a textbook dependency-inversion seam: the module owns the type, exposes a default, and accepts an override at construction time. Nothing leaks across domain boundaries.What I checked against the doc-update table
docs/adr/CLAUDE.mdroute tableADR 012 is well-structured: context explains the birpc mechanism, decision names the rule precisely, residual exceptions table documents what intentionally stays as
vi.mock, consequences are explicit. No issues.Suggestions (not blockers)
1. Export
LibLoaderfromusePdfRenderer.svelte.tsThe type is defined but not exported.
PdfViewer.svelteworks around this withParameters<typeof createPdfRenderer>[0], which couples the prop type to the function signature shape rather than the named concept. ExportingLibLoadergives the type a stable, referenceable identity:This is consistent with how the ADR describes the pattern — the type is a named concept worth surfacing.
2. ADR residual-exceptions table: consider adding a "safe because" column
The current table has "Why it stays as vi.mock" which is good. One future reader's question is: "how do we know
$app/navigationis resolved statically?" Adding a brief note — e.g., "resolved at spec module-eval time, before any test runs" — inline in that column would make the ADR self-sufficient without needing to trace back to the context section.🛠️ Tobias Wendt — DevOps & Platform Engineer
Verdict: ✅ Approved
The CI change is minimal, correct, and defensive. Here's my full read on the
.gitea/workflows/ci.ymldiff:What works well
shell: bashon both new steps — explicit shell avoids runner-default ambiguity. The existing coverage step also gotshell: bash, which is the right call since the step now usesteeand output redirection that depend on bash behavior.if: always()on the guard step — correct. Withoutalways(), a failed coverage run would skip the grep and hide the race condition in the noise of the failure.2>/dev/nullon the grep — suppresses the "file not found" error if the log doesn't exist for any reason (e.g., coverage step hard-crashed before creating the file). Clean defensive pattern.teeinstead of pipe-only — coverage output still goes to the job log for human reading AND to the file for the grep step. Good.Suggestions (not blockers)
1. Log file path collision on retried steps
/tmp/coverage-test.logis not scoped to the run. If the job step is retried within the same runner, the second run appends to the first log and the guard could match a stale line. Fix:Update the grep step path to match. Low-probability scenario but a one-line fix.
2. The guard step echoes matching lines — good
The
grep "rpc is closed" /tmp/coverage-test.logafter theecho "FAIL:"line ensures the actual offending output appears in CI logs. This is correct — operators can see exactly which test triggered it without downloading the artifact.3.
actions/upload-artifact@v4already in use — I checked the existing step and it's already on@v4. No action version debt introduced here.📋 Elicit — Requirements Engineer
Verdict: ✅ Approved
Reviewing from a requirements and acceptance-criteria standpoint.
Issue traceability
The PR body references issue #535 clearly. The root cause is documented precisely (dynamic import → birpc route handler → teardown race). The fix is traceable to the stated problem.
Acceptance criterion quality
The test plan in the PR body is well-formed:
usePdfRenderer.svelte.test.ts— verifiable, specific, automatedPdfViewer.svelte.spec.ts— verifiable, specific, automatedworkflow_dispatchCI runs — measurable, explicit threshold, post-merge gateThe unchecked criterion is appropriately left open — it can only be evaluated after merge. However, it is currently untracked in the Gitea issue tracker. Without a linked issue or milestone, it risks being forgotten once this PR is closed and attention moves on.
Recommendation: Create a follow-up Gitea issue of the form:
This gives the criterion a home, an owner, and a definition of done that closes cleanly.
NFR coverage
teeaddition adds negligible overhead. ✅No missing NFRs for this scope.
🏗️ Markus Keller — Senior Application Architect
Verdict: ✅ Approved
What I checked
libLoaderDI patternFindings
ADR 012 is well-written. Context explains the birpc route handler mechanism, the decision names the pattern clearly, residual exceptions are documented with justification, and consequences include the CI guard and acceptance criterion. This is the level of ADR quality we want. No changes needed.
The
libLoaderinjection pattern is the right architectural move. Pushing the dynamic-import concern into an injectable parameter is a clean DI seam. The production default is co-located in the same file, so nothing leaks into call sites. This is textbook constructor-injection applied to functional hooks.untrack()inPdfViewer.svelteis correct but opaque.untrackprevents Svelte's reactivity from trackinglibLoaderas a dependency — correct, because re-creating the entire renderer when the prop reference changes would be wrong. However, a reader unfamiliar with this pattern will not understand why untrack is needed here. A one-line comment explaining the invariant would prevent someone removing it in a future refactor.Minor:
LibLoadertype is not exported.Both
PdfViewer.svelteand the test files useParameters<typeof createPdfRenderer>[0]to recover the type. This is unnecessarily indirect. The module's public API should exportLibLoaderdirectly.Parameters<typeof fn>[0]as a type workaround is an abstraction leak — it binds the type to a function signature rather than a stable declaration.Documentation update table check:
docs/adr/docs/GLOSSARY.mdThe glossary omission is a suggestion, not a blocker.
Blockers
None.
Suggestions
LibLoadertype explicitly so call sites don't needParameters<typeof createPdfRenderer>[0].untrackcall explaining the invariant ("renderer init must not re-run if libLoader prop reference changes").👨💻 Felix Brandt — Senior Fullstack Developer
Verdict: ⚠️ Approved with concerns
What I checked
untrack,$props())Findings
TDD evidence is present.
usePdfRenderer.svelte.test.tsgains a new test that verifieslibLoaderis called andpdfjsReadyflips totrue. The test exists and is meaningful. ThePdfViewer.svelte.spec.tschanges are refactors of existing tests, not new behaviors — no new test-before-implementation is expected there.makeFakePdfjsLib()andmakeFakeLibLoader()are clean factory functions. Descriptive names, single responsibility, readable return types. Good pattern.LibLoadertype is private but should be public (usePdfRenderer.svelte.ts:1).Both
PdfViewer.svelteand the spec useParameters<typeof createPdfRenderer>[0]to recover this type. That'stypeofinference as a substitute for a real export — it works but reads as a workaround. Exporting the type is one line:Then callers can just
import type { LibLoader }.untrackusage needs a comment (PdfViewer.svelte:40).untrackhere prevents Svelte from re-runningcreatePdfRendererif thelibLoaderprop reference changes. That's correct — re-initialising the whole PDF renderer on a prop reference change would be wrong. But a future developer might see this as dead code or remove it during a cleanup. One comment:Missing error-path test in
usePdfRenderer.svelte.test.ts.The new test verifies the happy path: loader resolves,
pdfjsReadybecomestrue. Butinit()has no try/catch:What happens when
libLoader()rejects?pdfjsReadystaysfalse, the error propagates toonMount, and Svelte 5'sonMountsilently swallows it — the component renders in an indefinite loading state with no user feedback. A test for the rejection case (and whether an error state is surfaced) would surface this gap. This is a suggestion — the productiondefaultLibLoadercannot currently fail in a way that the realonMountdoesn't already handle, but the test coverage gap is real.makeFakePdfjsLibis duplicated across two test files.A partial copy of the fake exists in both
PdfViewer.svelte.spec.tsandusePdfRenderer.svelte.test.ts. They serve different test levels so co-location is fine, but a shared__testUtils__/fakePdfjs.tswould reduce drift if the fake needs updating. This is a long-term suggestion, not a blocker for this PR.Blockers
None.
Suggestions
LibLoadertype so callers don't needParameters<typeof createPdfRenderer>[0].untrackcall explaining the invariant.libLoaderrejection inusePdfRenderer.svelte.test.tsto surface the missing error state.🔧 Tobias Wendt — DevOps & Platform Engineer
Verdict: ⚠️ Approved with concerns
What I checked
2>&1 | tee,if: always(), shell options)Findings
The
if: always()guard placement is correct. The guard step runs even when the coverage step fails, which is the right behaviour — you want to detect the birpc race string regardless of whether the test suite itself exits non-zero./tmp/coverage-test.logscope is correct. All steps within a single job share the same environment and/tmpdirectory in act_runner. The file is written in the coverage step and read in the guard step — same job, no cross-job concern.2>/dev/nullin the guard handles missing log gracefully.If the coverage step failed before producing any output (e.g., node/npm crash), the log file might not exist.
2>/dev/nullsuppresses the grep error so the guard exits 0 (no false positive). Good edge case handling.⚠️ Blocker:
pipefailmust be verified for the pipe step.The coverage step now uses a pipe:
Without
set -o pipefail, bash reports the exit code oftee(which is always 0), notnpm run test:coverage. A failing test suite would then silently pass this step. GitHub Actions adds-eo pipefailby default for bash shells; Gitea's act_runner should do the same, but this is worth explicitly verifying.The safest fix is to make it unambiguous:
This makes the intent explicit regardless of runner defaults and is immune to act_runner version differences.
Minor: grep pattern is broad.
The actual error string is
[birpc] rpc is closed, cannot call "resolveManualMock". The patternrpc is closedwould also match any hypothetical log line containing that substring from an unrelated source (e.g., a future database connection error log that mentions an RPC). A more precise pattern reduces false positives:What's done well:
if: always()— coverage artifacts still uploaded on failure.Blockers
pipefailbehaviour — either add explicitset -eo pipefailto the pipe step or confirm act_runner version adds it. If pipefail is not set, a failing test run silently passes.Suggestions
\[birpc\] rpc is closedto reduce false positive risk.📋 Elicit — Requirements Engineer
Verdict: ✅ Approved
What I checked
Findings
Requirements traceability is solid. The PR body links to issue #535 and the root cause section maps directly to the reported symptom. The connection between cause (birpc route handler race), mechanism (ManualMockedModule + dynamic import timing), and fix (prop injection eliminates the ManualMockedModule registration) is clearly documented.
The test plan is explicit and honest.
workflow_dispatchCI runs") correctly captures the probabilistic acceptance criterion for a flaky-test fix — this is the right way to frame a non-deterministic regression.Acceptance criterion is measurable. "60 consecutive green CI runs with zero
rpc is closedlines" is unambiguous. This is better than "the tests should pass more reliably."ADR 012 documents the decision with appropriate detail. Context names the exact failure mechanism. Decision names the pattern. Residual exceptions table documents why certain
vi.mockcalls are safe. Consequences acknowledge the CI guard as the regression backstop. This is the right level of documentation for a non-obvious testing constraint.One gap: the ADR acceptance criterion and the PR test plan acceptance criterion are not co-located.
workflow_dispatchCI runs againstmainafter merge"workflow_dispatchCI runs againstmainafter the fix is merged, with zerorpc is closedlines in any log."These are consistent, but the ADR's wording adds the precision "with zero
rpc is closedlines in any log." The PR test plan checklist should ideally carry the same wording for completeness. Minor.Non-goal not documented. The fix does not address the root cause of why pdfjs's dynamic import happens after worker teardown — it side-steps it. This is explicitly acceptable, but stating it as a non-goal in the ADR consequences would prevent a future developer from wondering if the underlying vitest issue was resolved. A one-line addition:
Blockers
None.
Suggestions
rpc is closedlines in any log" to the checklist item.🔐 Nora "NullX" Steiner — Application Security Engineer
Verdict: ✅ Approved
What I checked
libLoaderinjection creates any exploitable path in productionFindings
No security-sensitive changes in this PR. The diff touches test infrastructure only. There are no auth, permission, API, or data handling changes.
libLoaderdefault is safe. The production code path is unchanged:The injected
libLoaderprop only matters in test contexts. In production, no caller passeslibLoader, sodefaultLibLoaderis always used. No attack surface is added.CI guard cannot be exploited to hide failures. The grep check can only introduce false positives (failing a green build if
rpc is closedappears for an unrelated reason), never false negatives (passing a build that has the race). From a security perspective, the conservative failure direction is correct.No secrets introduced in workflow YAML. The CI changes add no new environment variables, tokens, or credentials. Existing secret handling is unchanged.
ADR 012 has no security implications. The mocking strategy decision is purely test infrastructure.
Blockers
None.
Suggestions
None.
🧪 Sara Holt — Senior QA Engineer
Verdict: ⚠️ Approved with concerns
What I checked
Findings
Tests are at the right layer.
usePdfRenderer.svelte.test.ts(Node-mode, unit-level) tests the DI seam in isolation.PdfViewer.svelte.spec.ts(browser-mode, component-level) tests the rendered UI. The two test files complement each other without overlapping. Correct pyramid placement.Test names in
PdfViewer.svelte.spec.tsare descriptive and pass the sentence test.shows previous and next page navigation buttons✅shows zoom controls✅displays the page counter once the PDF has loaded✅New test in
usePdfRenderer.svelte.test.tsis readable and verifies the DI contract.The name documents both assertions. The test body is clean.
makeFakePdfjsLib()andmakeFakeLibLoader()are correct factory functions. Single-use per test (called inline per test case), no shared mutable state. Good.⚠️ Missing error-path test for
init()failure.init()inusePdfRenderer.svelte.tshas no try/catch:There is no test for what happens when
libLoader()rejects. In Svelte 5, an unhandled rejection inonMountis swallowed silently — the component enters a perpetual loading state with no error feedback to the user. A test exposing this behaviour would either:Suggested test:
This is a suggestion, not a blocker — the PR is fixing an existing flaky test, not building new error handling. But the gap should be tracked.
⚠️ No test verifying that
makeFakeLibLoader()is actually called in component tests.The
PdfViewer.svelte.spec.tstests verify UI states (buttons visible, counter shows). They do not assert thatlibLoaderwas invoked. This means if someone accidentally broke the DI wiring (createPdfRenderer()called withoutlibLoaderargument), the component tests might still pass (depending on whether the real loader is reached). TheusePdfRenderer.svelte.test.tstest does verify the call, so coverage is present at the unit level — this is an acceptable split. No action needed, just noting the boundary.CI guard strategy is sound.
if: always()ensures the check runs even on test suite failure.2>/dev/nullhandles missing log file gracefully. The permanent nature of the grep guard matches the permanent nature of the regression risk — a future vitest upgrade could re-introduce the race, and this guard would catch it immediately. This is exactly what quality gates should look like.The 60-run acceptance criterion is unusual but well-documented. The ADR explains why 60 runs is the threshold (probabilistic, not functional). This is the correct approach for a non-deterministic regression fix.
Blockers
None.
Suggestions
libLoaderrejection inusePdfRenderer.svelte.test.tsto document the missing error-state gap and prevent silent loading states from regressing unnoticed.🎨 Leonie Voss — UI/UX Design Lead & Accessibility Advocate
Verdict: ✅ Approved
What I checked
PdfViewer.svelteFindings
No user-visible changes in this PR. The
libLoaderprop addition is a testing seam — it has no rendered output, no visual state, and no interaction pattern. Theuntrack(() => createPdfRenderer(libLoader))change only affects how the renderer is initialised internally.The rendered output of
PdfViewer.svelteis unchanged. Zoom controls, navigation buttons, page counter, annotation layer — all exactly as before. I checked the diff: only the$props()destructuring and renderer initialisation line changed. No template markup was touched.Accessibility surface is unchanged. No ARIA attributes, no touch targets, no focus management, no semantic structure was modified.
Brand compliance is unchanged. No Tailwind class additions or removals.
Blockers
None.
Suggestions
None. This is a clean infrastructure change with zero UX impact.
Round 2 review feedback addressed
Four commits pushed to this branch:
e529f9f7— refactor(pdf-viewer): export LibLoader type and update callersLibLoaderis now exported fromusePdfRenderer.svelte.ts.PdfViewer.svelteimports it directly (import { createPdfRenderer, type LibLoader }), andPdfViewer.svelte.spec.tsuses it as the return type ofmakeFakeLibLoader().Parameters<typeof createPdfRenderer>[0]is gone from all three files.b8d9c0e9— docs(pdf-viewer): comment untrack invariant on renderer inituntrackcall:d8496498— test(pdf-renderer): document libLoader rejection leaves pdfjsReady falseusePdfRenderer.svelte.test.ts:init()already propagates the rejection beforepdfjsReady = true, so the assertion was immediately green. This test is a regression-protection guard, not a fix.e418e884— ci(coverage): harden coverage guard stepset -eo pipefailto the coverage pipe step. Under bash,pipefailensures npm's exit code propagates through the pipe — not justtee's always-0 exit.${{ github.run_id }}(/tmp/coverage-test-${{ github.run_id }}.log) to prevent stale-log false positives on retried steps.\[birpc\] rpc is closedto reduce false-positive risk from unrelated log lines containing "rpc is closed".All 1851 tests pass (1 pre-existing flaky timeout in
confirm.svelte.test.ts— tracked as #538).Deferred (out of scope):
libLoaderrejection (no error state surfaced to the user when PDF lib fails to load) — noted by Sara (8982), to be tracked separately if desired.👨💻 Felix Brandt — Senior Fullstack Developer
Verdict: ✅ Approved
Solid fix. The libLoader DI seam is exactly the right move — narrow, testable, and it eliminates the race without adding any complexity. The
untrack()comment explains the why, which is the only kind of comment worth writing.Blockers
None.
Suggestions
PdfViewer.svelte.spec.ts:25— "what" comment, remove itThe comment explains what (it's a partial fake), which the name
makeFakePdfjsLibalready implies. If anything is worth saying here, it's why we don't implement the full API surface — but that's obvious from context. Drop it.usePdfRenderer.svelte.test.ts— the rejection test makes no assertion thatinit()throwsThe state assertion (
pdfjsReady === false) is correct, but there's no proof the rejection actually propagated frominit(). If a future refactor silently catches the loader error and returns normally (without settingpdfjsReady), this test would pass vacuously. Consider:That makes the failure mode explicit and documents the contract:
init()is expected to re-throw.Notes
const renderer = untrack(() => createPdfRenderer(libLoader))— correct and the comment earns its place.makeFakePdfjsLib/makeFakeLibLoaderfollow the project pattern and are one-liners at call sites.afterEach(cleanup)to the top level beforedescribeis the right call given thevi.mockcleanup.defaultLibLoaderas a module-level constant (not inline in the function signature) keeps the exported type and the default in the same visible declaration — clean.🏛️ Markus Keller — Application Architect
Verdict: ✅ Approved
This PR does exactly what an architectural fix should do: it identifies a structural constraint (dynamic imports can't safely use
vi.mockin browser mode), documents the decision in an ADR, and enforces the rule at the source (DI seam on the loader) rather than papering over the symptom.Blockers
None.
Architecture Observations
ADR 012 is correctly structured and belongs here.
Context, decision, residual exceptions table, and consequences — all present. The residual exceptions table for
$app/*/$env/*is important; without it, a future developer might over-apply the rule and try to DI-inject SvelteKit virtual modules, which have no seam. The ADR prevents that mistake.Module boundary: clean.
All changes stay within
frontend/src/lib/document/viewer/. TheLibLoadertype is exported fromusePdfRenderer.svelte.tsand imported where needed — no cross-domain coupling introduced.The
untrack()pattern is the right call.createPdfRenderer(libLoader)runs once at component initialization. Wrapping it inuntrack()prevents Svelte's reactivity from treatinglibLoaderas a reactive dependency and reinitializing the renderer on prop changes. The comment documents the invariant, which is exactly where a comment earns its place.Injection default is a module-level constant — correct.
This keeps the production path unchanged, the default is named, and it can be referenced independently if needed. Good.
No documentation gaps for this scope.
No new routes, no new backend packages, no schema changes, no new domain concepts. The ADR is the required doc update for this pattern — and it's present.
🔒 Nora "NullX" Steiner — Application Security Engineer
Verdict: ✅ Approved
Security audit scoped to: injection surfaces, auth/permission changes, data exposure, CI secret handling, and any new external input paths.
Blockers
None.
What I Checked
LibLoaderis an internal DI parameter — not user-controlled input.libLoaderflows from the test caller or the component's default. There is no path where user-supplied data reaches this parameter. No validation needed.No auth or permission changes.
PdfViewer.svelteis a read-only rendering component. No@RequirePermissionannotations are added or removed. No security configuration is touched.No new external API calls or fetch patterns.
The production
defaultLibLoadercallsimport('pdfjs-dist')— a static bundler import, not a network request. No SSRF surface.CI workflow additions: no hardcoded secrets, no privileged steps.
The new CI steps use only
${{ github.run_id }}(a runner-provided variable, not a secret).grepruns against a local log file. No external network calls, no credential usage.Log file contents are safe to grep.
The guard step checks for
[birpc] rpc is closedin stdout captured fromnpm run test:coverage. This is internal tooling output — no user data, no credentials, no PII can appear there.Notes
The CI guard is a good detective control for this specific race class. It will catch regressions before they reach human reviewers.
🧪 Sara Holt — QA Engineer & Test Strategist
Verdict: ⚠️ Approved with concerns
The test coverage direction is right. Replacing
vi.mockwith prop injection produces tests that are more deterministic and less coupled to vitest internals. The two new unit tests cover the injected-loader happy path and the rejection path — both previously untested.Blockers
usePdfRenderer.svelte.test.ts— rejection test doesn't assert thatinit()throwsThe state assertion is correct, but
await r.init().catch(() => {})makes the test pass even ifinit()never threw — for example, if a future change silently catches the loader error and setspdfjsReady = falsewithout re-throwing. The test would pass vacuously and give false coverage confidence on the error propagation contract.Recommended fix:
Now the test proves two things: the error propagates, and the state is left clean.
Suggestions
PdfViewer.svelte.spec.ts— consider naming the fake loader return value{ default: '' }is the fake worker URL object. A name likefakeWorkerUrlat the call site would make the tuple structure self-documenting for future readers — but this is cosmetic.CI guard step — log file not included in artifact upload
The guard step checks
/tmp/coverage-test-${{ github.run_id }}.log, but the existingUpload coverage reportsstep is not modified to include this file. If the guard fails in CI, the log evidence disappears when the runner cleans up. The artifact upload step should include the log:Without this, a failing guard gives you the
exit 1but no downloadable log to inspect.What's Good
'calls injected libLoader during init and sets pdfjsReady','leaves pdfjsReady false when libLoader rejects'.makeFakePdfjsLibandmakeFakeLibLoaderfactory functions follow the project pattern. One-line call sites.afterEach(cleanup)is correctly hoisted to the top level.usePdfRenderer.svelte.test.ts) and the browser-environment component tests (inPdfViewer.svelte.spec.ts) are updated — no coverage gap between the two test environments.if: always()on the CI guard is correct — runs even when the coverage step fails, which is exactly when you most want it.⚙️ Tobias Wendt — DevOps & Platform Engineer
Verdict: ⚠️ Approved with concerns
The CI changes are well-structured.
set -eo pipefail,tee, explicitshell: bash, andif: always()are all correct calls. One actionable issue before merge.Blockers
Log file is not preserved in artifact upload — evidence disappears on guard failure
The coverage log is written to
/tmp/coverage-test-${{ github.run_id }}.log. If the guard step fires and exits 1, you'll see theFAIL:line in the runner console — but the log file is gone as soon as the runner tears down. The existingUpload coverage reportsstep is not modified to include it:Without this, a CI failure gives you the exit code but no downloadable evidence. For a guard designed to catch a race condition that shows up intermittently, that log is the only forensics you have.
Suggestions
Use
-F(fixed string) in grep for robustnessThe current pattern
\[birpc\]relies on BRE escaping (\[= literal[). This works, but-F(fixed string) is more explicit and immune to accidental regex interpretation:Minor — the current version is not wrong, just less self-evident.
What's Good
set -eo pipefailin the coverage step: correct. The|innpm run test:coverage 2>&1 | tee ...would previously have masked a non-zero exit fromnpmwithoutpipefail.2>&1 | teecaptures both stdout and stderr into the log while still showing output in the runner console — the right pattern.if: always()on the guard step: essential. This ensures the birpc check runs even when the coverage step exits non-zero, which is the scenario you most need to inspect.2>/dev/nullon the grep: correct — silences the "No such file" error if the log was never created (e.g., the coverage step bailed before writing any output).github.run_idis runner metadata, not a secret.🎨 Leonie Voss — UX Designer & Accessibility Strategist
Verdict: ✅ Approved
This PR contains no UI changes, no Svelte template markup changes, no CSS or Tailwind modifications, and no accessibility-relevant DOM structure changes.
What I verified:
PdfViewer.sveltediff — the only template change is the addition oflibLoader = undefinedto the$props()destructuring. No HTML, no aria attributes, no styling, no focus management, no visual output changed.untrack()call is in the<script>block only — invisible to the rendered DOM.The UI test assertions in
PdfViewer.svelte.spec.tsstill check for the navigation buttons ("zurück", "weiter"), zoom controls, and the page counter — confirming the existing UI behavior is preserved under the new injection pattern.📋 Elicit — Requirements Engineer
Verdict: ✅ Approved
Reviewing against the originating issue (#535) and the PR's stated acceptance criteria.
Requirements Coverage
usePdfRenderer.svelte.test.ts— new test verifieslibLoaderis called andpdfjsReadybecomestrue'calls injected libLoader during init and sets pdfjsReady'PdfViewer.svelte.spec.ts— all 3 existing tests pass with injected fake loader, novi.mockmakeFakeLibLoader().gitea/workflows/ci.ymlAssert no birpc teardown race in coverage runworkflow_dispatchCI runs after mergeThe deferred acceptance criterion is correctly handled — it's a runtime quality gate that can't be validated in the PR itself. It is explicitly noted in the PR description as unchecked. No concern here.
Scope Verification
The PR is tightly scoped to issue #535. It introduces no new features, no new user-facing behavior, and no API contract changes. The
libLoaderprop is optional with a production default — existing callers require no changes.One Open Question
The PR description states: "the birpc route handler that races with worker teardown is never installed." ADR 012 documents this for pdfjs specifically. The ADR's residual exceptions table lists the
$app/*/$env/*modules as safe to keep asvi.mock.OQ-001: Is there a linting or static analysis rule (e.g., an ESLint rule or a Semgrep rule) planned to enforce the ADR 012 constraint automatically — i.e., to flag any future
vi.mock(externalLib, factory)call in browser-mode spec files? Without automation, the rule depends on reviewers remembering ADR 012, which is a weak enforcement mechanism for a constraint that's non-obvious.This is not a blocker for merge, but it's worth tracking as a follow-up issue if recurring violations are anticipated.
Round 3 review feedback addressed
Four commits pushed to this branch:
2163cd96— test(pdf-renderer): assert init() re-throws when libLoader rejectsawait r.init().catch(() => {})was swallowing the rejection, so the test passed vacuously even if a future refactor silently caught the loader error. Replaced withawait expect(r.init()).rejects.toThrow('load failed'), which proves two things: the error propagates frominit(), andpdfjsReadystaysfalse. Red phase was not achievable here —init()already propagates rejections (no try/catch) — this is a test contract tightening, not a new behavior.58eb6e27— refactor(test): remove what-comment from makeFakePdfjsLib// Partial fake: only the methods used by usePdfRenderer are implementedfromPdfViewer.svelte.spec.ts. The namemakeFakePdfjsLibalready implies it's a fake; the comment described what, not why.017b404f— ci(coverage): include coverage log in artifact upload/tmp/coverage-test-${{ github.run_id }}.logto theUpload coverage reportsartifact step. Without this, a guard failure leaves only theexit 1visible in the runner console — the log file with the actual[birpc] rpc is closedlines disappears when the runner tears down.48b7e0f3— ci(coverage): use grep -F for birpc guard to avoid BRE escapinggrep -q "\[birpc\] rpc is closed"withgrep -qF "[birpc] rpc is closed".-F(fixed string) matches the literal pattern without relying on BRE bracket escaping, making the intent explicit and immune to accidental regex interpretation.All 1851 tests pass (1 pre-existing flaky timeout in
confirm.svelte.test.ts— tracked as #538).👨💻 Felix Brandt — Senior Fullstack Developer
Verdict: ⚠️ Approved with concerns
Three rounds of review have tightened this PR well. I'll only flag angles not yet covered.
New observations
1.
init()has no concurrent-call guard (usePdfRenderer.svelte.ts)init()is called once fromonMounttoday, so re-entrancy isn't a real scenario right now. But nothing in the function prevents a second call while the first is still awaitinglibLoader():If
loadDocument()(which presumably checkspdfjsReadyor callsinit()defensively) ever races withonMount,libLoaderfires twice. A one-liner guard at the top would close it:Not a blocker now, but worth fixing before the pattern spreads.
2.
TextLayerMockuses old-style prototype assignment (PdfViewer.svelte.spec.ts)This works, but the rest of the file (and the codebase) uses TypeScript class syntax. The equivalent is cleaner and consistent:
Cosmetic, but it stands out in an otherwise modern file.
3.
makeFakePdfjsLibis duplicated across two test filesPdfViewer.svelte.spec.tsandusePdfRenderer.svelte.test.tseach define their own copy of the pdfjs fake. They're structurally similar but not identical. As more specs adopt thelibLoaderpattern (per ADR 012), this fake will be defined a third, fourth time. A shared__testUtils__/fakePdfjs.ts(or similar) would prevent drift. Not a blocker for this PR, but worth a follow-up issue before the next component adopts the pattern.🧪 Sara Holt — QA Engineer & Test Strategist
Verdict: ⚠️ Approved with concerns
Previous rounds covered test structure thoroughly. Two angles not yet raised:
1. CI guard is diagnostic, not detection — and only covers the coverage run
The guard step detects
[birpc] rpc is closedin the coverage log. But if a future developer re-introduces avi.mock(externalLib, factory)in a browser-mode spec, the birpc race would appear in bothnpm testandnpm run test:coverage.The CI pipeline runs
npm testbefore the coverage step. That run would fail with exit 1, no diagnostic message, and the developer gets a cryptic failure. Only if they then look at the later coverage run do they see the guard explanation.The guard is correct as a regression backstop, but it's mispositioned for diagnosis. Options:
tee-and-grep wrapper to the regularnpm teststep too (slight duplication)npm testrequires looking at the coverage run for the diagnostic messageNeither is a blocker, but the current state means a first-time failure diagnosis is non-obvious.
2. Loading state transitions are not covered in browser-mode specs
The three
PdfViewer.svelte.spec.tstests verify that buttons and the page counter are visible. The third test (displays the page counter once the PDF has loaded) implicitly exercises the async init path — but none of the tests check what the user sees whileinit()is pending.Between
onMountandpdfjsReady = true, the component is in a loading state. If it renders controls (as the first two tests suggest), users could click navigation before a document is loaded. If it renders a spinner, we have no test proving the spinner appears.This is pre-existing behavior, not introduced by this PR. But the new explicit
libLoaderseam makes the async boundary visible and testable in a way it wasn't before. A test for the "during init" state would fully close the behavior coverage:Whether this reveals a missing spinner or confirms controls appear immediately, either outcome is valuable. Suggest filing as a follow-up issue.
🔧 Tobias Wendt — DevOps & Platform Engineer
Verdict: ✅ Approved
Previous rounds addressed
pipefail,run_idscoping,-Fflag, and artifact upload. Nothing to add on those. One new observation:The diagnostic guard is positioned after the problem it diagnoses
If a future birpc regression is re-introduced, it shows up in both the regular
npm teststep and the coverage step. In CI,npm testruns beforenpm run test:coverage.The pipeline would:
npm test— fails with exit 1, no diagnostic outputnpm run test:coverage— fails with exit 1, tee captures outputif: always()fires, prints the[birpc] rpc is closeddiagnosticThe developer sees a failing pipeline, reads the summary, and finds the diagnostic only in step 3 of a step that ran after the already-failing
npm test. On first encounter, this is non-obvious — especially since the issue title is "birpc teardown race" and the guard is the only step that names it.This is a CI ergonomics concern, not a correctness issue. The guard works. But a one-line comment above the coverage step or the guard step would help future-Tobias navigate:
Or alternatively: wrap
npm testthe same way. Depends on whether the birpc race is reproducible outside coverage mode — which, given that coverage instrumentation adds load, it might not be.What's solid:
if: always(),2>/dev/null,-Fflag,run_idscoping, artifact inclusion — all correct. Good defensive posture for an intermittent race condition.🏛️ Markus Keller — Application Architect
Verdict: ✅ Approved
ADR 012, the
LibLoaderDI seam, and the module boundaries are all sound. Previous rounds covered this thoroughly. One forward-looking observation:Pattern proliferation: ADR 012 doesn't say when to revisit
LibLoader's homeADR 012 establishes the
libLoaderpattern for pdfjs-dist. That's one dynamic-import dependency in one viewer module. As written, if a future component needs to inject its own external library loader (e.g., a hypotheticalVideoPlayerinjecting a video codec library, or aChartViewerinjecting a charting library), the pattern calls for each component to:LibLoadertype in its own moduledefaultLibLoaderconstantThis is correct for 1–2 instances. At 4–5 instances, the pattern starts producing near-identical
LibLoadertypes in different modules:The ADR's "Consequences" section could benefit from a one-line note: "If three or more components adopt this pattern, consider a shared
$lib/types/lib-loader.tsor a genericDynamicImportLoader<T>type to avoid parallel type definitions."Not a blocker. Not even a suggestion for this PR. Just a "when to revisit" signal the ADR currently lacks.
🔐 Nora "NullX" Steiner — Application Security Engineer
Verdict: ✅ Approved
Security surface is unchanged from prior rounds. One angle not previously raised:
CI log artifact captures full test output — check for sensitive test data
The coverage step pipes
2>&1intotee /tmp/coverage-test-${{ github.run_id }}.log, which is then uploaded as a CI artifact. This means the complete vitest stdout+stderr — including test names, assertion messages, and any printed values — is archived as a downloadable artifact.In isolation this is standard practice. The specific concern for this project: Familienarchiv test fixtures should use synthetic family data (fake names, fake dates, fake document content), not copies of real archive entries. If a test ever uses a real person's name or document text as a fixture value, it would appear in the CI artifact log.
This is not a bug introduced by this PR (the coverage log has always captured this output; this PR just adds the log to the artifact upload). But it's the first time that output becomes a persistently downloadable artifact, so it's worth a quick review of browser-mode spec fixtures to confirm they use clearly synthetic data.
Specific files to spot-check:
PdfViewer.svelte.spec.ts(uses URL/api/documents/test-id/file— synthetic ✅),usePdfRenderer.svelte.test.ts(no document data — ✅). Looks clean. Just flagging the category for awareness as more specs are added.🎨 Leonie Voss — UX Designer & Accessibility Strategist
Verdict: ✅ Approved with one pre-existing observation
No UI changes in this PR — template markup, ARIA attributes, Tailwind classes, and focus management are all untouched. Previous rounds confirmed this and I agree.
Pre-existing gap surfaced by the explicit
pdfjsReadyflagThis PR introduces
pdfjsReadyas an explicit, named reactive state. Looking at the component from the outside: betweenonMountandpdfjsReady = true, the PDF viewer is in an indeterminate state. The browser-mode specs confirm that navigation buttons and zoom controls are visible immediately (the first two tests render and assert without waiting for init). This means:init()propagates a rejection. The test now confirmspdfjsReadystaysfalse, but what does the user see? Presumably an indefinite loading state with no message.This is pre-existing behavior — nothing in this PR makes it worse. But now that
pdfjsReadyhas a name and a test, the loading/error state design gap is more visible than before.Recommend filing a follow-up issue: "UX: PDF viewer has no loading indicator or error state when pdfjs-dist fails to initialise." The
libLoaderinjection pattern in this PR makes that issue testable in isolation, which is a win.📋 Elicit — Requirements Engineer
Verdict: ✅ Approved
Requirements traceability and test plan coverage are solid and have been verified across three rounds. Two items still unresolved:
OQ-001 (raised round 3, still open): ADR 012 enforcement via static analysis
Round 3 raised whether an ESLint or Semgrep rule should enforce the ADR 012 constraint automatically. No response or follow-up issue has appeared. The current enforcement mechanism is:
This is a weak prevention layer. The CI guard detects a regression after it's been committed and merged into a test run; it doesn't prevent the offending
vi.mockfrom being committed at all. A pre-commit or CI lint rule would catch it at the right moment.If enforcement via lint isn't planned, that decision should be explicit in the ADR ("no automated enforcement; CI guard is the backstop") so future reviewers don't wonder why.
The 60-run quality gate is underspecified for post-merge operation
The PR body states: "60 consecutive green
workflow_dispatchCI runs againstmainafter merge." Across three review rounds, multiple personas have asked whether this is tracked. Still no confirmation.Beyond tracking, the criterion itself has operationalization gaps:
A quality gate without a process is a hope, not a gate. Recommend adding a comment to issue #535 (or a dedicated follow-up issue) that answers these questions, or explicitly accepting the informal interpretation: "if the coverage guard stays silent for 60 runs of any kind on main, we're satisfied."
Round 4 review feedback addressed
Four commits pushed to this branch:
0b3ce838— test(pdf-renderer): guard init() against repeated calls — libLoader must fire onceinit()had no idempotency guard — calling it twice would invokelibLoadertwice. Added failing test first (init()called twice →fakeLoadercalled twice → red), then addedif (pdfjsReady) return;at the top ofinit()→ green. Closes the re-entrancy gap before the pattern spreads.185c754e— refactor(test): convert TextLayerMock to class syntax in PdfViewer specTextLayerMockused old-style prototype assignment — a leftover from thevi.mockera. Converted to class syntax to match the rest of the codebase. All 3PdfViewertests still pass.8396d82c— ci(coverage): document that birpc guard covers coverage run only[birpc]failure innpm testknows to look at the coverage guard step for the diagnostic message.3d9e10f3— docs(adr-012): add enforcement note and libLoader revisit signallibLoader, consider a sharedDynamicImportLoader<T>type.Deferred as follow-up issues:
test: share fakePdfjs fixture across viewer test files(Felix concern #3)UX: PDF viewer has no loading indicator or error state when pdfjs-dist fails to initialise(Sara concern #2 + Leonie)test: fix flaky browser-mode tests in AnnotationShape and OcrTrainingCard specs(pre-existing flaky tests)60-run gate operationalization: answered in a comment on #535.
Test suite: 1851 passed / 1853 total. The 2 remaining failures are
OcrTrainingCard.svelte.spec.ts(pre-existingtrack_reactivity_lossflake, tracked as #541) — unrelated to this PR.👨💻 Felix Brandt — Senior Fullstack Developer
Verdict: ✅ Approved
What I checked
TDD evidence, naming, function size, Svelte 5 patterns, test factory discipline, and comment hygiene.
Positives
TDD evidence is solid. Three new tests cover every behavioral change:
'calls injected libLoader during init and sets pdfjsReady'— happy path'leaves pdfjsReady false when libLoader rejects'— error path'init() is idempotent — libLoader called only once on repeated calls'— the key guard behaviourThe idempotency guard
if (pdfjsReady) return;inusePdfRenderer.svelte.ts:26is the most important production change — it has its own test, which is correct.Naming and structure are clean.
LibLoader,defaultLibLoader,makeFakePdfjsLib(),makeFakeLibLoader()— all names reveal intent. The// untrack: libLoader prop change must not reinitialise the renderercomment inPdfViewer.svelte:39is exactly the right comment — it explains the WHY (a reactive re-init invariant), not the WHAT.TextLayerMockconverted to class syntax — correct modernization; the constructor-function form was a JS quirk that is now gone.Suggestions (non-blocking)
Duplicated
fakePdfjsliteral inusePdfRenderer.svelte.test.ts. The same inline object ({ GlobalWorkerOptions, getDocument, TextLayer }) is constructed independently in two tests.PdfViewer.svelte.spec.tsalready hasmakeFakePdfjsLib()as a factory — the same helper should move to (or be shared by)usePdfRenderer.svelte.test.tsto prevent drift if the shape changes.This is cosmetic — doesn't affect correctness — but is consistent with the project's factory-function discipline.
🏛️ Markus Keller — Application Architect
Verdict: ✅ Approved
What I checked
ADR quality, abstraction placement, doc update obligations, and forward-looking extensibility notes.
Positives
ADR 012 is the right artifact for this decision. Root cause, decision, pattern example, residual exceptions table, and consequences are all present and specific. The table of acceptable
vi.mockcalls ($app/*,$env/*) with their justification is exactly the kind of knowledge that would otherwise silently erode — it now lives in the repo permanently.Abstraction is at the right layer.
LibLoaderlives inusePdfRenderer.svelte.ts, not in the component. The component just threads the optional prop through viauntrack(). This matches the "logic in hooks, not components" principle we apply across the frontend.The "When to revisit the LibLoader home" note is correct deferral. Extracting a shared
$lib/types/lib-loader.tsat three adopters is the right threshold — not prematurely, not retroactively.Doc update check
docs/adr/No missing doc updates.
One observation (non-blocking)
The ADR states "No automated lint rule is planned; the CI coverage guard is the regression backstop." That's a valid decision, but it creates a single point of failure: if someone changes the grep path or the log file location in CI, the guard silently stops detecting regressions. Consider adding one sentence to the ADR's Consequences section: "If the CI guard step is modified (log path, grep pattern), update this ADR's Enforcement section." This makes the dependency explicit.
🔧 Tobias Wendt — DevOps & Platform Engineer
Verdict: ✅ Approved
What I checked
Shell correctness, CI step ordering,
if:conditions, artifact upload, grep usage, and log file lifecycle.Positives
set -eo pipefailis correct. Theteepipe cannot silently swallow a non-zero exit fromnpm run test:coverage. This is the right way to write a multi-command CI step.2>&1 | teecorrectly captures both stdout and stderr into the log file. Coverage output from vitest typically arrives on stderr — without2>&1, the grep would miss it.if: always()on the guard step is critical — the check must run even when the coverage step exits 1 (which is exactly the scenario we're guarding against). Correct.grep -F(fixed string) avoids regex interpretation of[birpc]. The[character is a regex metacharacter;-Fbypasses this entirely. Previous commits already fixed a-E/ BRE issue — this PR carries that fix correctly.2>/dev/nullin the guard's grep handles the edge case where the log file doesn't exist (e.g., the coverage step died before creating it). Correct defensive coding.Artifact upload extended to include the log file. This means CI failures are debuggable from the Gitea artifact without needing to re-run. Good.
Minor observation (non-blocking)
${{ github.run_id }}makes the log file unique per workflow run, but not per re-run attempt. If a workflow is re-triggered (samerun_id, differentrun_attempt), the log file is overwritten by the second attempt. This is harmless — the second attempt's coverage run produces a fresh file — but${{ github.run_id }}-${{ github.run_attempt }}would be strictly safer. Not a blocker for this PR.The step comment "Diagnostic guard: covers the coverage run only. If
npm test(above) exits 1 with a birpc error, the named pattern appears here — not there." is valuable — it correctly explains why this is not a general test gate, preventing future maintainers from expanding its scope incorrectly.📋 Elicit — Requirements Engineer
Verdict: ✅ Approved
What I checked
PR scope alignment with issue #535, acceptance criterion measurability, decision documentation completeness, and residual open items.
Positives
The fix is precisely scoped. The root cause (dynamic imports via
vi.mockfactories + birpc teardown race) is correctly identified, and the solution (prop injection /libLoaderparameter) is the minimal intervention that eliminates the race without touching unrelated code.The PR description is a well-structured requirements artifact. It includes:
main)ADR 012 captures the residual exceptions clearly. The table of acceptable
vi.mockcalls with their justification ($app/navigationetc.) directly answers "what are the boundaries of this rule?" — a question that would otherwise produce inconsistent interpretations across future PRs.Observations
The post-merge acceptance criterion needs a tracking mechanism. The "60 consecutive green runs" criterion is measurable but only verifiable by reviewing CI history. Since Gitea doesn't auto-count consecutive successes, this requires manual verification. Recommend adding a comment to issue #535 after merge with a concrete plan: e.g., check back after X weeks and post the CI run IDs that confirm the streak.
The unchecked item
[ ] 60 consecutive green workflow_dispatch CI runsis correctly marked unchecked — this is honest scope management, not a gap in the PR. Ensuring issue #535 stays open until this criterion is met is the right process.🔒 Nora "NullX" Steiner — Application Security Engineer
Verdict: ✅ Approved
What I checked
New attack surface, input propagation paths, CI script injection vectors, and any changes touching auth or session code.
Security surface analysis
No new attack surface introduced. The
libLoaderprop is optional and typed (LibLoader | undefined). It defaults to the real dynamic import. No user-controlled input flows into this prop — it is only set in test code, never from a form field, URL parameter, or API response.The
untrack()call prevents reactive re-initialization. This is a correctness guard (prevents the renderer from being recreated on prop change), not a security concern. Correctly placed.CI script uses
grep -F(fixed string). The string"[birpc] rpc is closed"contains[and], which are regex metacharacters.-Fbypasses regex interpretation entirely — correct. A previous commit already fixed a BRE escaping issue; this PR carries the safe form.2>/dev/nullon the grep prevents log-file-not-found from being treated as a passing state. If the log file is absent (coverage step died), grep exits non-zero with an error message — without2>/dev/nullthat error would appear in CI output but theifcheck still functions correctly because theifblock only fires ongrepexit code 0. Safe.No authentication, authorization, backend, or session code touched. OWASP Top 10 checklist: not applicable to this PR scope.
Nothing to flag.
🧪 Sara Holt — QA Engineer & Test Strategist
Verdict: ⚠️ Approved with concerns
What I checked
Test coverage for new behaviour, factory function discipline, CI gate quality, error path coverage, and test naming.
Positives
Three new tests with distinct behavioural goals in
usePdfRenderer.svelte.test.ts:'calls injected libLoader during init and sets pdfjsReady'— verifies the injection contract and the reactive state transition'leaves pdfjsReady false when libLoader rejects'— covers the error path; critical since a failed load should not silently leave the renderer in an indeterminate state'init() is idempotent — libLoader called only once on repeated calls'— directly tests theif (pdfjsReady) return;guardAll three names read as sentences describing behaviour. ✅
Existing three browser-mode specs in
PdfViewer.svelte.spec.tsare updated to usemakeFakeLibLoader()— no orphaned tests, no vi.mock bypass remaining.makeFakePdfjsLib()andmakeFakeLibLoader()factory functions are clean and follow the project's factory discipline.afterEach(cleanup)is present inPdfViewer.svelte.spec.ts. ✅CI guard step (
Assert no birpc teardown race in coverage run) is a meaningful quality gate addition — it will catch regressions that only manifest in the coverage run, which is exactly the scenario that triggered #535.Concerns
Duplicated
fakePdfjsconstruction inusePdfRenderer.svelte.test.ts(non-blocking). The inline object literal forfakePdfjsis copy-pasted in two separate tests.PdfViewer.svelte.spec.tscorrectly extracts this tomakeFakePdfjsLib(). The unit test file should do the same:If the
pdfjs-disttype shape changes, only one place needs updating instead of two.No browser-mode test for the
libLoaderrejection path inPdfViewer.svelte.spec.ts(non-blocking). The unit test inusePdfRenderer.svelte.test.tscovers this path at the hook level. Since the component just callsrenderer.init()fromonMount, covering the rejection at the hook level is sufficient. A comment in the spec file noting this delegation would prevent a future reviewer from wondering if the error path is tested.🎨 Leonie Voss — UX Designer & Accessibility Strategist
Verdict: ✅ Approved
What I checked
Visual changes to
PdfViewer.svelte, rendered output, accessibility attributes, touch targets, and brand token usage.Assessment
This PR makes no user-facing changes.
The only modification to
PdfViewer.svelteis:untrackandLibLoader(internal, not rendered)libLoader = undefinedto the props destructuring (not rendered)createPdfRenderer()inuntrack()(not rendered)The
libLoaderprop is a test seam — it affects how the PDF rendering library is loaded during tests, but the rendered output (controls, page counter, canvas, text layer) is identical to the previous version in every production scenario.Accessibility unchanged. No ARIA attributes added or removed. No interactive elements modified. No focus management affected.
Brand tokens unchanged. No new Tailwind classes introduced in the component template.
LGTM.
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>- 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>.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>3d9e10f3e7toefece12f29