fix(pdf-viewer): eliminate real pdfjs-dist loading from browser tests — stop birpc teardown race #550
Reference in New Issue
Block a user
Delete Branch "worktree-feat+issue-546-pdf-viewer-test-fix"
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?
Root cause
The two previous PRs removed
vi.mock('pdfjs-dist', factory)from test files, but missed a subtler trigger: five tests inusePdfRenderer.svelte.test.tscalledcreatePdfRenderer()without a fakelibLoader, soinit()performed a real dynamicimport('pdfjs-dist')inside the browser.In vitest browser mode every dynamic import — mocked or not — is intercepted by Playwright's route handler, which calls
resolveManualMockvia birpc to check for mocks. If the birpc RPC channel closes during teardown while one of these imports is still in flight, the race fires:This is why the error persisted even after
vi.mock('pdfjs-dist')was banned.Fix
Add
makeFakeLibLoader()tousePdfRenderer.svelte.test.ts(same pattern asPdfViewer.svelte.test.ts) and thread it into the five affected tests:createPdfRenderer()→ real pdfjs-dist loads in browsercreatePdfRenderer(makeFakeLibLoader())→ pre-resolved Promise, no network requestNo real module loads, no Playwright route-handler calls, no birpc exposure.
Test semantics are preserved: all assertions (
pdfjsReady,loading, no-throws) still hold against the fake.Test plan
Assert no birpc teardown race in coverage runstep passes greenno-restricted-syntaxrule (ADR 012) still catches any futurevi.mock('pdfjs-dist')regressions🤖 Generated with Claude Code
👨💻 Felix Brandt — Senior Fullstack Developer
Verdict: ⚠️ Approved with concerns
The root-cause fix is correct and the migration to
makeFakeLibLoader()injection is clean. A few things worth addressing.Blockers
None.
Suggestions
Duplicated
makeFakeLibLoader()across two test files (DRY violation)PdfViewer.svelte.test.tsandusePdfRenderer.svelte.test.tseach define their ownmakeFakeLibLoader()with different fidelity levels:PdfViewerversion: full fake —getPageresolves withgetViewport,render,streamTextContentusePdfRendererversion: lighter fake —getPageis justvi.fn()When
LibLoader's return type changes, both fakes need updating independently. ADR-012 already anticipates this ("If three or more components adopt this pattern, consider extracting…") — we're already at two with diverging implementations. Consider extracting a sharedmakeLibLoader(overrides?)to asrc/lib/document/viewer/testHelpers.tsnow rather than waiting for a third adopter.Error path for
loadDocumentwas silently droppedThe old test:
The new test only asserts the happy path (
loadingreturns tofalse). Theerrorstate on a rejected loader is untested. This doesn't have to be a blocker — if the error path is covered by an integration test elsewhere — but if not, it's a gap.Minor: assertion style inconsistency in absorbed tests
The three tests absorbed from the deleted
.spec.tsfile usetoBeInTheDocument()while the surrounding tests indescribe('PdfViewer — loaded state')usetoBeVisible(). Both are valid but mixing them in the same describe block is inconsistent.toBeVisible()is the stricter assertion (element in DOM and not hidden), so prefer it uniformly.Test names: improvement noted ✅
'init() sets pdfjsReady to true when loader resolves'and'after init, loadDocument completes and loading returns to false'are clearer than the old'is callable and resolves without throwing'variants. Good signal-to-noise improvement here.🏛️ Markus Keller — Application Architect
Verdict: ✅ Approved
This is a well-bounded test infrastructure fix with no architectural impact. The decisions are sound and the documentation keeps up with the code.
What I checked
ADR-012 update is correct and complete. The change from "No automated lint rule is planned" to documenting the actual ESLint rule + CI grep guard is accurate. The belt-and-suspenders rationale (lint catches it on save, CI grep catches it before tests launch, coverage guard catches the race symptom) is a layered, defensible approach. The "When to revisit the LibLoader home" note is still there and still correct — triggering at three adopters is a reasonable threshold.
No doc update matrix triggers. No new routes, no new backend packages, no Flyway migrations, no new domain terms, no new Docker services. Nothing in the update table applies here. The ADR update was the only documentation obligation and it was met.
LibLoader prop injection pattern is architecturally sound. Injecting a loader function as a component prop rather than relying on dynamic imports is a clean inversion of control. The test-seam design — pass a fake at call time, default to the real loader in production code — is the correct pattern for this class of problem in a browser-mode test environment.
One forward-looking note (not a blocker): Felix is right that
makeFakeLibLoader()is now duplicated with different fidelity levels. When theLibLoadertype evolves, both fakes will drift. If a third component adopts this pattern, that's the hard signal to extract a shared test helper. The ADR already says so; just making sure it stays actionable.🔧 Tobias Wendt — DevOps & Platform Engineer
Verdict: ✅ Approved
Clean CI addition. The new grep step is fast, unambiguous, and positioned correctly.
What I checked
CI step placement is correct. The
Assert no banned vi.mock patternsstep runs afterlintand before the test suite. That's the right order — cheap static check blocks an expensive test run if someone slips in a banned pattern.Grep logic is correct.
if grep -rF "vi.mock('pdfjs-dist'" frontend/src/; then echo FAIL; exit 1; fi—grepexits 0 on match, so thethenbranch fires, echoes the actionable message, and exits 1. Works exactly as intended.Path is correct. The step runs from the repo root (no
working-directoryoverride), and the pathfrontend/src/is relative to repo root — consistent with how the workflow is structured.The failure message is developer-friendly. It references ADR 012 and tells the developer what to do instead. A CI failure message that says "use the libLoader prop injection pattern instead" takes 10 seconds to understand vs. a bare exit code.
No concerns with the ESLint config change. The
no-restricted-syntaxrule is scoped to**/*.spec.tsand**/*.test.tsonly — it won't fire on production code. The AST selector targets the specificvi.mock('pdfjs-dist', ...)call expression pattern, not a blanketvi.mockban.No infrastructure changes required. No new Docker services, no compose file changes, no new CI dependencies. Fully self-contained.
🔐 Nora "NullX" Steiner — Application Security Engineer
Verdict: ✅ Approved
This PR touches only test infrastructure and CI configuration. No security-relevant code paths are modified.
What I checked
.test.ts,.spec.ts), ESLint config, CI workflow, and an ADR update. No auth paths, no controllers, no permission checks, no data access.LibLoaderproduces no exploitable surface. ThemakeFakeLibLoader()returns a pre-resolved Promise with an in-memory fake. No network calls, no file access, no untrusted input — nothing to exploit in a test context.grep -rFwith a fixed string is not injectable —-Ftreats the pattern as a literal string, not a regex. No user-controlled input reaches this step.Nothing to flag here. The security posture of the application is unchanged.
🧪 Sara Holt — QA Engineer & Test Strategist
Verdict: ⚠️ Approved with concerns
The core fix is correct and the test consolidation is an improvement. Two concerns worth tracking.
Blockers
None — but one gap deserves a follow-up ticket.
Suggestions
1. Error path for
loadDocumentwas removed without replacementThe old
usePdfRenderer.svelte.test.tshad:The new test covers
loading → falseon the happy path but drops the error branch entirely. Theerrorstate ofcreatePdfRenderer()whengetDocumentrejects is now untested. This could hide a regression in error handling. Consider adding:2. Diverging fake fidelity between test files
PdfViewer.svelte.test.tsbuilds a richmakeFakePdfjsLib()withgetPagereturninggetViewport,render, andstreamTextContent.usePdfRenderer.svelte.test.tsuses a lighter fake wheregetPageisvi.fn()with no implementation. This asymmetry means the two suites are testing against different fakes. IfrenderCurrentPagebehavior changes, the lighter fake may silently pass while the richer fake would catch it. This isn't a blocker now, but it's the kind of drift that produces silent test gaps. Tracking ticket recommended alongside the shared helper extraction.What's good:
usePdfRendererare meaningfully improved ('init() sets pdfjsReady to true when loader resolves'is measurably better than the old'is callable and resolves without throwing').afterEach(cleanup)is properly placed inPdfViewer.svelte.test.ts— no test isolation risk.📋 Elicit — Requirements Engineer
Verdict: ✅ Approved
The PR fully satisfies the requirements it sets out to address. No requirements gaps.
What I checked
Root cause is precisely stated and traced to the fix. The PR body explains the causal chain (real
import('pdfjs-dist')→ Playwright route handler → birpcresolveManualMock→ race on teardown) and maps it directly to the intervention (inject a pre-resolved fake, no route-handler call, no birpc exposure). This is rare clarity for a flaky-test fix.Acceptance criteria are concrete and verifiable:
Assert no birpc teardown race in coverage runstep passes green" — testable in CI ✅no-restricted-syntaxrule still catches any futurevi.mock('pdfjs-dist')regressions" — testable by running lint ✅The three-layer enforcement hierarchy is well-documented: lint on save (ESLint rule) → pre-test static check (CI grep) → post-test symptom guard (birpc grep in coverage log). Each layer catches a different failure mode at a different cost. This is a maintainable and understandable regression-prevention strategy.
One open question not addressed in the PR (not a blocker): The acceptance criterion for issue #535 mentions "60 consecutive green
workflow_dispatchCI runs againstmainafter the fix is merged." This is a durability criterion, not a merge criterion. It should be tracked in the linked issue rather than in this PR's test plan to avoid the impression that it must pass before merging.🎨 Leonie Voss — UX Designer & Accessibility Strategist
Verdict: ✅ Approved
This PR makes no changes to any UI component, Svelte template, CSS, or user-facing behavior. The diff is entirely test infrastructure, CI configuration, ESLint rules, and documentation.
Nothing in my review scope is touched. No accessibility findings, no brand compliance issues, no responsive layout concerns. LGTM.
Review concerns addressed — commit
8dd9e58fAll suggestions from the review round have been resolved:
makeFakeLibLoader()duplicated with diverging fidelitytestHelpers.ts; both test files now import from it. The rich PdfViewer-level fake (withgetViewport,render,streamTextContent) is the shared baseline.loadDocumenterror path untested'loadDocument sets error and loading=false when getDocument().promise rejects'tousePdfRenderer.svelte.test.ts. Verified againstloadDocument's existingcatch/finallyblock.toBeInTheDocument()in the three absorbed teststoBeVisible()— uniform assertion style across theloaded statedescribe block.🤖 Generated with Claude Code
👨💻 Felix Brandt — Senior Fullstack Developer
Verdict: ✅ Approved
Round 2 review. All three concerns I flagged in round 1 have been addressed cleanly.
What was fixed
DRY violation — resolved.
testHelpers.tsis now the single source of truth for bothmakeFakePdfjsLib()andmakeFakeLibLoader(). Both test files import from it. The deletedPdfViewer.svelte.spec.tseliminates the parallel fake definition entirely. Clean.Missing error path — resolved.
usePdfRenderer.svelte.test.tsnow has:This correctly uses an inline failing lib (rather than
makeFakePdfjsLib()which only returns a success stub), verifiesr.loading === falseandr.error === 'PDF not found'. Well-structured.Assertion style — resolved. The three absorbed tests from
spec.tsall usetoBeVisible(), consistent with every other assertion in theloaded statedescribe block.Observations on the new commit
The test name changes are improvements:
'init() is callable and resolves without throwing in browser env'→'init() sets pdfjsReady to true when loader resolves'— describes the behavior, not the mechanics'after init, loadDocument with a bogus URL sets error'→'after init, loadDocument completes and loading returns to false'— accurate now that the fake always resolves successfullyThe ESLint
no-restricted-syntaxselector:This is AST-precise and scoped correctly to
**/*.spec.tsand**/*.test.ts. No broader than it needs to be.No blockers
testHelpers.tsis well-structured. If a third component adoptsLibLoader, ADR-012 already calls out the extraction point. Nothing premature here.🧪 Sara Holt — Senior QA Engineer
Verdict: ✅ Approved
Round 2 review. Both issues I raised in round 1 are resolved.
What was fixed
Fidelity divergence — eliminated.
PdfViewer.svelte.spec.tsis deleted. There is now exactly one fake implementation:testHelpers.ts. BothPdfViewer.svelte.test.tsandusePdfRenderer.svelte.test.tsimport from it. No drift possible.loadDocumenterror path — covered. The new test inusePdfRenderer.svelte.test.ts:Two assertions — one per observable state change. Each assertion has exactly one reason to fail. This is the right pattern.
Test quality notes
The test correctly uses an inline
failingLibrather than repurposingmakeFakePdfjsLib(). The shared helper only models the success path; the error path test needs a custom stub wheregetDocument().promiserejects. That's the right call — don't bend a shared fixture to serve a single test's edge case.Test names are now behaviorally accurate throughout. "init() sets pdfjsReady to true when loader resolves" — you know exactly what breaks if this fails.
The
afterEach(cleanup)inPdfViewer.svelte.test.tshandles DOM teardown correctly.usePdfRenderer.svelte.test.tstests pure state logic, no DOM to clean up — correct omission.No blockers
Test suite structure is solid. Fake layer is DRY. Error paths are covered. Three-layer enforcement (ESLint + CI grep + birpc grep guard) gives me high confidence this regression class won't return.
🏛️ Markus Keller — Application Architect
Verdict: ✅ Approved
Round 2 review. No architectural concerns in this PR; my round 1 assessment stands and the new commit reinforces the right patterns.
What I checked
testHelpers.tsplacement. Co-located insrc/lib/document/viewer/— correct. This is test-layer infrastructure scoped to the PDF viewer domain. It does not leak into production code paths.LibLoadertype ownership. Still lives inusePdfRenderer.svelte.ts, exported from there. Appropriate for the current scope. ADR-012 carries the right note:Three-layer enforcement. ESLint (lint-time) + CI grep (pre-test) + birpc coverage grep (post-test) is a good defense-in-depth strategy for a pattern that's hard to catch otherwise. Each layer catches it at a different point in the developer feedback loop.
ADR-012 update. The enforcement section now accurately reflects what's actually implemented. ADRs must stay current — this one does.
No blockers
🔧 Tobias Wendt — DevOps & Platform Engineer
Verdict: ✅ Approved
Round 2 review. CI changes are solid. Let me walk through what the new step does.
CI grep step
-F(fixed string) is the right flag. No regex, no risk of accidental shell meta-character expansion. Fast on large trees.Positioned before the test suite. This fails fast — the grep runs in seconds before Playwright/Chromium spins up. Developer gets immediate feedback without waiting for the full browser test run.
Covers both quote styles? The pattern
vi.mock('pdfjs-dist'(single quotes) is what the spec used. The ESLint AST rule catches the syntactic form regardless of quote style. Together they have complementary coverage — the grep catches the most common human-typed form, the ESLint rule catches it via the AST parser.Three-layer regression backstop is well-structured
Each layer is cheap to run and independent. Good belt-and-suspenders.
No blockers
🔒 Nora Steiner — Application Security Engineer
Verdict: ✅ Approved
Round 2 review. This PR is pure test infrastructure — no production code paths, no API surface, no auth logic changed.
What I checked
The
vi.fn().mockResolvedValue([fakePdfjs, { default: '' }])pattern creates no real network activity. The fakegetDocument()returns a pre-resolved promise in memory. No SSRF vectors, no actual fetch calls to mock intercept issues.The
fetchSpypattern used in the annotation tests (vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(...))) is correctly scoped withtry/finally { fetchSpy.mockRestore() }. No test-global mock leakage.The new
testHelpers.tsexportsvifrom vitest — test-only dependency, not bundled in production.No security findings
📋 Elicit — Requirements Engineer
Verdict: ✅ Approved
Round 2 review. This PR closes issue #546 (fix PDF viewer test flakiness) and in the process makes the ADR and enforcement machinery match the implementation.
Requirements traceability
The original issue (#535 birpc race → #546 follow-up) had a clear acceptance criterion: 60 consecutive green CI runs with zero
rpc is closedlines. The root cause was confirmed asvi.mock('pdfjs-dist', factory)triggering Playwright's route handler during teardown.This PR addresses that in three layers:
The acceptance criterion in ADR-012 (60 consecutive green runs) remains unchanged. That's correct — the enforcement machinery helps prevent regressions, but the acceptance criterion is a runtime measurement that stands on its own.
ADR-012 enforcement section
The updated text accurately describes all three layers and their rationale. It also carries a forward-looking note about extracting a shared
LibLoadertype if the pattern spreads — appropriate scope management.No requirements gaps found
🎨 Leonie Voss — UX Designer & Accessibility Strategist
Verdict: ✅ Approved
Round 2 review. This PR contains no user-facing changes — it's entirely test infrastructure and CI configuration. No UI components, no CSS, no Svelte template markup, no accessibility implications.
LGTM from the design and accessibility perspective.
8dd9e58fa4tod21ba8fed2