diff --git a/make-pdf/test/coverage-gaps.test.ts b/make-pdf/test/coverage-gaps.test.ts new file mode 100644 index 000000000..aa6997a03 --- /dev/null +++ b/make-pdf/test/coverage-gaps.test.ts @@ -0,0 +1,220 @@ +/** + * Coverage-gap fills from the v1.58.0.0 ship audit — the branches the main + * suites couldn't reach without a live browse tab (mock-tab here), plus the + * pure-function stragglers (WebP probing, landscape geometry, bundle path + * resolution, screen CSS). + */ +import { describe, expect, test } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { + RenderCallError, + type RenderTab, + landscapeContentBox, + rasterizeDiagramFigures, + renderFenceSlots, + resolveBundlePath, + substituteSlots, +} from "../src/diagram-prepass"; +import { imageDims } from "../src/image-size"; +import { screenCss } from "../src/print-css"; + +/** Duck-typed RenderTab: scripted call results + a loadBundle counter. */ +function mockTab(script: (fn: string, ...args: Array) => string) { + const calls: string[] = []; + let reloads = 0; + const tab = { + call: (fn: string, ...args: Array) => { + calls.push(fn); + return script(fn, ...args); + }, + loadBundle: () => { reloads++; }, + close: () => {}, + } as unknown as RenderTab; + return { tab, calls, reloadCount: () => reloads }; +} + +const fence = (over: Partial<{ lang: string; source: string; ordinal: number }>) => ({ + lang: "mermaid", + source: "graph LR\n A --> B", + render: true as const, + token: `tok-${over.ordinal ?? 1}`, + ordinal: over.ordinal ?? 1, + title: undefined, + page: undefined, + ...over, +}); + +// ─── renderFenceSlots: reset contract + excalidraw branches ─────────── + +describe("renderFenceSlots (mock tab)", () => { + test("reset contract: a failure reloads the bundle and the NEXT fence still renders", () => { + const { tab, reloadCount } = mockTab((fn, ...args) => { + if (String(args[1] ?? "").includes("BROKEN")) throw new RenderCallError("Parse error on line 1"); + return ""; + }); + const warnings: string[] = []; + const slots = renderFenceSlots( + [ + fence({ ordinal: 1 }), + fence({ ordinal: 2, source: "BROKEN" }), + fence({ ordinal: 3 }), + ], + tab, + (m) => warnings.push(m), + ); + expect(slots.get("tok-1")).toContain(""); + expect(slots.get("tok-2")).toContain("diagram-error"); + expect(slots.get("tok-3")).toContain(""); // post-failure fence rendered + expect(reloadCount()).toBe(1); // exactly one reset reload + expect(warnings[0]).toContain("failed to render"); + }); + + test("excalidraw fence renders via __excalidrawToSvg", () => { + const { tab, calls } = mockTab(() => ""); + const slots = renderFenceSlots( + [fence({ lang: "excalidraw", source: '{"type":"excalidraw","elements":[]}' })], + tab, + () => {}, + ); + expect(calls).toEqual(["__excalidrawToSvg"]); + expect(slots.get("tok-1")).toContain(" { + const { tab, calls, reloadCount } = mockTab(() => ""); + const warnings: string[] = []; + const slots = renderFenceSlots( + [fence({ lang: "excalidraw", source: "{not json" })], + tab, + (m) => warnings.push(m), + ); + expect(calls).toEqual([]); // JSON.parse threw before any bundle call + expect(slots.get("tok-1")).toContain("diagram-error"); + expect(reloadCount()).toBe(1); + expect(warnings).toHaveLength(1); + }); +}); + +// ─── rasterizeDiagramFigures: svg-data-URI + error fallbacks ────────── + +describe("rasterizeDiagramFigures (mock tab)", () => { + const figure = ``; + + test("svg data-URI images rasterize to PNG", () => { + const svgUri = `data:image/svg+xml;base64,${Buffer.from("").toString("base64")}`; + const { tab } = mockTab(() => "data:image/png;base64,AAAA"); + const out = rasterizeDiagramFigures(`v`, tab, 6.5, () => {}); + expect(out).toContain('src="data:image/png;base64,AAAA"'); + }); + + test("figure rasterization failure keeps the figure (never silent loss)", () => { + const { tab } = mockTab(() => { throw new RenderCallError("tainted"); }); + const warnings: string[] = []; + const out = rasterizeDiagramFigures(figure, tab, 6.5, (m) => warnings.push(m)); + expect(out).toContain('
{ + const svgUri = `data:image/svg+xml;base64,${Buffer.from("").toString("base64")}`; + const { tab } = mockTab(() => { throw new RenderCallError("decode failed"); }); + const tagIn = ``; + const out = rasterizeDiagramFigures(tagIn, tab, 6.5, () => {}); + expect(out).toBe(tagIn); + }); +}); + +// ─── image-size: WebP variants ──────────────────────────────────────── + +describe("imageDims WebP", () => { + function riff(fmt: string, body: Buffer): Buffer { + const b = Buffer.alloc(12 + 4 + body.length); + b.write("RIFF", 0, "ascii"); + b.writeUInt32LE(4 + body.length + 4, 4); + b.write("WEBP", 8, "ascii"); + b.write(fmt, 12, "ascii"); + body.copy(b, 16); + return b; + } + + test("VP8 (lossy)", () => { + const body = Buffer.alloc(16); + body.writeUInt16LE(800 & 0x3fff, 10); // width at chunk offset 26 = body offset 10 + body.writeUInt16LE(600 & 0x3fff, 12); + expect(imageDims(riff("VP8 ", body))).toEqual({ width: 800, height: 600, mime: "image/webp" }); + }); + + test("VP8L (lossless)", () => { + const body = Buffer.alloc(10); + body[4] = 0x2f; // signature at chunk offset 20 = body offset 4 + const w = 1023, h = 511; + const bits = (w - 1) | ((h - 1) << 14); + body.writeUInt32LE(bits >>> 0, 5); + expect(imageDims(riff("VP8L", body))).toEqual({ width: 1023, height: 511, mime: "image/webp" }); + }); + + test("VP8X (extended)", () => { + const body = Buffer.alloc(14); + const w = 4000 - 1, h = 250 - 1; // 24-bit minus-one at offsets 24/27 = body 8/11 + body[8] = w & 0xff; body[9] = (w >> 8) & 0xff; body[10] = (w >> 16) & 0xff; + body[11] = h & 0xff; body[12] = (h >> 8) & 0xff; body[13] = (h >> 16) & 0xff; + expect(imageDims(riff("VP8X", body))).toEqual({ width: 4000, height: 250, mime: "image/webp" }); + }); + + test("unknown RIFF subtype → null", () => { + expect(imageDims(riff("XXXX", Buffer.alloc(14)))).toBeNull(); + }); +}); + +// ─── landscape geometry + slot fallback + bundle path + screen css ──── + +describe("pure-function stragglers", () => { + test("landscapeContentBox letter defaults: 9in × 6.5in", () => { + expect(landscapeContentBox({})).toEqual({ contentWIn: 9, contentHIn: 6.5 }); + }); + test("landscapeContentBox a4 + asymmetric margins", () => { + const box = landscapeContentBox({ pageSize: "a4", marginLeft: "0.5in", marginRight: "0.5in", marginTop: "25mm", marginBottom: "1in" }); + expect(box.contentWIn).toBeCloseTo(11.69 - 1, 2); + expect(box.contentHIn).toBeCloseTo(8.27 - 25 / 25.4 - 1, 2); + }); + + test("substituteSlots bare-token fallback (token not

