From 275121391b3341c0994268e38d840b8355ad4b5a Mon Sep 17 00:00:00 2001 From: haritowa <6107211+haritowa@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:48:16 +0000 Subject: [PATCH] test(documents): keep pdf-parse out of the server's runtime A structural guard fails when any runtime file under src/ imports pdf-parse again, which would bring its second pdfjs back into the server process. The next.config comment on the tracing includes now describes the text path as it is, and the extractor no longer turns an empty end-of-line run into a blank line of its own. --- next.config.ts | 13 ++-- src/__tests__/single-pdfjs-guard.test.ts | 78 ++++++++++++++++++++++++ src/lib/documents/local-extract.ts | 5 +- 3 files changed, 90 insertions(+), 6 deletions(-) create mode 100644 src/__tests__/single-pdfjs-guard.test.ts diff --git a/next.config.ts b/next.config.ts index 055b1dcf7..66662bb82 100644 --- a/next.config.ts +++ b/next.config.ts @@ -302,11 +302,14 @@ const nextConfig: NextConfig = { outputFileTracingIncludes: { "*": [ "./src/lib/ai/prompts/safety-contracts.*.yaml", - // Document AI local text extraction runs `pdf-parse` (→ pdfjs-dist) - // server-side in the content-index job — the provider-free fallback that - // keeps content search working with no AI configured. Both are pure JS + - // wasm; there is NO native `.node` on the text-layer path (`@napi-rs/canvas` - // is only pulled for image rendering, which `getText()` never invokes). + // Document AI local text extraction reads the text layer with pdfjs-dist + // (`getTextContent`, `src/lib/documents/local-extract.ts`) server-side in + // the content-index job — the provider-free fallback that keeps content + // search working with no AI configured. It is pure JS + wasm; there is NO + // native `.node` on the text-layer path (`@napi-rs/canvas` is only pulled + // for image rendering). Nothing at runtime imports `pdf-parse` any more + // (its own older pdfjs clashed with the app's); its glob below is dead + // weight kept until the image is rebuilt and checked without it. // The standalone tracer can miss pdfjs-dist's wasm decoders (they resolve // via `new URL(..., import.meta.url)`), so pin both packages into the // runtime image explicitly. pnpm keeps pdfjs-dist unhoisted under `.pnpm`. diff --git a/src/__tests__/single-pdfjs-guard.test.ts b/src/__tests__/single-pdfjs-guard.test.ts new file mode 100644 index 000000000..897189e1b --- /dev/null +++ b/src/__tests__/single-pdfjs-guard.test.ts @@ -0,0 +1,78 @@ +/** + * Structural guard: the server runs one pdfjs. + * + * ## The failure this freezes + * + * `pdf-parse` brings its own `pdfjs-dist` beside the app's, and under Node + * every pdfjs copy keeps its worker on the one `globalThis.pdfjsWorker`. While + * local text extraction went through pdf-parse, whichever copy ran first in a + * process broke the other: "The API version "6.4.299" does not match the + * Worker version "5.4.296"". Thumbnails and vision reads failed after a text + * extraction, and the text extraction failed after a raster. + * + * ## What it proves and what it does not + * + * No runtime file under `src/` imports `pdf-parse`, so the only pdfjs the + * server loads is the app's own. Tests may still read generated PDFs back with + * it; they run in their own processes. The pdfjs-one-version tests prove the + * two orders in one process; this file stops the import from coming back. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + requireFloor, + stripComments, + walkSourceFiles, +} from "./helpers/source-files"; + +const SRC = join(process.cwd(), "src"); + +/** + * Every runtime module under `src/`: the suites, mocks and generated client + * are not loaded by the server. About 3,400 today; the floor sits below that + * with headroom, so a walk that comes back short fails instead of passing. + */ +function runtimeFiles(): string[] { + const files = walkSourceFiles(SRC, { + floor: 5000, + extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"], + }).filter( + (rel) => + !rel.startsWith("generated/") && + !rel.split("/").includes("__tests__") && + !rel.split("/").includes("__mocks__") && + !/\.test\.(ts|tsx)$/.test(rel), + ); + return [...requireFloor("runtime files under src/", files, 3000)]; +} + +/** A static import, a re-export, a dynamic import or a require of pdf-parse. */ +const PDF_PARSE_IMPORT = + /(?:\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*|\bimport\s+)["']pdf-parse(?:\/[^"']*)?["']/; + +describe("one pdfjs in the server", () => { + it("no runtime file imports pdf-parse", () => { + const importers = runtimeFiles().filter((rel) => + PDF_PARSE_IMPORT.test( + stripComments(readFileSync(join(SRC, rel), "utf8")), + ), + ); + expect(importers).toEqual([]); + }); + + it("recognises each way of importing it", () => { + for (const line of [ + 'import { PDFParse } from "pdf-parse";', + 'import type { PDFParse } from "pdf-parse";', + 'const { PDFParse } = await import("pdf-parse");', + "const m = require('pdf-parse/worker');", + 'export { PDFParse } from "pdf-parse";', + ]) { + expect(PDF_PARSE_IMPORT.test(line), line).toBe(true); + } + expect(PDF_PARSE_IMPORT.test('import x from "pdf-parser";')).toBe(false); + }); +}); diff --git a/src/lib/documents/local-extract.ts b/src/lib/documents/local-extract.ts index 8476eb803..39b33df09 100644 --- a/src/lib/documents/local-extract.ts +++ b/src/lib/documents/local-extract.ts @@ -84,7 +84,10 @@ function pageText(items: Array): string { else if (x - last.end > size * 0.1 && !/\s$/.test(text)) text += " "; } text += item.str; - if (item.hasEOL) text += "\n"; + // An empty run that only ends a line adds no blank line of its own. + if (item.hasEOL && !(item.str === "" && text.endsWith("\n"))) { + text += "\n"; + } last = { end: x + item.width, y, size }; } return text;