mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 22:48:57 +02:00
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:
co-authored by
Claude Fable 5.1
parent
444f8feff8
commit
e1147b75aa
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user