test: coverage for the render CLI, setup rebuild check, make-pdf exit codes, and prose $B spans

New free tests from the ship coverage audit: test/gstack-render-cli.test.ts
(argv guards, --help, output contract with a fake daemon, failure and
serve-root paths, no-browser case, prompt exit), test/setup-needs-build.test.ts
(every binary and source set flips NEEDS_BUILD, Windows suffixes),
make-pdf/test/cli-exit-codes.test.ts and setup-smoke.test.ts (error to exit
code mapping, runSetup stages, renderPdf's engine), and prose-span cases for
extractBrowseCommands in test/skill-parser.test.ts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-09-06 07:23:26 +00:00
co-authored by Claude Fable 5.1
parent 444f8feff8
commit e1147b75aa
5 changed files with 1437 additions and 0 deletions
+329
View File
@@ -0,0 +1,329 @@
/**
* cli.ts error → exit code mapping, pinned through the REAL CLI process.
*
* exit 0 success / 1 bad args / 2 render error / 3 Paged.js timeout / 4 no browser
*
* `main()` is not exported and runs only under `import.meta.main`, so the
* catch block cannot be driven in-process. Each case spawns `bun cli.ts`
* instead, two ways:
*
* 1. Stubbed orchestrator. A `--preload` file registers a Bun runtime plugin
* whose onLoad swaps make-pdf/src/orchestrator.ts for a module whose
* generate()/preview() throw a chosen error class. The stub imports
* BrowserUnavailableError from the same types.ts cli.ts uses, so the
* `instanceof` check in the catch block is exercised for real.
* 2. No stubs. Filesystem errors that need no browser (missing input, output
* dir that does not exist), bad args, and fake `aside` / `browse` shell
* scripts reached via GSTACK_BROWSE_BIN and a scrubbed PATH. The children
* never see the real PATH, so no real browser is ever probed or launched.
*
* Exit 4 through an UNSTUBBED process is not forceable from inside this
* checkout: resolveBrowseBin() always finds <repo>/browse/dist/browse (a
* BROWSE_ROOTS entry computed from import.meta.dir), so the stubbed case (1)
* is the exit-4 pin here; make-pdf/test/asideClient.test.ts covers the
* renderFailure() classification that produces the error in the first place.
*/
import { afterAll, beforeAll, 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 { ExitCode } from "../src/types";
import { NO_BROWSER, NO_BROWSER_HELP } from "../../lib/aside-render";
const ROOT = path.resolve(import.meta.dir, "..", "..");
const CLI = path.join(ROOT, "make-pdf", "src", "cli.ts");
const TYPES = path.join(ROOT, "make-pdf", "src", "types.ts");
const isWin = process.platform === "win32";
let tmp: string;
/** PATH for children: nothing on it (no `aside`, no `browse`, no `pdftotext`). */
let emptyBin: string;
/** PATH for children: only a fake `aside` that answers the probe but cannot run a script. */
let fakeAsideBin: string;
/** Fake browse daemon CLIs (reached via GSTACK_BROWSE_BIN, never PATH). */
let browseOk: string;
let browseRefusesNewtab: string;
let inputMd: string;
beforeAll(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "make-pdf-cli-exit-"));
emptyBin = path.join(tmp, "empty-bin");
fs.mkdirSync(emptyBin);
fakeAsideBin = path.join(tmp, "fake-aside-bin");
fs.mkdirSync(fakeAsideBin);
inputMd = path.join(tmp, "in.md");
fs.writeFileSync(inputMd, "# Smoke\n\nOne paragraph is enough.\n", "utf8");
if (!isWin) {
// Answers `aside --version` and the ASIDE_READY probe like a live app, then
// fails every render script the way an Aside release that renamed its
// private CDP bridge would — the exact shape render() retries on browse.
writeScript(path.join(fakeAsideBin, "aside"), [
'case "$1" in',
' --version) echo "aside 1.26.0-fake" ;;',
" repl)",
' case "$2" in',
' *ASIDE_READY*) echo "ASIDE_READY /fake/aside/session" ;;',
' *) echo "ReferenceError: openTab is not defined" ;;',
" esac ;;",
"esac",
"exit 0",
]);
browseOk = writeFakeBrowse("browse-ok", { pdf: true });
browseRefusesNewtab = writeFakeBrowse("browse-refuses-newtab", { newtabFails: true });
}
});
afterAll(() => {
fs.rmSync(tmp, { recursive: true, force: true });
});
// ─── helpers ──────────────────────────────────────────────────────────────────
function writeScript(file: string, body: string[]): string {
// Builtins only (case/echo/read/printf): the children run with PATH scrubbed.
fs.writeFileSync(file, ["#!/bin/sh", ...body, ""].join("\n"), { mode: 0o755 });
return file;
}
/**
* A stand-in for gstack's browse daemon CLI: `newtab --json` hands out a tab,
* `js` answers with `pageText`, and `pdf --from-file <payload>` writes a fake
* PDF at the payload's `output` path (JSON parsed with parameter expansion).
*/
function writeFakeBrowse(name: string, opts: { pageText?: string; pdf?: boolean; newtabFails?: boolean }): string {
const body = ['case "$1" in'];
body.push(opts.newtabFails
? ' newtab) echo "daemon refused: boom" >&2; exit 1 ;;'
: ` newtab) echo '{"tabId":7}' ;;`);
body.push(` js) echo '${opts.pageText ?? "browser-ok"}' ;;`);
if (opts.pdf) {
body.push(
" pdf)",
' read -r payload < "$3"',
' out="${payload#*\\"output\\":\\"}"',
' out="${out%%\\"*}"',
" printf '%%PDF-1.4 fake\\n' > \"$out\"",
" ;;",
);
}
body.push("esac", "exit 0");
return writeScript(path.join(tmp, name), body);
}
/** Hermetic child env: no real PATH, Aside skipped unless a test opts back in. */
function childEnv(extra: Record<string, string | undefined> = {}): Record<string, string> {
const env: Record<string, string> = {
PATH: emptyBin,
HOME: process.env.HOME ?? tmp,
TMPDIR: os.tmpdir(),
GSTACK_SKIP_ASIDE: "1",
NO_COLOR: "1",
};
for (const [k, v] of Object.entries(extra)) {
if (v === undefined) delete env[k];
else env[k] = v;
}
return env;
}
function runCli(args: string[], opts: { preload?: string; env?: Record<string, string> } = {}) {
const argv = [process.execPath, ...(opts.preload ? ["--preload", opts.preload] : []), CLI, ...args];
const r = Bun.spawnSync(argv, { cwd: ROOT, env: opts.env ?? childEnv(), stdout: "pipe", stderr: "pipe", stdin: "ignore", timeout: 120_000 });
return { code: r.exitCode, stdout: r.stdout.toString(), stderr: r.stderr.toString() };
}
const ORCHESTRATOR_FILTER = String.raw`make-pdf[/\\]src[/\\]orchestrator\.ts$`;
/**
* Write a --preload file whose Bun plugin replaces the orchestrator module with
* one whose generate()/preview() `throw <throwExpr>`. `throwExpr` may reference
* BrowserUnavailableError (imported from the real types.ts, so instanceof holds).
*/
function stubOrchestrator(name: string, throwExpr: string): string {
const module = [
`import { BrowserUnavailableError } from ${JSON.stringify(TYPES)};`,
`export const OUTPUT_TMP_DIR = ${JSON.stringify(os.tmpdir())};`,
`export async function generate() { throw ${throwExpr}; }`,
`export async function preview() { throw ${throwExpr}; }`,
"",
].join("\n");
const preload = [
"// Generated by make-pdf/test/cli-exit-codes.test.ts",
"Bun.plugin({",
' name: "make-pdf-test-stub-orchestrator",',
" setup(build) {",
` build.onLoad({ filter: new RegExp(${JSON.stringify(ORCHESTRATOR_FILTER)}) }, () => ({ loader: "ts", contents: ${JSON.stringify(module)} }));`,
" },",
"});",
"",
].join("\n");
const file = path.join(tmp, `stub-${name}.preload.ts`);
fs.writeFileSync(file, preload, "utf8");
return file;
}
// ─── 1. error class → exit code (orchestrator stubbed) ────────────────────────
describe("cli.ts maps the thrown error class to the exit code (orchestrator stubbed)", () => {
test("BrowserUnavailableError → exit 4, stderr is `$P: <message>`, stdout stays empty", () => {
const msg = `${NO_BROWSER}: ${NO_BROWSER_HELP} (NEEDS_ASIDE: stubbed)`;
const r = runCli(["generate", inputMd], {
preload: stubOrchestrator("no-browser", `new BrowserUnavailableError(${JSON.stringify(msg)})`),
});
expect(r.code).toBe(ExitCode.BrowserUnavailable);
expect(r.code).toBe(4);
expect(r.stderr.trim()).toBe(`$P: ${msg}`);
expect(r.stdout).toBe("");
});
test("a plain Error → exit 2 (render error): message only, the stack only with --verbose", () => {
const preload = stubOrchestrator("render-error", `new Error("PDF render failed: render script did not finish")`);
const quiet = runCli(["generate", inputMd], { preload });
expect(quiet.code).toBe(ExitCode.RenderError);
expect(quiet.code).toBe(2);
expect(quiet.stderr.trim()).toBe("$P: PDF render failed: render script did not finish");
expect(quiet.stdout).toBe("");
const verbose = runCli(["generate", inputMd, "--verbose"], { preload });
expect(verbose.code).toBe(ExitCode.RenderError);
const lines = verbose.stderr.trim().split("\n");
expect(lines[0]).toBe("$P: PDF render failed: render script did not finish");
expect(lines.length).toBeGreaterThan(1); // err.stack follows
expect(verbose.stderr).toMatch(/\bat\b/);
expect(verbose.stdout).toBe("");
});
test("an ENOENT-coded error → exit 1 (bad args) with `file not found: <path>`", () => {
const withPath = runCli(["generate", inputMd], {
preload: stubOrchestrator("enoent-path",
`Object.assign(new Error("ENOENT: no such file or directory, open '/x/in.md'"), { code: "ENOENT", errno: -2, syscall: "open", path: "/x/in.md" })`),
});
expect(withPath.code).toBe(ExitCode.BadArgs);
expect(withPath.code).toBe(1);
expect(withPath.stderr.trim()).toBe("$P: file not found: /x/in.md");
expect(withPath.stdout).toBe("");
// No `path` on the error: the message stands in.
const noPath = runCli(["generate", inputMd], {
preload: stubOrchestrator("enoent-nopath", `Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" })`),
});
expect(noPath.code).toBe(ExitCode.BadArgs);
expect(noPath.stderr.trim()).toBe("$P: file not found: ENOENT: no such file or directory");
});
test("an error named PagedJsTimeout → exit 3", () => {
const r = runCli(["generate", inputMd], {
preload: stubOrchestrator("pagedjs", `Object.assign(new Error("Paged.js did not finish within 3000ms"), { name: "PagedJsTimeout" })`),
});
expect(r.code).toBe(ExitCode.PagedJsTimeout);
expect(r.code).toBe(3);
expect(r.stderr.trim()).toBe("$P: Paged.js did not finish within 3000ms");
expect(r.stdout).toBe("");
});
test("a non-Error throw is still reported (String(err)) and exits 2", () => {
const r = runCli(["generate", inputMd], { preload: stubOrchestrator("string-throw", `"boom"`) });
expect(r.code).toBe(ExitCode.RenderError);
expect(r.stderr.trim()).toBe("$P: boom");
expect(r.stdout).toBe("");
});
test("preview shares the same catch block: BrowserUnavailableError → exit 4", () => {
const r = runCli(["preview", inputMd], {
preload: stubOrchestrator("preview-no-browser", `new BrowserUnavailableError("${NO_BROWSER}: stubbed")`),
});
expect(r.code).toBe(ExitCode.BrowserUnavailable);
expect(r.stderr.trim()).toBe(`$P: ${NO_BROWSER}: stubbed`);
expect(r.stdout).toBe("");
});
});
// ─── 2. real error paths that need no browser (no stubs) ──────────────────────
describe("cli.ts exit codes end to end (no stubs, no browser touched)", () => {
test("a missing input is the orchestrator's own guard: exit 2 `input file not found` — NOT the ENOENT branch", () => {
// generate() checks existsSync before any fs call can throw ENOENT, so a
// typo'd input reads as a render error (2), not bad args (1). Pinned as-is.
const missing = path.join(tmp, "does-not-exist.md");
const r = runCli(["generate", missing, "--quiet"]);
expect(r.code).toBe(ExitCode.RenderError);
expect(r.stderr.trim()).toBe(`$P: input file not found: ${missing}`);
expect(r.stdout).toBe("");
});
test("a real ENOENT (output directory does not exist; --to html needs no browser) → exit 1 `file not found`", () => {
const out = path.join(tmp, "no-such-dir", "out.html");
const r = runCli(["generate", inputMd, out, "--to", "html", "--quiet"]);
expect(r.code).toBe(ExitCode.BadArgs);
expect(r.stderr.trim()).toBe(`$P: file not found: ${out}`);
expect(r.stdout).toBe("");
expect(fs.existsSync(out)).toBe(false);
});
test("bad args → exit 1: no input, unknown command (with usage), invalid --to", () => {
const noInput = runCli(["generate"]);
expect(noInput.code).toBe(ExitCode.BadArgs);
expect(noInput.stderr).toContain("$P generate: missing <input.md>");
expect(noInput.stdout).toBe("");
const unknown = runCli(["frobnicate"]);
expect(unknown.code).toBe(ExitCode.BadArgs);
expect(unknown.stderr).toContain("$P: unknown command: frobnicate");
expect(unknown.stderr).toContain("Usage:");
expect(unknown.stdout).toBe("");
const badTo = runCli(["generate", inputMd, "--to", "xml"]);
expect(badTo.code).toBe(ExitCode.BadArgs);
expect(badTo.stderr).toContain("invalid --to 'xml'");
expect(badTo.stdout).toBe("");
});
test("exit 0 paths: no arguments prints usage (stderr); version prints VERSION on stdout only", () => {
const usage = runCli([]);
expect(usage.code).toBe(ExitCode.Success);
expect(usage.stderr).toContain("Usage:");
expect(usage.stdout).toBe("");
const version = runCli(["version"]);
expect(version.code).toBe(ExitCode.Success);
expect(version.stdout.trim()).toBe(fs.readFileSync(path.join(ROOT, "VERSION"), "utf8").trim());
expect(version.stderr).toBe("");
});
});
// ─── 3. fake browsers: success contract, render error vs no browser, mid-run note ─
describe.skipIf(isWin)("cli.ts through fake browsers (GSTACK_BROWSE_BIN + a scrubbed PATH)", () => {
test("gstack's own browser renders: exit 0, stdout is the output path and nothing else, --quiet keeps stderr empty", () => {
const out = path.join(tmp, "steady", "out.pdf");
const r = runCli(["generate", inputMd, out, "--quiet"], { env: childEnv({ GSTACK_BROWSE_BIN: browseOk }) });
expect(r.code).toBe(ExitCode.Success);
expect(r.stdout).toBe(`${out}\n`);
expect(r.stderr).toBe("");
expect(fs.readFileSync(out, "utf8")).toBe("%PDF-1.4 fake\n");
});
test("a browser that is up but cannot render is a render error (exit 2), never `no browser` (exit 4)", () => {
const out = path.join(tmp, "refused", "out.pdf");
const r = runCli(["generate", inputMd, out, "--quiet"], { env: childEnv({ GSTACK_BROWSE_BIN: browseRefusesNewtab }) });
expect(r.code).toBe(ExitCode.RenderError);
expect(r.stderr.trim()).toBe("$P: PDF render failed: browse newtab failed: daemon refused: boom");
expect(r.stderr).not.toContain(NO_BROWSER);
expect(r.stdout).toBe("");
expect(fs.existsSync(out)).toBe(false);
});
test("Aside answers the probe but cannot run the script: the PDF comes from gstack's browser, stderr says so, exit 0", () => {
const out = path.join(tmp, "mid-run", "out.pdf");
const r = runCli(["generate", inputMd, out, "--quiet"], {
env: childEnv({ PATH: fakeAsideBin, GSTACK_SKIP_ASIDE: undefined, GSTACK_BROWSE_BIN: browseOk }),
});
expect(r.code).toBe(ExitCode.Success);
expect(r.stdout).toBe(`${out}\n`);
expect(r.stderr).toContain("Aside was unavailable mid-run; the PDF was rendered through gstack's own browser.");
expect(fs.readFileSync(out, "utf8")).toBe("%PDF-1.4 fake\n");
});
});
+259
View File
@@ -0,0 +1,259 @@
/**
* `$P setup` smoke flow — make-pdf/src/setup.ts runSetup() — with no real
* browser probed or launched.
*
* runSetup() reads the engine through lib/aside-render's process-wide cache
* (pickEngine() with no arguments, and render() which consults the same
* cache), so `pickEngine(true, deps)` is the intended seam: prime it once with
* a stubbed Aside probe and a stubbed browse-binary resolver, then call
* runSetup(). A fake `browse` shell script plays gstack's own headless
* browser for the [2/5] render smoke and the [4/5] smoke PDF. process.exit is
* stubbed to throw so the exit code is observable; process.stderr.write is
* captured for the step lines.
*
* Also pins renderPdf()'s return value — the engine that actually rendered —
* which the orchestrator compares against the engine it announced.
*/
import { afterAll, beforeAll, 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 {
NO_BROWSER,
NO_BROWSER_HELP,
pickEngine,
renderTmpDir,
type AsideProbe,
type RenderResult,
type RenderSpec,
} from "../../lib/aside-render";
import { renderPdf } from "../src/asideClient";
import { OUTPUT_TMP_DIR } from "../src/orchestrator";
import { runSetup } from "../src/setup";
import { ExitCode } from "../src/types";
const isWin = process.platform === "win32";
const PROBE_DETAIL = "primed by setup-smoke.test.ts";
const noAside = (): AsideProbe => ({ ok: false, reason: "NEEDS_ASIDE", detail: PROBE_DETAIL });
let tmp: string;
beforeAll(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "make-pdf-setup-smoke-"));
});
afterAll(() => {
fs.rmSync(tmp, { recursive: true, force: true });
// The engine cache is process-wide. The free runner gives every test file
// its own process, but under a bare multi-file `bun test` a later in-process
// caller must not inherit a fake binary that no longer exists: re-resolve
// gstack's real browse binary (Aside probe still stubbed — never a round-trip).
pickEngine(true, { probe: noAside });
});
// ─── helpers ──────────────────────────────────────────────────────────────────
class ExitSentinel extends Error {
constructor(readonly code: number) {
super(`process.exit(${code})`);
this.name = "ExitSentinel";
}
}
/** Run `fn` with process.exit throwing a sentinel and process.stderr.write captured. Restores both. */
async function captureRun<T>(fn: () => Promise<T>): Promise<{ result?: T; exit?: number; error?: unknown; stderr: string }> {
const chunks: string[] = [];
const origWrite = process.stderr.write;
const origExit = process.exit;
process.stderr.write = ((chunk: string | Uint8Array) => {
chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"));
return true;
}) as typeof process.stderr.write;
process.exit = ((code?: number) => {
throw new ExitSentinel(code ?? 0);
}) as typeof process.exit;
try {
const result = await fn();
return { result, stderr: chunks.join("") };
} catch (e) {
if (e instanceof ExitSentinel) return { exit: e.code, stderr: chunks.join("") };
return { error: e, stderr: chunks.join("") };
} finally {
process.stderr.write = origWrite;
process.exit = origExit;
}
}
/**
* A stand-in for gstack's browse daemon CLI: `newtab --json` hands out a tab,
* `js` answers with `pageText`, `pdf --from-file <payload>` writes a fake PDF
* at the payload's `output` path when `pdf` is set (and nothing otherwise).
*/
function writeFakeBrowse(name: string, opts: { pageText?: string; pdf?: boolean; newtabFails?: boolean }): string {
const body = ["#!/bin/sh", 'case "$1" in'];
body.push(opts.newtabFails
? ' newtab) echo "daemon refused: boom" >&2; exit 1 ;;'
: ` newtab) echo '{"tabId":7}' ;;`);
body.push(` js) echo '${opts.pageText ?? "browser-ok"}' ;;`);
if (opts.pdf) {
body.push(
" pdf)",
' read -r payload < "$3"',
' out="${payload#*\\"output\\":\\"}"',
' out="${out%%\\"*}"',
" printf '%%PDF-1.4 fake\\n' > \"$out\"",
" ;;",
);
}
body.push("esac", "exit 0", "");
const file = path.join(tmp, name);
fs.writeFileSync(file, body.join("\n"), { mode: 0o755 });
return file;
}
/** The [2/5] smoke staging dirs currently under renderTmpDir(). */
function smokeDirs(stage: string): string[] {
return fs.readdirSync(stage).filter((n) => n.startsWith("make-pdf-setup-")).sort();
}
const smokePdf = () => path.join(OUTPUT_TMP_DIR, `make-pdf-smoke-${process.pid}.pdf`);
const smokeFixture = () => path.join(OUTPUT_TMP_DIR, `make-pdf-smoke-${process.pid}.md`);
// ─── [1/5] browser check ──────────────────────────────────────────────────────
describe("runSetup [1/5]: no browser at all", () => {
test("Aside absent and no browse binary: FAIL, the NO_BROWSER text with both remedies, exit 4, never reaches [2/5]", async () => {
pickEngine(true, { probe: noAside, resolveBin: () => null });
const run = await captureRun(() => runSetup());
expect(run.error).toBeUndefined();
expect(run.exit).toBe(ExitCode.BrowserUnavailable);
expect(run.exit).toBe(4);
expect(run.stderr).toContain("make-pdf setup — verifying install");
expect(run.stderr).toContain("[1/5] Checking for a browser... FAIL");
expect(run.stderr).toContain(NO_BROWSER);
expect(run.stderr).toContain("no browser available");
expect(run.stderr).toContain(NO_BROWSER_HELP);
expect(run.stderr).toContain(`(NEEDS_ASIDE: ${PROBE_DETAIL})`);
expect(run.stderr).not.toContain("[2/5]");
});
});
// ─── [2/5] render smoke through the fallback browser ──────────────────────────
describe.skipIf(isWin)("runSetup [2/5]: render smoke through gstack's own browser", () => {
test("a browser that answers but the page text is wrong: FAIL, `could not render a page` + remedy, exit 4, smoke dir removed", async () => {
const fake = writeFakeBrowse("browse-wrong-text", { pageText: "not-the-smoke-page" });
pickEngine(true, { probe: noAside, resolveBin: () => fake });
const stage = renderTmpDir();
const before = smokeDirs(stage);
const run = await captureRun(() => runSetup());
expect(run.error).toBeUndefined();
expect(run.exit).toBe(ExitCode.BrowserUnavailable);
expect(run.stderr).toContain(`[1/5] Checking for a browser... gstack browser OK (fallback: ${fake}; Aside is not running)`);
expect(run.stderr).toContain("[2/5] Rendering through gstack browser... FAIL");
expect(run.stderr).toContain("gstack browser could not render a page: unexpected page text: not-the-smoke-page");
expect(run.stderr).toContain(`To fix: ${NO_BROWSER_HELP}`);
expect(run.stderr).toContain("open the Aside app");
expect(run.stderr).toContain("./setup");
expect(run.stderr).not.toContain("[3/5]");
expect(smokeDirs(stage)).toEqual(before); // finally { rmSync(smokeDir) } ran despite the exit
});
test("a daemon that refuses to open a tab surfaces browse's own error on the same FAIL path (exit 4)", async () => {
const fake = writeFakeBrowse("browse-refuses-newtab", { newtabFails: true });
pickEngine(true, { probe: noAside, resolveBin: () => fake });
const stage = renderTmpDir();
const before = smokeDirs(stage);
const run = await captureRun(() => runSetup());
expect(run.exit).toBe(ExitCode.BrowserUnavailable);
expect(run.stderr).toContain("[2/5] Rendering through gstack browser... FAIL");
expect(run.stderr).toContain("gstack browser could not render a page: browse newtab failed: daemon refused: boom");
expect(run.stderr).toContain(`To fix: ${NO_BROWSER_HELP}`);
expect(smokeDirs(stage)).toEqual(before);
});
});
// ─── [3/5][5/5] the rest of the flow with a fake browser ─────────────────────
describe.skipIf(isWin)("runSetup [3/5]-[5/5]: pdftotext, smoke PDF, cheatsheet", () => {
test("happy path: every step reports, the smoke PDF lands at OUTPUT_TMP_DIR, the fixture is removed, no exit", async () => {
const fake = writeFakeBrowse("browse-happy", { pageText: "browser-ok", pdf: true });
pickEngine(true, { probe: noAside, resolveBin: () => fake });
const outPath = smokePdf();
const fixturePath = smokeFixture();
try {
const run = await captureRun(() => runSetup());
expect(run.error).toBeUndefined();
expect(run.exit).toBeUndefined();
expect(run.stderr).toContain(`[1/5] Checking for a browser... gstack browser OK (fallback: ${fake}; Aside is not running)`);
expect(run.stderr).toContain("[2/5] Rendering through gstack browser... OK");
// pdftotext is optional: OK where poppler is installed, SKIP with install hints otherwise.
expect(run.stderr).toMatch(/\[3\/5\] Checking pdftotext \(optional\)\.\.\. (OK \(|SKIP\n)/);
expect(run.stderr).toContain("[4/5] Generating smoke-test PDF...");
expect(run.stderr).toContain(`PASSED. Smoke test saved to ${outPath}`);
expect(run.stderr).toContain("[5/5] All checks passed.");
expect(run.stderr).toContain("make-pdf is ready. Try:");
expect(run.stderr).toContain(`Smoke-test PDF: ${outPath}`);
// The announced engine (browse) is the one that rendered: no mid-run note.
expect(run.stderr).not.toContain("mid-run");
expect(fs.readFileSync(outPath, "utf8")).toBe("%PDF-1.4 fake\n");
expect(fs.existsSync(fixturePath)).toBe(false);
} finally {
fs.rmSync(outPath, { force: true });
fs.rmSync(fixturePath, { force: true });
}
});
test("[4/5] smoke PDF failure (browser up, print produced nothing): FAILED with the render error, exit 2, fixture removed", async () => {
// Same fake, but `pdf` writes no artifact — a render error, not a missing browser.
const fake = writeFakeBrowse("browse-no-artifact", { pageText: "browser-ok", pdf: false });
pickEngine(true, { probe: noAside, resolveBin: () => fake });
const outPath = smokePdf();
const fixturePath = smokeFixture();
try {
const run = await captureRun(() => runSetup());
expect(run.error).toBeUndefined();
expect(run.exit).toBe(ExitCode.RenderError);
expect(run.exit).toBe(2);
expect(run.stderr).toContain("[2/5] Rendering through gstack browser... OK");
expect(run.stderr).toContain("[4/5] Generating smoke-test PDF...");
expect(run.stderr).toContain(" FAILED: PDF render failed: step 0 produced no artifact");
expect(run.stderr).not.toContain("[5/5]");
expect(fs.existsSync(outPath)).toBe(false);
expect(fs.existsSync(fixturePath)).toBe(false); // finally { unlinkSync(fixturePath) } ran despite the exit
} finally {
fs.rmSync(outPath, { force: true });
fs.rmSync(fixturePath, { force: true });
}
});
});
// ─── renderPdf returns the engine that rendered ───────────────────────────────
describe("renderPdf reports which engine rendered", () => {
const injected = (engine: RenderResult["engine"]) => async (spec: RenderSpec): Promise<RenderResult> => {
const step = spec.steps[0];
return { ok: true, engine, outputs: step.kind === "pdf" ? [step.out] : [], evals: {}, stdout: "" };
};
test("resolves 'browse' when gstack's own browser printed the PDF", async () => {
await expect(renderPdf("<p>x</p>", { output: path.join(tmp, "browse.pdf") }, injected("browse"))).resolves.toBe("browse");
});
test("resolves 'aside' when Aside printed it", async () => {
await expect(renderPdf("<p>x</p>", { output: path.join(tmp, "aside.pdf") }, injected("aside"))).resolves.toBe("aside");
});
test("resolves undefined when the result names no engine (older render shapes)", async () => {
await expect(renderPdf("<p>x</p>", { output: path.join(tmp, "none.pdf") }, injected(undefined))).resolves.toBeUndefined();
});
test("a failed result still throws (the engine is never returned for a failure)", async () => {
const failing = async (): Promise<RenderResult> => ({ ok: false, engine: "browse", outputs: [], evals: {}, stdout: "", error: "browse pdf failed: boom" });
await expect(renderPdf("<p>x</p>", { output: path.join(tmp, "fail.pdf") }, failing)).rejects.toThrow(/PDF render failed: browse pdf failed: boom/);
});
});
+542
View File
@@ -0,0 +1,542 @@
/**
* bin/gstack-render.ts — the CLI skills shell out to for rendering local HTML
* (make-pdf's print pipeline, the diagram bundle, design previews).
*
* Everything here is hermetic: GSTACK_SKIP_ASIDE=1 forces the browse fallback,
* and GSTACK_BROWSE_BIN points at a fake `browse` shell script that answers the
* daemon CLI's contract (newtab --json, goto, js, pdf --from-file, viewport,
* screenshot, closetab) and logs every argv line it receives. No real browser,
* no network beyond the CLI's own loopback server.
*
* Pinned contracts:
* - argument guards exit 1 with `ERROR: <msg>` + the usage line on stderr and
* never touch a browser;
* - `--help` / `-h` print the usage line to STDOUT, exit 0;
* - success output: `ENGINE=browse` first, one `OK <abs path>` per artifact,
* then EVAL / PAGE_ERRORS lines fenced as UNTRUSTED WEB CONTENT;
* - failure output: `ENGINE=browse` still first, `ERROR: browse <cmd> failed:`
* plus a transcript tail on stderr (suppressed by --quiet), exit 1;
* - `--serve-root` containment;
* - the BROWSER SETUP first-line contract when neither browser resolves.
*
* Process shape: every path that ends in `process.exit` (guards, --help, every
* failure) is run with spawnSync. A SUCCESSFUL render has no process.exit — the
* CLI falls off the end of the module, so bun exits only when the event loop is
* empty. A dangling timer in lib/aside-render's runProc once kept it alive for
* `min(120s, --timeout) + 10s` after the output was printed (2m10s at the
* default budget; 25s floor from closetab's fixed 15s); runProc now clears every
* timer it sets, and `exits promptly` below pins that. The success cases still
* pass `--timeout 5000` and run as ONE concurrent batch: if the leak ever comes
* back, one test fails with a clear message in ~30s instead of every success
* test stalling to its own timeout.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { resolveBrowseBin } from '../lib/aside-render';
const ROOT = path.resolve(import.meta.dir, '..');
const CLI = path.join(ROOT, 'bin/gstack-render.ts');
// The same bun that runs this test file, by absolute path: a scrubbed PATH in a
// child env must never decide whether the CLI itself can start.
const BUN = process.execPath;
const USAGE_PREFIX = 'usage: gstack-render';
const FENCE_BEGIN = '═══ BEGIN UNTRUSTED WEB CONTENT ═══';
const FENCE_END = '═══ END UNTRUSTED WEB CONTENT ═══';
const TEST_TIMEOUT = 30_000;
/** Ceiling for the concurrent success batch: the old 25s linger + slack; a hang past it is a test failure, not a stall. */
const BATCH_TIMEOUT = 90_000;
/**
* A successful render takes well under a second against the fake; the timer leak
* described in the header made it take 25s or more. 15s sits between the two
* with room for a loaded CI box.
*/
const PROMPT_EXIT_MS = 15_000;
// ─── The fake browse daemon CLI ──────────────────────────────────────────────
/**
* Answers exactly what lib/aside-render's browse path asks for. Every call
* appends its argv to $FAKE_BROWSE_DIR/argv.log; FAKE_BROWSE_FAIL=<cmd> makes
* that one command exit 1; FAKE_PAGE_ERRS is what the PAGE_ERRORS probe returns.
*/
const FAKE_BROWSE = `#!/bin/bash
printf '%s\\n' "$*" >> "$FAKE_BROWSE_DIR/argv.log"
cmd="$1"
if [ -n "$FAKE_BROWSE_FAIL" ] && [ "$cmd" = "$FAKE_BROWSE_FAIL" ]; then
echo "fake browse: $cmd refused" >&2
exit 1
fi
case "$cmd" in
newtab) echo '{"tabId":7}' ;;
goto|viewport|closetab) ;;
js)
expr="$2"
case "$expr" in
'JSON.stringify(window.__gstackErrs || [])') echo "\${FAKE_PAGE_ERRS:-[]}" ;;
'(() => { try { return !!('*) echo true ;;
'(() => { window.__gstackErrs'*) echo undefined ;;
*)
shift 2
out=""
while [ $# -gt 0 ]; do
if [ "$1" = "--out" ]; then out="$2"; shift 2; continue; fi
shift
done
if [ -n "$out" ]; then printf 'written-by-fake' > "$out"; else echo "Hello From Page"; fi
;;
esac
;;
pdf)
payload="$3"
cp "$payload" "$FAKE_BROWSE_DIR/pdf-payload.json"
out=$(sed -n 's/.*"output":"\\([^"]*\\)".*/\\1/p' "$payload")
printf '%%PDF-1.4 fake\\n' > "$out"
;;
screenshot)
shift
target=""
while [ $# -gt 0 ]; do
case "$1" in
--tab-id|--selector) shift 2 ;;
--*) shift ;;
*) target="$1"; shift ;;
esac
done
printf '\\x89PNG fake' > "$target"
;;
*) echo "fake browse: unknown command $cmd" >&2; exit 2 ;;
esac
`;
interface Fixture {
dir: string;
/** <dir>/site/doc.html — one level down so --serve-root <dir> has a relative path to show. */
html: string;
fakeDir: string;
fake: string;
}
function makeFixture(): Fixture {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-render-cli-'));
const sub = path.join(dir, 'site');
fs.mkdirSync(sub);
const html = path.join(sub, 'doc.html');
fs.writeFileSync(html, '<!doctype html><title>Doc</title><div id="done"></div>');
const fakeDir = path.join(dir, 'fake');
fs.mkdirSync(fakeDir);
const fake = path.join(fakeDir, 'browse');
fs.writeFileSync(fake, FAKE_BROWSE, { mode: 0o755 });
return { dir, html, fakeDir, fake };
}
/** Forget what the fake saw in an earlier run of the same fixture. */
function resetFake(f: Fixture) {
fs.rmSync(path.join(f.fakeDir, 'argv.log'), { force: true });
fs.rmSync(path.join(f.fakeDir, 'pdf-payload.json'), { force: true });
}
function argvLog(f: Fixture): string[] {
const p = path.join(f.fakeDir, 'argv.log');
return fs.existsSync(p) ? fs.readFileSync(p, 'utf8').trim().split('\n').filter(Boolean) : [];
}
interface RunOpts {
/** The fake exits 1 on this daemon command. */
fail?: string;
/** What the fake returns for the PAGE_ERRORS probe (default `[]`). */
pageErrs?: string;
env?: Record<string, string | undefined>;
}
interface CliResult {
status: number | null;
stdout: string;
stderr: string;
stdoutLines: string[];
stderrLines: string[];
/** Spawn-to-exit wall clock. */
durationMs: number;
}
function cliEnv(f: Fixture, opts: RunOpts): Record<string, string | undefined> {
return {
...process.env,
GSTACK_SKIP_ASIDE: '1',
GSTACK_BROWSE_BIN: f.fake,
BROWSE_BIN: undefined,
FAKE_BROWSE_DIR: f.fakeDir,
FAKE_BROWSE_FAIL: opts.fail,
FAKE_PAGE_ERRS: opts.pageErrs,
...opts.env,
};
}
function shape(status: number | null, stdout: string, stderr: string, durationMs: number): CliResult {
return { status, stdout, stderr, stdoutLines: stdout.split('\n').filter(Boolean), stderrLines: stderr.split('\n').filter(Boolean), durationMs };
}
/** Synchronous run: for every path that ends in process.exit (guards, --help, failures). */
function runCli(f: Fixture, args: string[], opts: RunOpts = {}): CliResult {
resetFake(f);
const started = Date.now();
const r = spawnSync(BUN, [CLI, ...args], { encoding: 'utf8', env: cliEnv(f, opts), timeout: TEST_TIMEOUT });
return shape(r.status, r.stdout ?? '', r.stderr ?? '', Date.now() - started);
}
/** Async run for the success path (see the header on why these overlap). */
async function runCliAsync(f: Fixture, args: string[], opts: RunOpts = {}): Promise<CliResult> {
resetFake(f);
const started = Date.now();
const proc = Bun.spawn([BUN, CLI, ...args], { env: cliEnv(f, opts), stdout: 'pipe', stderr: 'pipe', stdin: 'ignore' });
const killer = setTimeout(() => proc.kill('SIGKILL'), BATCH_TIMEOUT - 5_000);
const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
const status = await proc.exited;
clearTimeout(killer);
return shape(status, stdout, stderr, Date.now() - started);
}
// ─── Argument guards ─────────────────────────────────────────────────────────
describe('gstack-render CLI: argument guards', () => {
let f: Fixture;
beforeAll(() => { f = makeFixture(); });
afterAll(() => { fs.rmSync(f.dir, { recursive: true, force: true }); });
/** exit 1, stderr = `ERROR: <msg>` then the usage line, nothing on stdout, browser never spawned. */
function expectGuard(args: string[], msg: string) {
const r = runCli(f, args);
expect(r.status).toBe(1);
expect(r.stdout).toBe('');
expect(r.stderrLines.length).toBe(2);
expect(r.stderrLines[0]).toStartWith('ERROR: ');
expect(r.stderrLines[0]).toContain(msg);
expect(r.stderrLines[1]).toStartWith(USAGE_PREFIX);
expect(argvLog(f)).toEqual([]);
}
test('no arguments: just the usage line on stderr, exit 1, no browser spawned', () => {
const r = runCli(f, []);
expect(r.status).toBe(1);
expect(r.stdout).toBe('');
expect(r.stderrLines.length).toBe(1);
expect(r.stderrLines[0]).toStartWith(USAGE_PREFIX);
expect(argvLog(f)).toEqual([]);
}, TEST_TIMEOUT);
test('a pdf option before any --pdf', () => {
expectGuard([f.html, '--margin', '1in', '--pdf', 'out.pdf'], 'pdf option given before --pdf');
}, TEST_TIMEOUT);
test('--out without a preceding --eval', () => {
expectGuard([f.html, '--pdf', 'out.pdf', '--out', 'f.txt'], '--out belongs to --eval');
}, TEST_TIMEOUT);
test('unknown paper format', () => {
expectGuard([f.html, '--pdf', 'out.pdf', '--paper', 'napkin'], 'unknown paper format napkin');
}, TEST_TIMEOUT);
test('--paper-in that is not WxH', () => {
expectGuard([f.html, '--pdf', 'out.pdf', '--paper-in', '8x'], '--paper-in wants WxH');
}, TEST_TIMEOUT);
test('--pdf with no value', () => {
expectGuard([f.html, '--pdf'], '--pdf needs a value');
}, TEST_TIMEOUT);
test('unknown flag', () => {
expectGuard([f.html, '--bogus', '--pdf', 'out.pdf'], 'unknown argument --bogus');
}, TEST_TIMEOUT);
test('a file with no steps', () => {
expectGuard([f.html], 'no steps given');
}, TEST_TIMEOUT);
test('numeric flags refuse non-numbers: --timeout, --wait-timeout, --width, --quality', () => {
expectGuard([f.html, '--timeout', 'abc', '--pdf', 'out.pdf'], '--timeout wants a number');
expectGuard([f.html, '--wait-timeout', 'abc', '--pdf', 'out.pdf'], '--wait-timeout wants a number');
// --width / --quality are screenshot options: they need a --screenshot in
// flight first, otherwise the "option before --screenshot" guard fires.
expectGuard([f.html, '--screenshot', 'shot.png', '--width', 'abc'], '--width wants a number');
expectGuard([f.html, '--screenshot', 'shot.png', '--quality', 'abc'], '--quality wants a number');
}, TEST_TIMEOUT);
test('screenshot option before any --screenshot', () => {
expectGuard([f.html, '--width', '800', '--screenshot', 'shot.png'], 'screenshot option given before --screenshot');
}, TEST_TIMEOUT);
});
// ─── --help ──────────────────────────────────────────────────────────────────
describe('gstack-render CLI: --help', () => {
let f: Fixture;
beforeAll(() => { f = makeFixture(); });
afterAll(() => { fs.rmSync(f.dir, { recursive: true, force: true }); });
for (const flag of ['--help', '-h']) {
test(`${flag} prints the usage line to STDOUT and exits 0`, () => {
const r = runCli(f, [flag]);
expect(r.status).toBe(0);
expect(r.stderr).toBe('');
expect(r.stdoutLines.length).toBe(1);
expect(r.stdoutLines[0]).toStartWith(USAGE_PREFIX);
expect(argvLog(f)).toEqual([]);
}, TEST_TIMEOUT);
}
});
// ─── Success output contract ─────────────────────────────────────────────────
/** One success-path invocation: its own fixture (the fake's log is per fixture) and its own argv. */
interface SuccessCase {
args: (f: Fixture) => string[];
opts?: RunOpts;
}
const SUCCESS_CASES = {
artifacts: {
args: (f) => [
f.html, '--timeout', '5000',
'--pdf', path.join(f.dir, 'out', 'doc.pdf'), '--paper', 'letter', '--margin', '1in', '--page-numbers', '--tagged',
'--screenshot', path.join(f.dir, 'out', 'shot.png'), '--width', '800', '--height', '600',
'--eval', 'window.__svg', '--out', path.join(f.dir, 'out', 'd.svg'),
],
},
inlineEval: { args: (f) => [f.html, '--timeout', '5000', '--eval', 'document.title'] },
evalAfterPdf: { args: (f) => [f.html, '--timeout', '5000', '--pdf', path.join(f.dir, 'idx.pdf'), '--eval', 'document.title'] },
evalWithErrors: { args: (f) => [f.html, '--timeout', '5000', '--eval', 'document.title'], opts: { pageErrs: '["boom"]' } },
errorsOnly: { args: (f) => [f.html, '--timeout', '5000', '--pdf', path.join(f.dir, 'errs.pdf')], opts: { pageErrs: '["boom"]' } },
cleanPdf: { args: (f) => [f.html, '--timeout', '5000', '--pdf', path.join(f.dir, 'clean.pdf')], opts: { pageErrs: '[]' } },
waitSelector: { args: (f) => [f.html, '--timeout', '5000', '--wait-selector', '#done', '--eval', 'document.title'] },
serveRoot: { args: (f) => [f.html, '--timeout', '5000', '--serve-root', f.dir, '--pdf', path.join(f.dir, 'root.pdf')] },
} satisfies Record<string, SuccessCase>;
type SuccessKey = keyof typeof SUCCESS_CASES;
type Done = { f: Fixture; r: CliResult };
const successFixtures: Fixture[] = [];
let successBatch: Promise<Record<SuccessKey, Done>> | undefined;
/** Start every success case at once on first use; every test then awaits the same batch. */
function successRuns(): Promise<Record<SuccessKey, Done>> {
successBatch ??= (async () => {
const entries = await Promise.all((Object.keys(SUCCESS_CASES) as SuccessKey[]).map(async (key) => {
const c: SuccessCase = SUCCESS_CASES[key];
const f = makeFixture();
successFixtures.push(f);
return [key, { f, r: await runCliAsync(f, c.args(f), c.opts) }] as const;
}));
return Object.fromEntries(entries) as Record<SuccessKey, Done>;
})();
return successBatch;
}
// File-level: the batch is shared by two describes (--serve-root reads its fixture too).
afterAll(() => { for (const f of successFixtures) fs.rmSync(f.dir, { recursive: true, force: true }); });
describe('gstack-render CLI: output contract through the browse fallback', () => {
test('pdf + screenshot + eval --out: ENGINE=browse first, one OK per artifact, files exist, no fence without inline evals', async () => {
const { f, r } = (await successRuns()).artifacts;
const pdf = path.join(f.dir, 'out', 'doc.pdf');
const shot = path.join(f.dir, 'out', 'shot.png');
const svg = path.join(f.dir, 'out', 'd.svg');
expect(r.stderr).toBe('');
expect(r.status).toBe(0);
expect(r.stdoutLines).toEqual(['ENGINE=browse', `OK ${pdf}`, `OK ${shot}`, `OK ${svg}`]);
expect(r.stdout).not.toContain(FENCE_BEGIN);
expect(r.stdout).not.toContain('PAGE_ERRORS');
expect(fs.readFileSync(pdf, 'utf8')).toStartWith('%PDF');
expect(fs.readFileSync(shot).subarray(1, 4).toString()).toBe('PNG');
expect(fs.readFileSync(svg, 'utf8')).toBe('written-by-fake');
// The daemon conversation the CLI drove: tab 7 throughout, served URL on
// loopback pointing at the file inside its own directory, tab closed last.
const log = argvLog(f);
expect(log[0]).toBe('newtab --json');
expect(log[1]).toMatch(/^goto http:\/\/127\.0\.0\.1:\d+\/[0-9a-f]+\/doc\.html --tab-id 7$/);
expect(log.some((l) => l.startsWith('pdf --from-file ') && l.endsWith(' --tab-id 7'))).toBe(true);
expect(log).toContain('viewport 800x600 --tab-id 7');
expect(log.some((l) => /^screenshot \/tmp\/gstack-render-browse-[^ ]+\/gstack-render-1\.png --tab-id 7$/.test(l))).toBe(true);
expect(log).toContain('viewport 1280x720 --tab-id 7'); // default restored after a sized shot
expect(log.some((l) => l.startsWith('js window.__svg --out ') && l.endsWith(' --tab-id 7'))).toBe(true);
expect(log[log.length - 1]).toBe('closetab 7');
// The pdf options the CLI parsed reached the daemon as a --from-file payload.
const payload = JSON.parse(fs.readFileSync(path.join(f.fakeDir, 'pdf-payload.json'), 'utf8'));
expect(payload.width).toBe('8.5in');
expect(payload.height).toBe('11in');
expect(payload.marginTop).toBe('1in');
expect(payload.marginLeft).toBe('1in');
expect(payload.tagged).toBe(true);
expect(payload.footerTemplate).toContain('pageNumber');
expect(payload.headerTemplate).toBe('<div></div>');
}, BATCH_TIMEOUT);
test('inline --eval prints EVAL <i>: <text> inside the UNTRUSTED WEB CONTENT fence', async () => {
const { r } = (await successRuns()).inlineEval;
expect(r.stderr).toBe('');
expect(r.status).toBe(0);
expect(r.stdoutLines).toEqual(['ENGINE=browse', FENCE_BEGIN, 'EVAL 0: Hello From Page', FENCE_END]);
}, BATCH_TIMEOUT);
test('eval index follows step order: an artifact step before it keeps its slot', async () => {
const { f, r } = (await successRuns()).evalAfterPdf;
expect(r.status).toBe(0);
expect(r.stdoutLines).toEqual(['ENGINE=browse', `OK ${path.join(f.dir, 'idx.pdf')}`, FENCE_BEGIN, 'EVAL 1: Hello From Page', FENCE_END]);
}, BATCH_TIMEOUT);
test('page errors appear as PAGE_ERRORS=[...] inside the same fence as the evals', async () => {
const { r } = (await successRuns()).evalWithErrors;
expect(r.stderr).toBe('');
expect(r.status).toBe(0);
expect(r.stdoutLines).toEqual(['ENGINE=browse', FENCE_BEGIN, 'EVAL 0: Hello From Page', 'PAGE_ERRORS=["boom"]', FENCE_END]);
}, BATCH_TIMEOUT);
test('page errors with no evals still get a fence of their own', async () => {
const { f, r } = (await successRuns()).errorsOnly;
expect(r.status).toBe(0);
expect(r.stdoutLines).toEqual(['ENGINE=browse', `OK ${path.join(f.dir, 'errs.pdf')}`, FENCE_BEGIN, 'PAGE_ERRORS=["boom"]', FENCE_END]);
}, BATCH_TIMEOUT);
test('an empty error list prints no PAGE_ERRORS line and, with no evals, no fence at all', async () => {
const { f, r } = (await successRuns()).cleanPdf;
expect(r.status).toBe(0);
expect(r.stdoutLines).toEqual(['ENGINE=browse', `OK ${path.join(f.dir, 'clean.pdf')}`]);
}, BATCH_TIMEOUT);
test('--wait-selector polls the page through js before the first step', async () => {
const { f, r } = (await successRuns()).waitSelector;
expect(r.status).toBe(0);
expect(r.stdoutLines).toContain('EVAL 0: Hello From Page');
const log = argvLog(f);
const gotoAt = log.findIndex((l) => l.startsWith('goto '));
const pollAt = log.findIndex((l) => l.startsWith('js (() => { try { return !!(document.querySelector("#done")); }'));
const evalAt = log.findIndex((l) => l === 'js document.title --tab-id 7');
expect(gotoAt).toBeGreaterThan(-1);
expect(pollAt).toBeGreaterThan(gotoAt);
expect(evalAt).toBeGreaterThan(pollAt);
}, BATCH_TIMEOUT);
test('a successful render exits promptly: no dangling runProc timer holds the process after its output', async () => {
const runs = await successRuns();
for (const [key, { r }] of Object.entries(runs)) {
expect(r.status).toBe(0);
if (r.durationMs >= PROMPT_EXIT_MS) {
throw new Error(`${key}: exited 0 after ${r.durationMs}ms — output was complete long before; a timer in lib/aside-render.ts runProc is being left uncleared again (the CLI has no process.exit(0) on success)`);
}
}
}, BATCH_TIMEOUT);
});
// ─── Failure path ────────────────────────────────────────────────────────────
describe('gstack-render CLI: failure path', () => {
let f: Fixture;
beforeAll(() => { f = makeFixture(); });
afterAll(() => { fs.rmSync(f.dir, { recursive: true, force: true }); });
test('a failing goto: ENGINE=browse still first on stdout, ERROR + transcript tail on stderr, exit 1, tab closed', () => {
const pdf = path.join(f.dir, 'never.pdf');
const r = runCli(f, [f.html, '--pdf', pdf], { fail: 'goto' });
expect(r.status).toBe(1);
expect(r.stdoutLines).toEqual(['ENGINE=browse']);
expect(r.stderrLines[0]).toBe('ERROR: browse goto failed: fake browse: goto refused');
// The transcript tail: the daemon calls the render made, verbatim.
expect(r.stderr).toContain('$ browse newtab --json');
expect(r.stderr).toMatch(/\$ browse goto http:\/\/127\.0\.0\.1:/);
expect(fs.existsSync(pdf)).toBe(false);
const log = argvLog(f);
expect(log[log.length - 1]).toBe('closetab 7');
}, TEST_TIMEOUT);
test('--quiet drops the transcript tail but keeps ERROR:', () => {
const r = runCli(f, [f.html, '--quiet', '--pdf', path.join(f.dir, 'never2.pdf')], { fail: 'goto' });
expect(r.status).toBe(1);
expect(r.stdoutLines).toEqual(['ENGINE=browse']);
expect(r.stderrLines).toEqual(['ERROR: browse goto failed: fake browse: goto refused']);
expect(r.stderr).not.toContain('$ browse');
}, TEST_TIMEOUT);
test('a missing HTML file fails before any daemon call', () => {
const missing = path.join(f.dir, 'nope.html');
const r = runCli(f, [missing, '--pdf', path.join(f.dir, 'x.pdf')]);
expect(r.status).toBe(1);
expect(r.stdoutLines).toEqual(['ENGINE=browse']);
expect(r.stderrLines[0]).toBe(`ERROR: HTML file not found: ${missing}`);
expect(argvLog(f)).toEqual([]);
}, TEST_TIMEOUT);
});
// ─── --serve-root ────────────────────────────────────────────────────────────
describe('gstack-render CLI: --serve-root', () => {
let f: Fixture;
beforeAll(() => { f = makeFixture(); });
afterAll(() => { fs.rmSync(f.dir, { recursive: true, force: true }); });
test('a serve root containing the file serves it by its path relative to that root', async () => {
const { f: sf, r } = (await successRuns()).serveRoot;
expect(r.stderr).toBe('');
expect(r.status).toBe(0);
expect(r.stdoutLines).toEqual(['ENGINE=browse', `OK ${path.join(sf.dir, 'root.pdf')}`]);
expect(argvLog(sf).some((l) => /^goto http:\/\/127\.0\.0\.1:\d+\/[0-9a-f]+\/site\/doc\.html --tab-id 7$/.test(l))).toBe(true);
}, BATCH_TIMEOUT);
test('a serve root that does not contain the file is refused before any daemon call', () => {
const elsewhere = path.join(f.dir, 'elsewhere');
fs.mkdirSync(elsewhere);
const r = runCli(f, [f.html, '--serve-root', elsewhere, '--pdf', path.join(f.dir, 'no.pdf')]);
expect(r.status).toBe(1);
expect(r.stdoutLines).toEqual(['ENGINE=browse']);
expect(r.stderrLines[0]).toBe(`ERROR: file ${f.html} is outside serveRoot ${elsewhere}`);
expect(argvLog(f)).toEqual([]);
}, TEST_TIMEOUT);
});
// ─── Neither browser ─────────────────────────────────────────────────────────
describe('gstack-render CLI: neither browser available', () => {
// resolveBrowseBin also searches <repo>/browse/dist/browse (via import.meta.dir)
// and ~/.claude/skills/gstack/browse/dist/browse — both usually built on a dev
// box, so the environment alone cannot force "no engine". The CLI imports only
// ../lib/aside-render, which imports only builtins, so byte-identical copies of
// the two files staged in a temp tree move the repo root somewhere empty; HOME
// moves the install root; PATH is a dir with no `browse`. The one root left
// (process.execPath/../..) is probed and skips this case if it holds a browse.
const stage = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-render-stage-'));
const emptyBin = path.join(stage, 'empty-bin');
const fakeHome = path.join(stage, 'home');
fs.mkdirSync(emptyBin);
fs.mkdirSync(fakeHome);
const execRoot = path.resolve(path.dirname(process.execPath), '../..');
const uncontrollable = resolveBrowseBin({ PATH: emptyBin }, [execRoot]);
afterAll(() => { fs.rmSync(stage, { recursive: true, force: true }); });
test.skipIf(!!uncontrollable)('first line is NEEDS_ASIDE and the error names both remedies', () => {
for (const rel of ['bin/gstack-render.ts', 'lib/aside-render.ts']) {
fs.mkdirSync(path.dirname(path.join(stage, rel)), { recursive: true });
fs.copyFileSync(path.join(ROOT, rel), path.join(stage, rel));
}
const html = path.join(stage, 'doc.html');
fs.writeFileSync(html, '<!doctype html><title>Doc</title>');
const env: Record<string, string | undefined> = {
...process.env,
GSTACK_SKIP_ASIDE: '1',
GSTACK_BROWSE_BIN: path.join(stage, 'does-not-exist', 'browse'),
BROWSE_BIN: undefined,
PATH: emptyBin,
HOME: fakeHome,
};
const r = spawnSync(BUN, [path.join(stage, 'bin/gstack-render.ts'), html, '--pdf', path.join(stage, 'out.pdf')], { encoding: 'utf8', env, timeout: TEST_TIMEOUT });
const out = shape(r.status, r.stdout ?? '', r.stderr ?? '');
expect(out.status).toBe(1);
expect(out.stdoutLines).toEqual(['NEEDS_ASIDE']);
expect(out.stderrLines.length).toBe(1);
expect(out.stderrLines[0]).toStartWith('ERROR: no browser available: ');
expect(out.stderrLines[0]).toContain('Aside');
expect(out.stderrLines[0]).toContain('./setup');
expect(out.stderrLines[0]).toContain('GSTACK_BROWSE_BIN');
expect(out.stderrLines[0]).toContain('(NEEDS_ASIDE: GSTACK_SKIP_ASIDE=1');
expect(fs.existsSync(path.join(stage, 'out.pdf'))).toBe(false);
}, TEST_TIMEOUT);
});
+228
View File
@@ -0,0 +1,228 @@
/**
* setup: the NEEDS_BUILD decision ("# 1. Build browse binary if needed").
*
* One `bun run build` produces every binary (browse, design, make-pdf), so a
* missing or stale one of ANY of them must trigger the whole build. Before,
* only the browse binary's existence was checked and lib/ was not in the
* staleness set: a missing design/dist/design or make-pdf/dist/pdf, or an edit
* to lib/ (the canonical claude-bin / error-handling / aside-render sources the
* binaries embed), left setup reporting "up to date" with binaries that could
* not run or that embedded stale code.
*
* Behavior fixture, following test/setup-browser-hint.test.ts: slice the
* decision block out of setup between two stable anchors, prepend a prelude
* that defines the variables it reads, run it against a temp tree whose mtimes
* are set explicitly, and read NEEDS_BUILD back.
*/
import { describe, test, expect, afterEach } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { runBashScript } from './helpers/bash-script';
const ROOT = path.resolve(import.meta.dir, '..');
const SETUP_SRC = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
// From the $_EXE suffix derivation through the `fi` that closes the staleness
// chain. The statement that follows (the build itself) is the end anchor and is
// NOT included, so the harness never tries to run `bun run build`.
const BLOCK_START = '_EXE=""';
const BLOCK_END = '\nif [ "$NEEDS_BUILD" -eq 1 ]; then';
function needsBuildBlock(): string {
const start = SETUP_SRC.indexOf(BLOCK_START);
const end = SETUP_SRC.indexOf(BLOCK_END, start);
if (start < 0 || end < 0) throw new Error(`Could not locate the NEEDS_BUILD block in setup (${BLOCK_START} .. ${BLOCK_END.trim()})`);
return SETUP_SRC.slice(start, end + 1);
}
// Fixed instants, far apart, so coarse filesystem timestamps and clock skew
// can never blur "older than the binary" into "newer".
const BIN_T = new Date('2024-06-01T12:00:00Z');
const OLD_T = new Date('2024-01-01T12:00:00Z');
const NEW_T = new Date('2024-12-01T12:00:00Z');
// One file per source root the staleness `find` walks, plus the two manifests.
const SOURCE_FILES = [
'browse/src/index.ts',
'make-pdf/src/x.ts',
'design/src/index.ts',
'lib/claude-bin.ts',
'package.json',
'bun.lock',
];
const tmpDirs: string[] = [];
afterEach(() => {
for (const d of tmpDirs.splice(0)) fs.rmSync(d, { recursive: true, force: true });
});
function writeAt(file: string, t: Date, mode = 0o644): void {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, '#!/bin/sh\n');
fs.chmodSync(file, mode);
fs.utimesSync(file, t, t);
}
/** A tree where every binary is present and executable at BIN_T and every
* source/manifest is OLDER than it: the "nothing to do" baseline. `exe` is
* the suffix for the design and pdf binaries (".exe" on Windows). The browse
* binary keeps its bare name in every case: the prelude's BROWSE_BIN names it
* directly (setup derives the .exe form outside this block). */
function makeTree(opts: { exe?: string } = {}): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-needs-build-'));
tmpDirs.push(dir);
const exe = opts.exe ?? '';
writeAt(path.join(dir, 'browse/dist/browse'), BIN_T, 0o755);
writeAt(path.join(dir, `design/dist/design${exe}`), BIN_T, 0o755);
writeAt(path.join(dir, `make-pdf/dist/pdf${exe}`), BIN_T, 0o755);
for (const f of SOURCE_FILES) writeAt(path.join(dir, f), OLD_T);
return dir;
}
function touchNewer(dir: string, rel: string): void {
writeAt(path.join(dir, rel), NEW_T);
}
function decide(dir: string, opts: { isWindows?: '0' | '1' } = {}): number {
const script = [
'set -e',
`SOURCE_GSTACK_DIR="${dir}"`,
'BROWSE_BIN="$SOURCE_GSTACK_DIR/browse/dist/browse"',
`IS_WINDOWS=${opts.isWindows ?? '0'}`,
needsBuildBlock(),
'echo "NEEDS_BUILD=$NEEDS_BUILD"',
].join('\n');
const r = runBashScript(script, { timeout: 10_000 });
expect(r.stderr).toBe('');
expect(r.status).toBe(0);
const m = r.stdout.match(/^NEEDS_BUILD=([01])$/m);
if (!m) throw new Error(`no NEEDS_BUILD line in block output:\n${r.stdout}`);
return Number(m[1]);
}
describe('setup: NEEDS_BUILD static invariants', () => {
test('both anchors exist exactly once, so the slice is the decision block and nothing else', () => {
expect(SETUP_SRC.indexOf(BLOCK_START)).toBeGreaterThan(-1);
expect(SETUP_SRC.indexOf(BLOCK_START)).toBe(SETUP_SRC.lastIndexOf(BLOCK_START));
expect(SETUP_SRC.indexOf(BLOCK_END)).toBeGreaterThan(SETUP_SRC.indexOf(BLOCK_START));
expect(SETUP_SRC.indexOf(BLOCK_END)).toBe(SETUP_SRC.lastIndexOf(BLOCK_END));
const block = needsBuildBlock();
expect(block).toContain('NEEDS_BUILD=0');
expect(block).toContain('NEEDS_BUILD=1');
expect(block).not.toContain('bun_cmd run build');
});
test('all three binaries are existence-checked with -x and the $_EXE suffix', () => {
const block = needsBuildBlock();
expect(block).toContain('[ ! -x "$BROWSE_BIN" ]');
expect(block).toContain('[ ! -x "$SOURCE_GSTACK_DIR/design/dist/design$_EXE" ]');
expect(block).toContain('[ ! -x "$SOURCE_GSTACK_DIR/make-pdf/dist/pdf$_EXE" ]');
expect(block).toContain('if [ "$IS_WINDOWS" -eq 1 ]; then _EXE=".exe"; fi');
});
test('the staleness find walks every embedded source root, lib/ included', () => {
const block = needsBuildBlock();
for (const root of ['browse/src', 'make-pdf/src', 'design/src', 'lib']) {
expect(block).toContain(`"$SOURCE_GSTACK_DIR/${root}"`);
}
expect(block).toContain('-type f -newer "$BROWSE_BIN"');
expect(block).toContain('"$SOURCE_GSTACK_DIR/package.json" -nt "$BROWSE_BIN"');
expect(block).toContain('[ -f "$SOURCE_GSTACK_DIR/bun.lock" ] && [ "$SOURCE_GSTACK_DIR/bun.lock" -nt "$BROWSE_BIN" ]');
});
});
describe('setup: NEEDS_BUILD decision executes', () => {
test('every binary present and executable, nothing newer → 0', () => {
expect(decide(makeTree())).toBe(0);
});
test('make-pdf/dist/pdf missing → 1 (was: not checked at all)', () => {
const dir = makeTree();
fs.unlinkSync(path.join(dir, 'make-pdf/dist/pdf'));
expect(decide(dir)).toBe(1);
});
test('design/dist/design missing → 1 (was: not checked at all)', () => {
const dir = makeTree();
fs.unlinkSync(path.join(dir, 'design/dist/design'));
expect(decide(dir)).toBe(1);
});
test('browse binary missing → 1', () => {
const dir = makeTree();
fs.unlinkSync(path.join(dir, 'browse/dist/browse'));
expect(decide(dir)).toBe(1);
});
test('a binary that exists but is not executable counts as missing → 1', () => {
const dir = makeTree();
fs.chmodSync(path.join(dir, 'design/dist/design'), 0o644);
expect(decide(dir)).toBe(1);
});
test('a file under lib/ newer than the browse binary → 1 (was: lib/ not in the staleness set)', () => {
const dir = makeTree();
touchNewer(dir, 'lib/claude-bin.ts');
expect(decide(dir)).toBe(1);
});
test('a brand-new file under lib/ (not just a touched one) → 1', () => {
const dir = makeTree();
touchNewer(dir, 'lib/aside-render.ts');
expect(decide(dir)).toBe(1);
});
test('a newer make-pdf/src/x.ts → 1', () => {
const dir = makeTree();
touchNewer(dir, 'make-pdf/src/x.ts');
expect(decide(dir)).toBe(1);
});
test('a newer design/src file → 1', () => {
const dir = makeTree();
touchNewer(dir, 'design/src/index.ts');
expect(decide(dir)).toBe(1);
});
test('a newer browse/src file → 1', () => {
const dir = makeTree();
touchNewer(dir, 'browse/src/index.ts');
expect(decide(dir)).toBe(1);
});
test('a newer package.json → 1', () => {
const dir = makeTree();
touchNewer(dir, 'package.json');
expect(decide(dir)).toBe(1);
});
test('a newer bun.lock → 1', () => {
const dir = makeTree();
touchNewer(dir, 'bun.lock');
expect(decide(dir)).toBe(1);
});
test('no bun.lock at all → 0 (the -f guard keeps a missing lockfile from erroring or forcing a build)', () => {
const dir = makeTree();
fs.unlinkSync(path.join(dir, 'bun.lock'));
expect(decide(dir)).toBe(0);
});
test('IS_WINDOWS=1: design.exe and pdf.exe present, bare names absent → 0 (the $_EXE suffix is applied)', () => {
const dir = makeTree({ exe: '.exe' });
expect(fs.existsSync(path.join(dir, 'design/dist/design'))).toBe(false);
expect(fs.existsSync(path.join(dir, 'make-pdf/dist/pdf'))).toBe(false);
expect(decide(dir, { isWindows: '1' })).toBe(0);
});
test('IS_WINDOWS=1: only the bare names present → 1 (the suffix is required, not merely tolerated)', () => {
const dir = makeTree();
expect(decide(dir, { isWindows: '1' })).toBe(1);
});
test('IS_WINDOWS=0: only the .exe names present → 1 (no suffix on Unix)', () => {
const dir = makeTree({ exe: '.exe' });
expect(decide(dir, { isWindows: '0' })).toBe(1);
});
});
+79
View File
@@ -115,6 +115,85 @@ describe('extractBrowseCommands', () => {
});
});
// Prose-span extraction: the {{BROWSE_FALLBACK}} mapping table (and any other
// prose) carries `$B` shapes in backticks. Those are validated like code-block
// commands, with `[flags]`-style placeholders stripped as documentation.
describe('extractBrowseCommands — prose spans', () => {
test('a backticked $B shape in a markdown table row is extracted; [flags] placeholders are not args', () => {
const p = writeFixture('prose-table.md', [
'| Aside script step | `$B` equivalent |', // line 1: bare `$B` (no command) is not a span
'|---|---|', // line 2
'| `pg.pdf({ path })` | `$B pdf <out> [flags]` |', // line 3
].join('\n'));
const cmds = extractBrowseCommands(p);
expect(cmds).toHaveLength(1);
expect(cmds[0].command).toBe('pdf');
expect(cmds[0].args).toEqual(['<out>']);
expect(cmds[0].line).toBe(3);
expect(cmds[0].raw).not.toContain('[flags]');
});
test('a backticked $B inside a non-bash fenced block is NOT extracted; the surrounding prose still is', () => {
const p = writeFixture('prose-json-fence.md', [
'```json', // line 1
'{"cmd": "`$B goto http://a`"}', // line 2 — fenced, not bash: skipped
'```', // line 3
'Then run `$B text` to read it.', // line 4 — prose span
].join('\n'));
const cmds = extractBrowseCommands(p);
expect(cmds).toHaveLength(1);
expect(cmds[0].command).toBe('text');
expect(cmds[0].line).toBe(4);
});
test('an un-backticked $B in prose is not extracted', () => {
const p = writeFixture('prose-bare.md', 'Set $B goto first, then run `$B snapshot -i`.\n');
const cmds = extractBrowseCommands(p);
expect(cmds).toHaveLength(1);
expect(cmds[0].command).toBe('snapshot');
expect(cmds[0].args).toEqual(['-i']);
});
test('several backticked shapes on one prose line each yield a command, all pointing at that line', () => {
const p = writeFixture('prose-multi.md', [
'# Ref', // line 1
'', // line 2
'| `pg.evaluate(() => ...)` | `$B js "<expr>"` (`$B eval <file>` for multi-line) |', // line 3
'Use `$B click @e3` then `$B fill @e4 "value"` and finally `$B closetab`.', // line 4
].join('\n'));
const cmds = extractBrowseCommands(p);
expect(cmds.map(c => c.command)).toEqual(['js', 'eval', 'click', 'fill', 'closetab']);
expect(cmds[0].args).toEqual(['<expr>']);
expect(cmds[1].args).toEqual(['<file>']);
expect(cmds[3].args).toEqual(['@e4', 'value']);
expect(cmds.map(c => c.line)).toEqual([3, 3, 4, 4, 4]);
});
test('`$B --help` in prose is extracted and validateSkill accepts it as a CLI-only command', () => {
const p = writeFixture('prose-help.md', 'Run `$B --help` for the flag list.\n');
const cmds = extractBrowseCommands(p);
expect(cmds).toHaveLength(1);
expect(cmds[0].command).toBe('--help');
expect(cmds[0].args).toEqual([]);
const result = validateSkill(p);
expect(result.valid).toHaveLength(1);
expect(result.invalid).toHaveLength(0);
expect(result.warnings).toHaveLength(0);
});
test('prose shapes are validated like code: unknown commands and bad snapshot flags are flagged', () => {
const p = writeFixture('prose-invalid.md', [
'| step | `$B explode now` |',
'Also `$B snapshot --bogus` and `$B snapshot -i -a -o <path>`.',
].join('\n'));
const result = validateSkill(p);
expect(result.invalid.map(c => c.command)).toEqual(['explode']);
expect(result.snapshotFlagErrors).toHaveLength(1);
expect(result.snapshotFlagErrors[0].command.line).toBe(2);
expect(result.valid.map(c => c.raw)).toEqual(['$B snapshot -i -a -o <path>']);
});
});
describe('validateSkill', () => {
test('valid commands pass validation', () => {
const p = writeFixture('valid.md', [