mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 22:48:57 +02:00
* feat(aside): browser-driver contract, cookbook, research and fallback resolvers
{{ASIDE_SETUP}} (readiness probe + ten rules for driving the user's real browser), {{ASIDE_COOKBOOK}} (script shapes verified live against Aside CLI 1.26: one flow per aside repl script, CDP console hook before navigation, evidence lines, session-directory artifact handoff, GSTACK_STEP_OK sentinel), {{ASIDE_RESEARCH}} (research through aside exec, WebSearch when Aside is absent, knowledge otherwise) and {{BROWSE_FALLBACK}} (the fifteen-row Aside-step to $B-command table plus the rules that differ, so every browsing skill keeps working on gstack's own headless browser). test/aside-driver.test.ts pins the sentences and asserts every browsing skill carries the Aside block followed by the fallback; test/helpers/aside-available.ts is the shared live-Aside probe.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* feat(render): Aside-first local-HTML renderer with the bundled browser as fallback
lib/aside-render.ts serves the HTML's directory on loopback (Aside refuses file:// URLs), opens it with waitUntil load, prints through CDP Page.printToPDF so tagged output, outlines, header/footer templates and page numbers survive, emulates device metrics for sized screenshots, and writes in-page evaluations to files; when Aside is absent it runs the same spec through the browse daemon (newtab, load, js, pdf, screenshot, closetab) and reports ENGINE=aside|browse. bin/gstack-render.ts is the CLI skill templates call. lib/claude-bin.ts and lib/error-handling.ts become the canonical copies (browse/src re-exports them).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* refactor(browse): /browse drives Aside first, with the $B reference behind the fallback
Contract, cookbook, mode choice (aside repl by default, aside exec for reading), report format, the fallback section, and the full command reference carved on demand.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* refactor(qa): /qa and /qa-only drive Aside, fall back to $B
QA_METHODOLOGY runs every phase as Aside scripts (orient, explore, document, re-test, mobile viewport via CDP emulation, links via HEAD fetch); the authenticate phase is 'you are already signed in'; a 13th rule requires consent before mutating actions on non-local targets; the fallback section translates each step onto $B. The qa E2E tests run on whichever engine is present.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* refactor(design): design-review, design-consultation, design-shotgun, plan-design-review, design-html drive Aside
Design-system extraction is one script printing FONTS/COLORS/HEADINGS/TOUCH_TARGETS/NAV; competitor research confirms the exact URLs before opening them in the real browser and runs on the bundled browser when Aside is absent; design-html's viewport screenshots, sketches and comparison boards render through gstack-render.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* refactor(deploy): benchmark, canary, land-and-deploy Step 7, devex-review drive Aside
One aside repl script per page prints NAV/PAINT/LCP/RESOURCES/SCRIPTS/CSS/SUMMARY (benchmark), CONSOLE_ERRORS/NAV/TEXT + screenshot (canary, re-run every 60s), and the post-deploy check reads responseStatus from the navigation entry; each carries the $B fallback.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* feat(third-party-actions): Aside is the recommended driver; gstack's visible browser stays the fallback
The readiness probe is lifted from {{ASIDE_SETUP}} at gen time (byte-identity pinned) and rule 3 points at browse/SKILL.md for how to drive; the consent question offers Aside first and gstack's own visible browser (handoff/resume for sign-in) as the fallback, as v1.72 framed it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* refactor(scrape): /scrape reads pages through Aside; the browser-skills runtime rides the fallback
Look-then-extract scripts build the JSON inside the page and print it between JSON_START/JSON_END; aside exec for fuzzy intents; on the $B fallback the browser-skills match/prototype flow and /skillify apply as before.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* refactor(make-pdf): print through Aside first, the bundled browser otherwise
asideClient.ts replaces the direct $B client with one render() call per PDF (the exact option mapping the browse pdf command had: paper, margins, header/footer/page numbers, tagged, outline, printBackground, preferCSSPageSize, Paged.js wait); the diagram pre-pass, oversized-image downscale and DOCX rasters each run as one render script with per-fence try/catch; exit 4 now means no browser is available and names both remedies; $P setup reports which engine it found. The e2e gates run on whichever engine is present, so the Linux lane exercises the fallback.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* refactor(diagram): the triplet is one gstack-render call
SVG, PNG and excalidraw from one invocation over the content-addressed bundle staged under /tmp/gstack-render; every diagram type gets an excalidraw export; gstack-render picks the engine and prints ENGINE=; the diagram E2E gates on either engine.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* feat(research): web research runs in Aside first, WebSearch second
The planning, review, design, security and investigate skills research through {{ASIDE_RESEARCH}}; WebSearch stays in allowed-tools as the fallback; testing.ts's bootstrap step follows; skeleton ceilings ratcheted for the research block.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* feat(setup,gen-skill-docs): prune renders of skills that no longer exist
setup gains _prune_stale_generated for every host tree and the doc generator removes gstack-* output dirs it did not write, so a skill removed from the source tree can never linger in an install.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* test: registries, budgets and suite reconciled for Aside-first with the $B fallback
Touchfiles + E2E tiers gain the Aside keys, coverage matrix and eval baselines updated, size budget re-baselined to parity-baseline-v1.80.0.0.json (the contract plus fallback ride in every browsing skill), parity ceilings ratcheted with measured values, LLM-judge prompts and the E2E fixtures speak Aside-first, browse-fallback.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* docs: Aside first, gstack browser fallback
README, BROWSER.md, docs/, CONTRIBUTING, CLAUDE.md, ARCHITECTURE, AGENTS.md, TODOS and the root router describe the one product story: Aside is the browser gstack drives first; the bundled headless browser is the automatic fallback (Linux, Windows, app closed) where cookie import, GStack Browser, pair-agent and browser-skills still apply.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* chore: regenerate SKILL.md docs, llms.txt, agents digest, ship goldens, context-budget fixture
bun run gen:skill-docs over the templates; goldens re-rendered; context-budget ceilings recaptured.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* v1.80.0.0: Aside is the browser gstack drives first; the bundled browser is the fallback
MINOR: new capability across ten skills, the renderer and research; nothing removed. CHANGELOG release summary + itemized changes; VERSION 1.80.0.0; package.json 1.80.0.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* docs(todos): file non-Claude host ownership-gate and version-heading pin follow-ups
Two follow-ups from the /plan-ceo-review + /plan-eng-review pass on merging
PR #2804 with main's v1.80.0.0 ownership gate: bring the Codex/Factory/
OpenCode/Cursor/Kiro copy loops and the stale-render prune under the
.gstack-owned marker rule, and a free test pinning that the CHANGELOG top
heading equals VERSION (the collision that git cannot see).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix: pre-landing review fixes for the Aside-first branch
Review army + adversarial passes (Claude and Codex) on the merged branch:
setup
- _prune_stale_generated scans the host dirs too (the generator already
removed the render before setup ran, so the host branch was dead), skips
symlinks in the render tree (rm -rf on a slash-terminated link empties its
target), removes a host symlink only when it resolves into gstack, cleans a
bannered real dir through _cleanup_weak_dir, recognizes frontmatter-renamed
skills, and logs through log. The always-run codex render passes every host
dir that may link to it.
- NEEDS_BUILD checks all three binaries (with $_EXE) and lib/ sources; the
browser hint and the bootstrap summary honor GSTACK_SKIP_ASIDE, treat a
requested skip as a request, and derive one skill list.
lib/aside-render.ts + bin/gstack-render.ts
- The loopback server carries a per-render secret path, checks containment on
the real path (symlink escapes are 403), and rejects malformed encoding.
- Inline eval results are one base64 line, so page text cannot forge
ASIDE_DIR= or the sentinel; the last ASIDE_DIR wins.
- runProc escalates SIGTERM to SIGKILL, bounds every wait, and clears every
timer (an uncleared one kept gstack-render alive after printing OK).
- renderTmpDir refuses a shared /tmp name owned by someone else; the work dir
and server are created inside try; goto's budget follows the render budget.
- probeAside classifies a present-but-failing CLI as ASIDE_NOT_RUNNING like
the skills' bash probe; render() retries on gstack's own browser when Aside
could not start or its private CDP bridge is gone (never on a page error
or a timeout of a running script); the CLI reports the engine that actually
rendered, exits 0 on --help, rejects non-numeric flags, documents
--wait-timeout, fences EVAL/PAGE_ERRORS as untrusted content, and names the
daemon's cookie-import JS lock remedy.
- The browse path passes --scale only when asked (a scale change rebuilds
the daemon context) and restores the viewport after a sized screenshot.
resolvers / templates
- The bash probe honors GSTACK_SKIP_ASIDE and has a perl deadline on stock
macOS; .local is no longer LOCAL (mDNS); same-origin filters compare parsed
origins; link status is HEAD-checked only on LOCAL targets; every
aside exec goes through the receipted _aside_exec prelude
({{ASIDE_EXEC_PRELUDE}}), including nine template blocks that called it
bare; the design sketch and diagram staging use private directories.
- The generator prunes only bannered renders and never a host whose
generation failed.
Docs, stale comments and dead code cleaned; goldens re-rendered; tests
updated and added for every behavior above.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* 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>
* docs: CHANGELOG and TODOS cover the review fixes (v1.81.0.0)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* docs: sync project docs with the v1.81.0.0 review fixes
BROWSER.md, ARCHITECTURE.md, CONTRIBUTING.md, README.md, CLAUDE.md,
docs/TESTING_INTERNALS.md and docs/PROJECT_STRUCTURE.md now describe the
shipped renderer and setup: the loopback render server's per-render secret
path and real-path containment, ENGINE= naming the engine that actually
rendered (mid-run retry on gstack's own browser), EVAL/PAGE_ERRORS fenced as
untrusted content, --wait-timeout and the CLI's argv guards, the receipted
_aside_exec prelude ({{ASIDE_EXEC_PRELUDE}} in the placeholder table), the
LOCAL host rule without .local, LOCAL-only HEAD checks in the links script,
GSTACK_SKIP_ASIDE across probe/renderer/setup, the ownership-gated
retired-skill prune, the widened NEEDS_BUILD check, and the new free tests
(gstack-render-cli, setup-prune-stale-generated, setup-browser-hint,
setup-needs-build, make-pdf cli-exit-codes and setup-smoke).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* docs: CHANGELOG states the precise mid-run retry rule
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(test): skill-e2e-bws slices the $B setup block from the Browser fallback section
browse/SKILL.md no longer has '## SETUP' / '## Core QA Patterns' (Aside is the
primary driver; the $B block moved under 'Browser fallback'), so the gate test
sliced an empty block and handed the agent nothing to run. Anchor on
'### Find the `$B` binary' up to the next heading. 7/7 pass.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(test): gate POSIX-only fixtures off Windows
windows-free-tests: the gstack-render CLI tests drive a shebang fake browse
that CreateProcess cannot exec, and two NEEDS_BUILD cases assert an execute
bit and a bare-name miss that MSYS bash does not have (test -x ignores mode
bits and resolves design -> design.exe). Those describes and cases now
self-skip on win32; argument guards, --help, the no-browser case, and every
other rebuild-check case still run there.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(render): runProc waits for the exit code until the kill deadline; newtab retries once on a cold daemon
A process whose pipes have reached EOF is exiting, but runProc gave the exit
code only five seconds to arrive and then returned null, which run() reports
as a failed command. Under CI's six-shard load one such render failed with the
artifact already written. The SIGTERM/SIGKILL timers already bound the wait,
so the exit race now runs to the kill deadline.
The first CLI call auto-starts the browse daemon; on a cold start it can
answer 'Unable to connect' once while the server is still coming up. That
single case is retried after 1.5s; every other newtab failure is not.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* test(aside-render): warm the daemon before live fallback cases; failures name the render error
- Live fallback cases run 'goto about:blank' up to twice before asserting and
skip (never fail) when the daemon cannot come up.
- expectOk() puts r.error and the browse transcript into the assertion so a
failed render is diagnosable from the CI log.
- The argv-contract cases dump the fake's log on a miss.
- File default timeout is 30s: the subject is the CLI contract, not latency.
- Two cases pin the cold-daemon newtab retry and that other errors are not
retried.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* docs: CHANGELOG notes the cold-start tolerance of the bundled-browser renderer
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Sina <sdroid674+github@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
548 lines
26 KiB
TypeScript
548 lines
26 KiB
TypeScript
/**
|
|
* 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, '..');
|
|
// The fake `browse` is a shebang shell script: Windows' CreateProcess cannot
|
|
// exec it (spawn reports "Executable not found"), so every describe that drives
|
|
// the CLI through the fake self-skips on win32. The argument guards, --help,
|
|
// and the no-browser case need no fake and run everywhere.
|
|
const isWin = process.platform === 'win32';
|
|
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.skipIf(isWin)('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.skipIf(isWin)('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.skipIf(isWin)('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);
|
|
});
|