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>
This commit is contained in:
Marcel
2026-05-11 21:30:15 +02:00
parent 0142256b3c
commit b00ffc358f
2 changed files with 19 additions and 16 deletions

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onMount, setContext } from 'svelte'; import { onMount, setContext, untrack } from 'svelte';
import { createPdfRenderer } from '$lib/document/viewer/usePdfRenderer.svelte'; import { createPdfRenderer } from '$lib/document/viewer/usePdfRenderer.svelte';
import PdfControls from './PdfControls.svelte'; import PdfControls from './PdfControls.svelte';
import AnnotationLayer from '$lib/document/annotation/AnnotationLayer.svelte'; import AnnotationLayer from '$lib/document/annotation/AnnotationLayer.svelte';
@@ -21,7 +21,8 @@ let {
onDeleteAnnotationRequest, onDeleteAnnotationRequest,
documentFileHash, documentFileHash,
annotationsDimmed = false, annotationsDimmed = false,
flashAnnotationId = null flashAnnotationId = null,
libLoader = undefined
}: { }: {
url: string; url: string;
documentId?: string; documentId?: string;
@@ -35,9 +36,10 @@ let {
documentFileHash?: string | null; documentFileHash?: string | null;
annotationsDimmed?: boolean; annotationsDimmed?: boolean;
flashAnnotationId?: string | null; flashAnnotationId?: string | null;
libLoader?: Parameters<typeof createPdfRenderer>[0];
} = $props(); } = $props();
const renderer = createPdfRenderer(); const renderer = untrack(() => createPdfRenderer(libLoader));
// Canvas and text layer container refs — bound via bind:this // Canvas and text layer container refs — bound via bind:this
let canvasEl = $state<HTMLCanvasElement | null>(null); let canvasEl = $state<HTMLCanvasElement | null>(null);

View File

@@ -1,11 +1,11 @@
import { vi, describe, it, expect, afterEach } from 'vitest'; import { vi, describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte'; import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser'; import { page } from 'vitest/browser';
import type { createPdfRenderer } from '$lib/document/viewer/usePdfRenderer.svelte';
// pdfjs-dist is a rendering dependency — we mock it so unit tests don't need afterEach(cleanup);
// a real browser PDF engine. The interesting behaviour under test here is the
// component's own UI logic (controls, page counter), not pdfjs internals. function makeFakePdfjsLib() {
vi.mock('pdfjs-dist', () => {
function TextLayerMock() {} function TextLayerMock() {}
TextLayerMock.prototype.render = () => Promise.resolve(); TextLayerMock.prototype.render = () => Promise.resolve();
TextLayerMock.prototype.cancel = () => {}; TextLayerMock.prototype.cancel = () => {};
@@ -23,31 +23,32 @@ vi.mock('pdfjs-dist', () => {
}) })
}), }),
TextLayer: TextLayerMock TextLayer: TextLayerMock
}; } as unknown as typeof import('pdfjs-dist');
}); }
vi.mock('pdfjs-dist/build/pdf.worker.min.mjs?url', () => ({ default: '' })); function makeFakeLibLoader(): Parameters<typeof createPdfRenderer>[0] {
const fakePdfjs = makeFakePdfjsLib();
return vi.fn().mockResolvedValue([fakePdfjs, { default: '' }] as const);
}
import PdfViewer from './PdfViewer.svelte'; import PdfViewer from './PdfViewer.svelte';
afterEach(cleanup);
describe('PdfViewer', () => { describe('PdfViewer', () => {
it('shows previous and next page navigation buttons', async () => { it('shows previous and next page navigation buttons', async () => {
render(PdfViewer, { url: '/api/documents/test-id/file' }); render(PdfViewer, { url: '/api/documents/test-id/file', libLoader: makeFakeLibLoader() });
await expect.element(page.getByRole('button', { name: /zurück/i })).toBeInTheDocument(); await expect.element(page.getByRole('button', { name: /zurück/i })).toBeInTheDocument();
await expect.element(page.getByRole('button', { name: /weiter/i })).toBeInTheDocument(); await expect.element(page.getByRole('button', { name: /weiter/i })).toBeInTheDocument();
}); });
it('shows zoom controls', async () => { it('shows zoom controls', async () => {
render(PdfViewer, { url: '/api/documents/test-id/file' }); render(PdfViewer, { url: '/api/documents/test-id/file', libLoader: makeFakeLibLoader() });
await expect.element(page.getByRole('button', { name: /vergrößern/i })).toBeInTheDocument(); await expect.element(page.getByRole('button', { name: /vergrößern/i })).toBeInTheDocument();
await expect.element(page.getByRole('button', { name: /verkleinern/i })).toBeInTheDocument(); await expect.element(page.getByRole('button', { name: /verkleinern/i })).toBeInTheDocument();
}); });
it('displays the page counter once the PDF has loaded', async () => { it('displays the page counter once the PDF has loaded', async () => {
render(PdfViewer, { url: '/api/documents/test-id/file' }); render(PdfViewer, { url: '/api/documents/test-id/file', libLoader: makeFakeLibLoader() });
// Mock resolves synchronously, so "1 / 2" should appear quickly // Fake loader resolves synchronously, so "1 / 2" should appear quickly
await expect.element(page.getByText(/1\s*\/\s*2/)).toBeInTheDocument(); await expect.element(page.getByText(/1\s*\/\s*2/)).toBeInTheDocument();
}); });
}); });