mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-25 06:11:02 +02:00
refactor(make-pdf): print through Aside first, the bundled browser otherwise
asideClient.ts replaces the direct $B client with one render() call per PDF (the exact option mapping the browse pdf command had: paper, margins, header/footer/page numbers, tagged, outline, printBackground, preferCSSPageSize, Paged.js wait); the diagram pre-pass, oversized-image downscale and DOCX rasters each run as one render script with per-fence try/catch; exit 4 now means no browser is available and names both remedies; $P setup reports which engine it found. The e2e gates run on whichever engine is present, so the Linux lane exercises the fallback. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* Unit tests for the diagram pre-pass: fence extraction, info-string parsing,
|
||||
* slot substitution, diagnostic blocks, image inlining policy, and the
|
||||
* byte-level image dimension prober. No browse daemon required — the tab
|
||||
* factory returns null so downscale paths are exercised as no-ops.
|
||||
* byte-level image dimension prober, and the bundle runner's script shape
|
||||
* (render function injected). No live Aside required — `run: null` makes
|
||||
* downscale paths no-ops.
|
||||
*/
|
||||
import { afterAll, describe, expect, test } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
@@ -13,6 +14,7 @@ import zlib from "node:zlib";
|
||||
import {
|
||||
StrictModeError,
|
||||
buildDiagnosticBlock,
|
||||
bundleRunner,
|
||||
buildDiagramFigure,
|
||||
contentWidthInches,
|
||||
dimToInches,
|
||||
@@ -23,6 +25,7 @@ import {
|
||||
decodeFigureSource,
|
||||
} from "../src/diagram-prepass";
|
||||
import { imageDims } from "../src/image-size";
|
||||
import type { RenderResult, RenderSpec } from "../../lib/aside-render";
|
||||
|
||||
// ─── fence extraction ─────────────────────────────────────────────────
|
||||
|
||||
@@ -251,51 +254,51 @@ describe("inlineLocalImages", () => {
|
||||
strict: false,
|
||||
allowNetwork: false,
|
||||
contentWidthIn: 6.5,
|
||||
getTab: () => null,
|
||||
run: null,
|
||||
};
|
||||
|
||||
test("local image becomes a data URI with probed dimensions", () => {
|
||||
test("local image becomes a data URI with probed dimensions", async () => {
|
||||
const warnings: string[] = [];
|
||||
const out = inlineLocalImages(`<img src="ok.png" alt="x">`, { ...base, warn: (m) => warnings.push(m) });
|
||||
const out = await inlineLocalImages(`<img src="ok.png" alt="x">`, { ...base, warn: (m) => warnings.push(m) });
|
||||
expect(out).toContain("data:image/png;base64,");
|
||||
expect(out).toContain('data-gstack-px-width="40"');
|
||||
expect(out).toContain('data-gstack-px-height="20"');
|
||||
expect(warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("missing image → visible placeholder + warning", () => {
|
||||
test("missing image → visible placeholder + warning", async () => {
|
||||
const warnings: string[] = [];
|
||||
const out = inlineLocalImages(`<img src="nope.png">`, { ...base, warn: (m) => warnings.push(m) });
|
||||
const out = await inlineLocalImages(`<img src="nope.png">`, { ...base, warn: (m) => warnings.push(m) });
|
||||
expect(out).toContain("image-missing");
|
||||
expect(out).toContain("nope.png");
|
||||
expect(warnings.length).toBe(1);
|
||||
});
|
||||
|
||||
test("missing image + --strict → StrictModeError", () => {
|
||||
expect(() =>
|
||||
test("missing image + --strict → StrictModeError", async () => {
|
||||
await expect(
|
||||
inlineLocalImages(`<img src="nope.png">`, { ...base, strict: true, warn: () => {} }),
|
||||
).toThrow(StrictModeError);
|
||||
).rejects.toThrow(StrictModeError);
|
||||
});
|
||||
|
||||
test("remote image is BLOCKED with a visible placeholder (offline posture)", () => {
|
||||
test("remote image is BLOCKED with a visible placeholder (offline posture)", async () => {
|
||||
// Leaving the tag would make Chromium fetch it at print time anyway —
|
||||
// the offline posture must remove the src, not just warn about it.
|
||||
const warnings: string[] = [];
|
||||
const tag = `<img src="https://example.com/x.png">`;
|
||||
const out = inlineLocalImages(tag, { ...base, warn: (m) => warnings.push(m) });
|
||||
const out = await inlineLocalImages(tag, { ...base, warn: (m) => warnings.push(m) });
|
||||
expect(out).not.toContain("https://example.com/x.png\"");
|
||||
expect(out).toContain("remote image blocked");
|
||||
expect(warnings[0]).toContain("offline");
|
||||
});
|
||||
|
||||
test("symlink escaping the input dir is caught by the realpath check", () => {
|
||||
test("symlink escaping the input dir is caught by the realpath check", async () => {
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "prepass-symlink-"));
|
||||
fs.writeFileSync(path.join(outside, "secret.png"), tinyPng(5, 5));
|
||||
const link = path.join(dir, "innocent.png");
|
||||
try {
|
||||
fs.symlinkSync(path.join(outside, "secret.png"), link);
|
||||
const warnings: string[] = [];
|
||||
inlineLocalImages(`<img src="innocent.png">`, { ...base, warn: (m) => warnings.push(m) });
|
||||
await inlineLocalImages(`<img src="innocent.png">`, { ...base, warn: (m) => warnings.push(m) });
|
||||
expect(warnings.some((w) => w.includes("OUTSIDE the input directory"))).toBe(true);
|
||||
} finally {
|
||||
try { fs.unlinkSync(link); } catch { /* ignore */ }
|
||||
@@ -303,48 +306,48 @@ describe("inlineLocalImages", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("special files and oversized images degrade to placeholders, never hang", () => {
|
||||
test("special files and oversized images degrade to placeholders, never hang", async () => {
|
||||
// Directory masquerading as an image — not a regular file.
|
||||
fs.mkdirSync(path.join(dir, "dir.png"), { recursive: true });
|
||||
const warnings: string[] = [];
|
||||
const out = inlineLocalImages(`<img src="dir.png">`, { ...base, warn: (m) => warnings.push(m) });
|
||||
const out = await inlineLocalImages(`<img src="dir.png">`, { ...base, warn: (m) => warnings.push(m) });
|
||||
expect(out).toContain("image-missing");
|
||||
expect(warnings.some((w) => w.includes("not a regular file"))).toBe(true);
|
||||
});
|
||||
|
||||
test("malformed percent-encoding degrades to missing-image, never throws", () => {
|
||||
test("malformed percent-encoding degrades to missing-image, never throws", async () => {
|
||||
const warnings: string[] = [];
|
||||
const out = inlineLocalImages(`<img src="foo%zz.png">`, { ...base, warn: (m) => warnings.push(m) });
|
||||
const out = await inlineLocalImages(`<img src="foo%zz.png">`, { ...base, warn: (m) => warnings.push(m) });
|
||||
expect(out).toContain("image-missing");
|
||||
});
|
||||
|
||||
test("remote image + --allow-network passes silently", () => {
|
||||
test("remote image + --allow-network passes silently", async () => {
|
||||
const warnings: string[] = [];
|
||||
const tag = `<img src="https://example.com/x.png">`;
|
||||
const out = inlineLocalImages(tag, { ...base, allowNetwork: true, warn: (m) => warnings.push(m) });
|
||||
const out = await inlineLocalImages(tag, { ...base, allowNetwork: true, warn: (m) => warnings.push(m) });
|
||||
expect(out).toBe(tag);
|
||||
expect(warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("remote image + --strict → StrictModeError", () => {
|
||||
expect(() =>
|
||||
test("remote image + --strict → StrictModeError", async () => {
|
||||
await expect(
|
||||
inlineLocalImages(`<img src="https://example.com/x.png">`, { ...base, strict: true, warn: () => {} }),
|
||||
).toThrow(StrictModeError);
|
||||
).rejects.toThrow(StrictModeError);
|
||||
});
|
||||
|
||||
test("existing data URI gets dimension annotations only", () => {
|
||||
test("existing data URI gets dimension annotations only", async () => {
|
||||
const uri = `data:image/png;base64,${tinyPng(33, 44).toString("base64")}`;
|
||||
const out = inlineLocalImages(`<img src="${uri}">`, { ...base, warn: () => {} });
|
||||
const out = await inlineLocalImages(`<img src="${uri}">`, { ...base, warn: () => {} });
|
||||
expect(out).toContain('data-gstack-px-width="33"');
|
||||
expect(out).toContain('data-gstack-px-height="44"');
|
||||
});
|
||||
|
||||
test("out-of-tree image reads warn (never silent) and still inline", () => {
|
||||
test("out-of-tree image reads warn (never silent) and still inline", async () => {
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "prepass-outside-"));
|
||||
fs.writeFileSync(path.join(outside, "ext.png"), tinyPng(10, 10));
|
||||
try {
|
||||
const warnings: string[] = [];
|
||||
const out = inlineLocalImages(`<img src="${path.join(outside, "ext.png")}">`, {
|
||||
const out = await inlineLocalImages(`<img src="${path.join(outside, "ext.png")}">`, {
|
||||
...base, warn: (m) => warnings.push(m),
|
||||
});
|
||||
expect(out).toContain("data:image/png;base64,");
|
||||
@@ -354,26 +357,26 @@ describe("inlineLocalImages", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("out-of-tree image + --strict → StrictModeError", () => {
|
||||
test("out-of-tree image + --strict → StrictModeError", async () => {
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "prepass-outside-"));
|
||||
fs.writeFileSync(path.join(outside, "ext.png"), tinyPng(10, 10));
|
||||
try {
|
||||
expect(() =>
|
||||
await expect(
|
||||
inlineLocalImages(`<img src="${path.join(outside, "ext.png")}">`, {
|
||||
...base, strict: true, warn: () => {},
|
||||
}),
|
||||
).toThrow(StrictModeError);
|
||||
).rejects.toThrow(StrictModeError);
|
||||
} finally {
|
||||
fs.rmSync(outside, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Windows drive-letter src is treated as a local path, not a URL scheme", () => {
|
||||
test("Windows drive-letter src is treated as a local path, not a URL scheme", async () => {
|
||||
// C:/x.png matches the single-letter-scheme regex — it must reach the
|
||||
// local-path branch (and the missing-file placeholder), never silently
|
||||
// pass through as an unknown URL.
|
||||
const warnings: string[] = [];
|
||||
const out = inlineLocalImages(`<img src="C:/missing/x.png">`, { ...base, warn: (m) => warnings.push(m) });
|
||||
const out = await inlineLocalImages(`<img src="C:/missing/x.png">`, { ...base, warn: (m) => warnings.push(m) });
|
||||
expect(out).toContain("image-missing");
|
||||
// Two warnings: it's out-of-tree (resolved outside inputDir) AND missing.
|
||||
expect(warnings.some((w) => w.includes("image not found"))).toBe(true);
|
||||
@@ -393,11 +396,137 @@ describe("inlineLocalImages", () => {
|
||||
expect(markdown).toBe(md);
|
||||
});
|
||||
|
||||
test("oversized raster without a tab inlines at full size with no downscale", () => {
|
||||
test("oversized raster without a tab inlines at full size with no downscale", async () => {
|
||||
// 6000px-wide PNG header (body irrelevant for probing; file must exist)
|
||||
fs.writeFileSync(path.join(dir, "wide.png"), tinyPng(6000, 100));
|
||||
const warnings: string[] = [];
|
||||
const out = inlineLocalImages(`<img src="wide.png">`, { ...base, warn: (m) => warnings.push(m) });
|
||||
const out = await inlineLocalImages(`<img src="wide.png">`, { ...base, warn: (m) => warnings.push(m) });
|
||||
expect(out).toContain('data-gstack-px-width="6000"');
|
||||
});
|
||||
|
||||
test("oversized raster WITH a runner: one __downscaleRaster batch, token swapped for the scaled bytes", async () => {
|
||||
fs.writeFileSync(path.join(dir, "wide2.png"), tinyPng(6000, 100));
|
||||
const calls: Array<{ fn: string; args: unknown[] }> = [];
|
||||
const run = async (batch: Array<{ fn: string; args: unknown[] }>) => {
|
||||
calls.push(...batch);
|
||||
return batch.map(() => ({ ok: true as const, value: "data:image/png;base64,U0NBTEVE" }));
|
||||
};
|
||||
const warnings: string[] = [];
|
||||
// Same image twice: read/downscaled once, both tags rewritten.
|
||||
const out = await inlineLocalImages(`<img src="wide2.png"> <img src="wide2.png" alt="b">`, { ...base, run, warn: (m) => warnings.push(m) });
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].fn).toBe("__downscaleRaster");
|
||||
expect(String(calls[0].args[0])).toStartWith("data:image/png;base64,");
|
||||
expect(calls[0].args[1]).toBe(1950); // 6.5in × 300dpi
|
||||
expect(out.match(/data:image\/png;base64,U0NBTEVE/g)).toHaveLength(2);
|
||||
expect(out).toContain('data-gstack-px-width="1950"');
|
||||
expect(out).not.toContain("gstack-downscale-slot");
|
||||
expect(warnings.some((w) => w.includes("downscaled wide2.png 6000px"))).toBe(true);
|
||||
});
|
||||
|
||||
test("a failed downscale falls back to the full-size bytes with a warning", async () => {
|
||||
fs.writeFileSync(path.join(dir, "wide3.png"), tinyPng(6000, 100));
|
||||
const run = async (batch: unknown[]) => batch.map(() => ({ ok: false as const, error: "image decode failed" }));
|
||||
const warnings: string[] = [];
|
||||
const out = await inlineLocalImages(`<img src="wide3.png">`, { ...base, run, warn: (m) => warnings.push(m) });
|
||||
expect(out).toContain('data-gstack-px-width="6000"');
|
||||
expect(out).toContain("data:image/png;base64,");
|
||||
expect(out).not.toContain("gstack-downscale-slot");
|
||||
expect(warnings.some((w) => w.includes("downscale failed"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── bundle runner (script shape, injected render) ────────────────────
|
||||
|
||||
describe("bundleRunner", () => {
|
||||
const bundle = path.join(os.tmpdir(), `fake-bundle-${process.pid}.html`);
|
||||
fs.writeFileSync(bundle, "<!doctype html><div id=done>ready</div>");
|
||||
afterAll(() => { try { fs.unlinkSync(bundle); } catch { /* best-effort */ } });
|
||||
|
||||
/** Fake Aside: asserts the spec shape and writes OK:/ERR: result files. */
|
||||
function fakeRender(script: (fn: string, args: unknown[]) => string) {
|
||||
const specs: RenderSpec[] = [];
|
||||
const render = async (spec: RenderSpec): Promise<RenderResult> => {
|
||||
specs.push(spec);
|
||||
for (const step of spec.steps) {
|
||||
if (step.kind !== "eval" || !step.out) throw new Error("expected eval steps with out files");
|
||||
const i = Number(step.expression.match(/call-(\d+)\.json/)![1]);
|
||||
const fn = step.expression.match(/window\["(__\w+)"\]/)![1];
|
||||
const args = JSON.parse(fs.readFileSync(path.join(spec.serveRoot!, `call-${i}.json`), "utf8"));
|
||||
let text: string;
|
||||
try { text = "OK:" + script(fn, args); } catch (e: any) { text = "ERR:" + e.message; }
|
||||
fs.writeFileSync(step.out, text);
|
||||
}
|
||||
return { ok: true, outputs: [], evals: {}, stdout: "" };
|
||||
};
|
||||
return { render, specs };
|
||||
}
|
||||
|
||||
test("stages the bundle + one JSON args file per call in a served dir, waits for #done, reads results back", async () => {
|
||||
const { render, specs } = fakeRender((fn, args) => `${fn}(${args.join(",")})`);
|
||||
const run = bundleRunner({ bundlePath: bundle, render });
|
||||
const results = await run([
|
||||
{ fn: "__renderMermaid", args: ["mermaid-fence-1", "graph LR"] },
|
||||
{ fn: "__excalidrawToSvg", args: ["{}"] },
|
||||
]);
|
||||
expect(results).toEqual([
|
||||
{ ok: true, value: "__renderMermaid(mermaid-fence-1,graph LR)" },
|
||||
{ ok: true, value: "__excalidrawToSvg({})" },
|
||||
]);
|
||||
expect(specs).toHaveLength(1);
|
||||
const spec = specs[0];
|
||||
expect(spec.waitFor).toEqual({ selector: "#done", timeoutMs: 20_000 });
|
||||
expect(path.dirname(spec.file)).toBe(spec.serveRoot);
|
||||
expect(spec.steps).toHaveLength(2);
|
||||
// Payload rides the served dir, not argv: the expression stays tiny.
|
||||
for (const step of spec.steps) expect(step.kind === "eval" && step.expression.length < 300).toBe(true);
|
||||
// Private per-script dir is cleaned up.
|
||||
expect(fs.existsSync(spec.serveRoot!)).toBe(false);
|
||||
});
|
||||
|
||||
test("a throwing call is an ERR result; the other calls in the script still succeed", async () => {
|
||||
const { render } = fakeRender((_fn, args) => {
|
||||
if (String(args[1]).includes("BROKEN")) throw new Error("Parse error on line 1");
|
||||
return "<svg/>";
|
||||
});
|
||||
const run = bundleRunner({ bundlePath: bundle, render });
|
||||
const results = await run([
|
||||
{ fn: "__renderMermaid", args: ["a", "ok"] },
|
||||
{ fn: "__renderMermaid", args: ["b", "BROKEN"] },
|
||||
{ fn: "__renderMermaid", args: ["c", "ok"] },
|
||||
]);
|
||||
expect(results.map((r) => r.ok)).toEqual([true, false, true]);
|
||||
expect(results[1]).toEqual({ ok: false, error: "Parse error on line 1" });
|
||||
});
|
||||
|
||||
test("chunks at 40 calls per script (Aside's 120s script cap)", async () => {
|
||||
const { render, specs } = fakeRender(() => "x");
|
||||
const run = bundleRunner({ bundlePath: bundle, render });
|
||||
const results = await run(Array.from({ length: 85 }, (_, i) => ({ fn: "__renderMermaid", args: [`m${i}`, "g"] })));
|
||||
expect(results).toHaveLength(85);
|
||||
expect(specs.map((s) => s.steps.length)).toEqual([40, 40, 5]);
|
||||
});
|
||||
|
||||
test("a whole-script failure fails every call in it with the renderer's message", async () => {
|
||||
const render = async (): Promise<RenderResult> => ({ ok: false, outputs: [], evals: {}, stdout: "", error: "aside repl did not run: spawn aside ENOENT" });
|
||||
const run = bundleRunner({ bundlePath: bundle, render });
|
||||
const results = await run([{ fn: "__renderMermaid", args: ["a", "g"] }, { fn: "__renderMermaid", args: ["b", "g"] }]);
|
||||
expect(results).toHaveLength(2);
|
||||
for (const r of results) {
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toContain("diagram renderer: aside repl did not run");
|
||||
}
|
||||
});
|
||||
|
||||
test("an unreadable bundle fails every call without touching Aside; zero calls run nothing", async () => {
|
||||
let rendered = 0;
|
||||
const render = async (): Promise<RenderResult> => { rendered++; return { ok: true, outputs: [], evals: {}, stdout: "" }; };
|
||||
const run = bundleRunner({ bundlePath: "/nonexistent/diagram-render.html", render });
|
||||
expect(await run([])).toEqual([]);
|
||||
const results = await run([{ fn: "__renderMermaid", args: ["a", "g"] }, { fn: "__renderMermaid", args: ["b", "g"] }]);
|
||||
expect(results.map((r) => r.ok)).toEqual([false, false]);
|
||||
if (!results[0].ok) expect(results[0].error).toContain("ENOENT");
|
||||
expect(rendered).toBe(0);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user