-wrapped)", () => { + const slots = new Map([["gstack-diagram-slot-x-1", "

D
"]]); + const out = substituteSlots("
  • gstack-diagram-slot-x-1
  • ", slots); + expect(out).toBe("
  • D
  • "); + }); + + test("resolveBundlePath honors the env override", () => { + const tmp = path.join(os.tmpdir(), `bundle-override-${process.pid}.html`); + fs.writeFileSync(tmp, ""); + try { + expect(resolveBundlePath({ GSTACK_DIAGRAM_BUNDLE: tmp } as NodeJS.ProcessEnv)).toBe(tmp); + } finally { + fs.unlinkSync(tmp); + } + }); + test("resolveBundlePath error names every candidate and the fix", () => { + let message = ""; + try { + resolveBundlePath({ GSTACK_DIAGRAM_BUNDLE: "/definitely/not/here.html", HOME: "/nonexistent-home" } as NodeJS.ProcessEnv); + } catch (err: any) { + message = err.message; + } + // The repo-relative candidates exist in this checkout, so force a miss is + // not possible portably — accept either a path return or the error shape. + if (message) { + expect(message).toContain("diagram-render bundle not found"); + expect(message).toContain("build:diagram-render"); + } + }); + + test("screenCss is media-scoped and readable-width", () => { + const css = screenCss(); + expect(css).toContain("@media screen"); + expect(css).toContain("max-width: 52em"); + expect(css).toContain(".watermark { display: none; }"); + }); +}); diff --git a/make-pdf/test/e2e/diagram-gate.test.ts b/make-pdf/test/e2e/diagram-gate.test.ts index e0c5b5689..459172b0e 100644 --- a/make-pdf/test/e2e/diagram-gate.test.ts +++ b/make-pdf/test/e2e/diagram-gate.test.ts @@ -78,25 +78,43 @@ describe("diagram render gate", () => { const outputPdf = path.join(workDir, "out.pdf"); const ppmPrefix = path.join(workDir, "page"); try { - execFileSync(PDF_BIN, ["generate", FIXTURE, outputPdf, "--quiet"], { - encoding: "utf8", + // No --quiet: stderr carries the downscale warning asserted below. + const run = Bun.spawnSync([PDF_BIN, "generate", FIXTURE, outputPdf], { env: { ...process.env, BROWSE_BIN }, - stdio: ["ignore", "pipe", "pipe"], - timeout: CHILD_TIMEOUT_MS, + stdout: "pipe", + stderr: "pipe", }); + const stderr = new TextDecoder().decode(run.stderr); + if (run.exitCode !== 0) { + throw new Error(`generate failed (exit ${run.exitCode}):\n${stderr}`); + } expect(fs.existsSync(outputPdf)).toBe(true); + // 0. Print-resolution downscale fired on the 4200px noise photo — this + // is the only live coverage of __downscaleRaster AND the chunked + // jsViaBuffer transport (the data URI exceeds the 100KB argv path). + expect(stderr).toMatch(/downscaled huge-noise\.png 4200px → \d+px/); + const pdftotext = resolvePopplerTool("pdftotext")!; const text = execFileSync(pdftotext, [outputPdf, "-"], { encoding: "utf8", timeout: CHILD_TIMEOUT_MS }); - // 1. Vector text from BOTH diagrams (multi-fence + id-collision check — - // a collided render drops the second diagram's content). + // 1. Vector text from BOTH diagrams (multi-fence + id-collision check). + // The broken fence sits BETWEEN them in the fixture, so the second + // diagram rendering at all proves the reset contract (D6.2): the + // bundle page reloaded after the failure and kept working. for (const label of ["gatealphanode", "gatebetanode", "gategammanode", "gatedeltanode", "gateepsilonnode"]) { expect(text).toContain(label); } - // 2. Rendered fences must NOT ship raw mermaid; render=false must. + // 1b. The excalidraw fence rendered through exportToSvg (vector text + // from the scene file, plus its caption). + expect(text).toContain("excalialphanode"); + expect(text).toContain("excalibetanode"); + expect(text).toContain("Converted flowchart"); + + // 2. Rendered fences must NOT ship raw mermaid/scene JSON; render=false must. expect(text).not.toContain("GATEALPHA["); + expect(text).not.toContain('"type":"excalidraw"'); expect(text).toContain("RAWKEPT"); expect(text).toContain("ASCODE"); diff --git a/make-pdf/test/fixtures/diagram-assets/huge-noise.png b/make-pdf/test/fixtures/diagram-assets/huge-noise.png new file mode 100644 index 000000000..73ebd53ac Binary files /dev/null and b/make-pdf/test/fixtures/diagram-assets/huge-noise.png differ diff --git a/make-pdf/test/fixtures/diagram-gate.md b/make-pdf/test/fixtures/diagram-gate.md index fa2767461..c787b47a7 100644 --- a/make-pdf/test/fixtures/diagram-gate.md +++ b/make-pdf/test/fixtures/diagram-gate.md @@ -12,6 +12,14 @@ graph LR GATEBETA -->|yes| GATEGAMMA[gategammanode] ``` +## Deliberately broken + +```mermaid +graph LR + A --> + ((( +``` + ## Second diagram (id-collision check) ```mermaid @@ -26,12 +34,15 @@ graph LR RAWKEPT --> ASCODE ``` -## Deliberately broken -```mermaid -graph LR - A --> - ((( +## Excalidraw scene + +```excalidraw title="Converted flowchart" +{"type":"excalidraw","version":2,"source":"gstack-diagram-render","elements":[{"id":"VL7JRGkMTpqCVBye2mq3X","type":"rectangle","x":0,"y":0,"width":197.046875,"height":44,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a0","roundness":null,"seed":172328728,"version":3,"versionNonce":1118377320,"isDeleted":false,"boundElements":[{"type":"text","id":"mQsqVweT6BUmQpwbW6sOU"},{"id":"aVaLIsulCLlHiV1XqWi1-","type":"arrow"}],"updated":1781273248718,"link":null,"locked":false},{"id":"YX9Ff_UgFhhRa7lGo6xS9","type":"rectangle","x":247.046875,"y":0,"width":186.4375,"height":44,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a1","roundness":null,"seed":1275860584,"version":3,"versionNonce":45230184,"isDeleted":false,"boundElements":[{"type":"text","id":"9oes2DZoL-mRrT3RGakLq"},{"id":"aVaLIsulCLlHiV1XqWi1-","type":"arrow"}],"updated":1781273248718,"link":null,"locked":false},{"id":"aVaLIsulCLlHiV1XqWi1-","type":"arrow","x":197.047,"y":22,"width":44.70000000000002,"height":0,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a2","roundness":{"type":2},"seed":1530192920,"version":4,"versionNonce":1747670296,"isDeleted":false,"boundElements":null,"updated":1781273248718,"link":null,"locked":false,"points":[[0.5,0],[44.20000000000002,0]],"lastCommittedPoint":null,"startBinding":{"elementId":"VL7JRGkMTpqCVBye2mq3X","focus":0,"gap":1},"endBinding":{"elementId":"YX9Ff_UgFhhRa7lGo6xS9","focus":0,"gap":5.299874999999986},"startArrowhead":null,"endArrowhead":"arrow","elbowed":false},{"id":"mQsqVweT6BUmQpwbW6sOU","type":"text","x":33.5576171875,"y":9.5,"width":129.931640625,"height":25,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a3","roundness":null,"seed":1219280408,"version":3,"versionNonce":1462825496,"isDeleted":false,"boundElements":null,"updated":1781273248718,"link":null,"locked":false,"text":"excalialphanode","fontSize":20,"fontFamily":5,"textAlign":"center","verticalAlign":"middle","containerId":"VL7JRGkMTpqCVBye2mq3X","originalText":"excalialphanode","autoResize":true,"lineHeight":1.25},{"id":"9oes2DZoL-mRrT3RGakLq","type":"text","x":280.2998046875,"y":9.5,"width":119.931640625,"height":25,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"index":"a4","roundness":null,"seed":1436367640,"version":3,"versionNonce":639687528,"isDeleted":false,"boundElements":null,"updated":1781273248718,"link":null,"locked":false,"text":"excalibetanode","fontSize":20,"fontFamily":5,"textAlign":"center","verticalAlign":"middle","containerId":"YX9Ff_UgFhhRa7lGo6xS9","originalText":"excalibetanode","autoResize":true,"lineHeight":1.25}],"appState":{"viewBackgroundColor":"#ffffff"},"files":{}} ``` +## Huge photo (downscale trigger, no diagram hint) + +![a big noisy photo](./diagram-assets/huge-noise.png) + Done.