From 4c57a2262ff1925f78eb13c3a0bac3ac5ae2f46c Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 1 Jun 2026 20:41:18 +0200 Subject: [PATCH] test(frontend): guard wasm shipping at build time, drop CI-fragile pixel test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-browser pixel-render fixture test was green locally but flaky in CI: the real pdf.js worker could not fetch /pdfjs-wasm/ in the CI Chromium container, so the CCITT canvas stayed blank (0 sampled pixels) and failed the suite — green locally, red in CI, root cause not locally reproducible. A flaky gate is worse than none. This bug is a build/serve parity failure, so guard it deterministically where it actually breaks: a postbuild assertion that jbig2.wasm and openjpeg.wasm shipped into build/client/pdfjs-wasm/ (non-empty). It runs after `npm run build` — including the Docker build stage — and fails the build loudly if a future pdfjs bump makes the static-copy glob match nothing. Combined with the getDocument(wasmUrl) unit guard and the negative-path render test, the regression is covered without CI flake. Addresses re-review: Tobias (no automated parity check), Sara (pixel test not pinned). Render-decode correctness verified manually via `node build` serving /pdfjs-wasm/jbig2.wasm as application/wasm. Refs #708 Co-Authored-By: Claude Opus 4.8 --- frontend/package.json | 1 + frontend/scripts/assert-pdfjs-wasm.mjs | 29 ++++++++++ .../viewer/PdfViewerFixtures.svelte.test.ts | 52 ------------------ .../lib/document/viewer/fixtures/ccitt-g4.pdf | Bin 2064 -> 0 bytes .../lib/document/viewer/fixtures/jpeg-dct.pdf | Bin 5065 -> 0 bytes 5 files changed, 30 insertions(+), 52 deletions(-) create mode 100644 frontend/scripts/assert-pdfjs-wasm.mjs delete mode 100644 frontend/src/lib/document/viewer/PdfViewerFixtures.svelte.test.ts delete mode 100644 frontend/src/lib/document/viewer/fixtures/ccitt-g4.pdf delete mode 100644 frontend/src/lib/document/viewer/fixtures/jpeg-dct.pdf diff --git a/frontend/package.json b/frontend/package.json index fa8979ad..0f135964 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite dev", "build": "vite build", + "postbuild": "node scripts/assert-pdfjs-wasm.mjs", "preview": "vite preview", "prepare": "svelte-kit sync || true && git -C .. config core.hooksPath .husky 2>/dev/null || true", "postinstall": "patch-package", diff --git a/frontend/scripts/assert-pdfjs-wasm.mjs b/frontend/scripts/assert-pdfjs-wasm.mjs new file mode 100644 index 00000000..6606fa74 --- /dev/null +++ b/frontend/scripts/assert-pdfjs-wasm.mjs @@ -0,0 +1,29 @@ +// Build-time guard for issue #708. The pdf.js wasm image decoders are copied +// into build/client/pdfjs-wasm/ by vite-plugin-static-copy. If a future +// pdfjs-dist bump moves or renames the wasm, the glob could silently copy +// nothing — and CCITT/JBIG2/JPEG2000 scans would render blank in production +// again with no test catching it (the bug is invisible to unit tests). Fail +// the build loudly instead. Runs after `npm run build` (incl. the Docker +// build stage) via the `postbuild` npm script. +import { existsSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +const dir = join(process.cwd(), 'build', 'client', 'pdfjs-wasm'); +// jbig2.wasm decodes JBIG2 + CCITTFax; openjpeg.wasm decodes JPEG2000. +const required = ['jbig2.wasm', 'openjpeg.wasm']; + +const missing = required.filter((f) => { + const p = join(dir, f); + return !existsSync(p) || statSync(p).size === 0; +}); + +if (missing.length > 0) { + console.error( + `\n[assert-pdfjs-wasm] MISSING from build output: ${missing.join(', ')}\n` + + `Expected non-empty files in ${dir}.\n` + + `The pdf.js wasm decoders did not ship — scanned PDFs would render blank.\n` + + `Check the vite-plugin-static-copy target in vite.config.ts and that\n` + + `node_modules/pdfjs-dist/wasm/ still contains these files. See issue #708.\n` + ); + process.exit(1); +} diff --git a/frontend/src/lib/document/viewer/PdfViewerFixtures.svelte.test.ts b/frontend/src/lib/document/viewer/PdfViewerFixtures.svelte.test.ts deleted file mode 100644 index 2ec78f32..00000000 --- a/frontend/src/lib/document/viewer/PdfViewerFixtures.svelte.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, it, expect, afterEach, vi } from 'vitest'; -import { cleanup, render } from 'vitest-browser-svelte'; -import PdfViewer from './PdfViewer.svelte'; -import ccittUrl from './fixtures/ccitt-g4.pdf?url'; -import jpegUrl from './fixtures/jpeg-dct.pdf?url'; - -// Behavioral, real-render coverage of the wasm decode path. Unlike the rest of -// the viewer tests, these use the REAL pdf.js loader (no libLoader prop) so the -// page is actually decoded and painted, and the wasm is fetched from -// /pdfjs-wasm/ exactly as in production. CI runs this in a real Chromium. -// See issue #708. - -afterEach(cleanup); - -// A blank page is a uniform white canvas. A rendered page has dark glyph pixels. -function countNonBackgroundPixels(canvas: HTMLCanvasElement): number { - const ctx = canvas.getContext('2d'); - if (!ctx || canvas.width === 0 || canvas.height === 0) return 0; - const { data } = ctx.getImageData(0, 0, canvas.width, canvas.height); - let count = 0; - for (let i = 0; i < data.length; i += 4) { - const r = data[i]; - const g = data[i + 1]; - const b = data[i + 2]; - const a = data[i + 3]; - if (a > 0 && (r < 250 || g < 250 || b < 250)) count++; - } - return count; -} - -async function expectNonBlankRender(url: string): Promise { - render(PdfViewer, { url, documentId: 'fixture' }); - await vi.waitFor( - () => { - const canvas = document.querySelector('canvas'); - expect(canvas).not.toBeNull(); - expect((canvas as HTMLCanvasElement).width).toBeGreaterThan(0); - expect(countNonBackgroundPixels(canvas as HTMLCanvasElement)).toBeGreaterThan(50); - }, - { timeout: 20000, interval: 250 } - ); -} - -describe('PdfViewer — real codec fixtures (wasm decode path)', () => { - it('renders a CCITT (G4 fax) scan as a non-blank page — same jbig2.wasm path JBIG2 uses', async () => { - await expectNonBlankRender(ccittUrl); - }); - - it('renders a DCTDecode (JPEG) PDF as a non-blank page — no regression', async () => { - await expectNonBlankRender(jpegUrl); - }); -}); diff --git a/frontend/src/lib/document/viewer/fixtures/ccitt-g4.pdf b/frontend/src/lib/document/viewer/fixtures/ccitt-g4.pdf deleted file mode 100644 index 585893e5b34a0d9a8931acbb283d84bf930f7ef6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2064 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