From e1147b75aaa607bec33ab4a5b00ef2763f3c2cb5 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 6 Sep 2026 07:23:26 +0000 Subject: [PATCH] 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 --- make-pdf/test/cli-exit-codes.test.ts | 329 ++++++++++++++++ make-pdf/test/setup-smoke.test.ts | 259 +++++++++++++ test/gstack-render-cli.test.ts | 542 +++++++++++++++++++++++++++ test/setup-needs-build.test.ts | 228 +++++++++++ test/skill-parser.test.ts | 79 ++++ 5 files changed, 1437 insertions(+) create mode 100644 make-pdf/test/cli-exit-codes.test.ts create mode 100644 make-pdf/test/setup-smoke.test.ts create mode 100644 test/gstack-render-cli.test.ts create mode 100644 test/setup-needs-build.test.ts diff --git a/make-pdf/test/cli-exit-codes.test.ts b/make-pdf/test/cli-exit-codes.test.ts new file mode 100644 index 000000000..97aae07a8 --- /dev/null +++ b/make-pdf/test/cli-exit-codes.test.ts @@ -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 /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 ` 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 = {}): Record { + const env: Record = { + 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 } = {}) { + 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` 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: `, 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: `", () => { + 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 "); + 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"); + }); +}); diff --git a/make-pdf/test/setup-smoke.test.ts b/make-pdf/test/setup-smoke.test.ts new file mode 100644 index 000000000..ce48b7462 --- /dev/null +++ b/make-pdf/test/setup-smoke.test.ts @@ -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(fn: () => Promise): 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 ` 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 => { + 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("

x

", { output: path.join(tmp, "browse.pdf") }, injected("browse"))).resolves.toBe("browse"); + }); + + test("resolves 'aside' when Aside printed it", async () => { + await expect(renderPdf("

x

", { 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("

x

", { 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 => ({ ok: false, engine: "browse", outputs: [], evals: {}, stdout: "", error: "browse pdf failed: boom" }); + await expect(renderPdf("

x

", { output: path.join(tmp, "fail.pdf") }, failing)).rejects.toThrow(/PDF render failed: browse pdf failed: boom/); + }); +}); diff --git a/test/gstack-render-cli.test.ts b/test/gstack-render-cli.test.ts new file mode 100644 index 000000000..8f518b574 --- /dev/null +++ b/test/gstack-render-cli.test.ts @@ -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: ` + 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 ` per artifact, + * then EVAL / PAGE_ERRORS lines fenced as UNTRUSTED WEB CONTENT; + * - failure output: `ENGINE=browse` still first, `ERROR: browse 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= 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; + /** /site/doc.html — one level down so --serve-root 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, 'Doc
'); + 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; +} + +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 { + 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 { + 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: ` 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; + +type SuccessKey = keyof typeof SUCCESS_CASES; +type Done = { f: Fixture; r: CliResult }; + +const successFixtures: Fixture[] = []; +let successBatch: Promise> | undefined; +/** Start every success case at once on first use; every test then awaits the same batch. */ +function successRuns(): Promise> { + 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; + })(); + 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('
'); + }, BATCH_TIMEOUT); + + test('inline --eval prints EVAL : 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 /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, 'Doc'); + const env: Record = { + ...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); +}); diff --git a/test/setup-needs-build.test.ts b/test/setup-needs-build.test.ts new file mode 100644 index 000000000..86957348c --- /dev/null +++ b/test/setup-needs-build.test.ts @@ -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); + }); +}); diff --git a/test/skill-parser.test.ts b/test/skill-parser.test.ts index 3c62c682c..981b9dfba 100644 --- a/test/skill-parser.test.ts +++ b/test/skill-parser.test.ts @@ -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 [flags]` |', // line 3 + ].join('\n')); + const cmds = extractBrowseCommands(p); + expect(cmds).toHaveLength(1); + expect(cmds[0].command).toBe('pdf'); + expect(cmds[0].args).toEqual(['']); + 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 ""` (`$B eval ` 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(['']); + expect(cmds[1].args).toEqual(['']); + 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 `.', + ].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 ']); + }); +}); + describe('validateSkill', () => { test('valid commands pass validation', () => { const p = writeFixture('valid.md', [