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>
This commit is contained in:
Sina
2026-09-05 16:40:15 -04:00
co-authored by Claude Fable 5.1
parent 6cdf19337d
commit 7cb6a52863
11 changed files with 1076 additions and 149 deletions
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env bun
/**
* gstack-render — render a local HTML file through a browser: Aside when it
* is running, otherwise gstack's own headless browser (the browse daemon).
*
* bun run ~/.claude/skills/gstack/bin/gstack-render.ts <file.html> [options] [steps...]
*
* Options
* --serve-root <dir> directory served over loopback (default: the file's dir)
* --wait-selector <sel> wait until this selector is attached before any step
* --wait-expr <js> wait until this expression is truthy before any step
* --timeout <ms> whole-render budget (default 120000; Aside caps a script at 120s)
* --quiet print only the OK/EVAL lines
*
* Steps (run in the order given; repeatable)
* --pdf <out.pdf> [--paper letter|a4|... | --paper-in WxH] [--margin <len>] [--margin-top <len>] ...
* [--header <html>] [--footer <html>] [--page-numbers] [--tagged] [--outline]
* [--print-background] [--prefer-css-page-size] [--landscape] [--wait-pagedjs]
* --screenshot <out> [--width <px>] [--height <px>] [--selector <css>] [--viewport-only] [--jpeg [--quality <n>]]
* --eval <js> [--out <file>] evaluate in the page (promises awaited); with --out the result is
* written to the file (strings verbatim, data: URLs decoded to bytes,
* anything else as JSON); without --out it is printed as EVAL <i>: ...
*
* Output: `ENGINE=aside|browse` first, then one `OK <path>` line per artifact,
* `EVAL <i>: <text>` for inline evals, `PAGE_ERRORS=[...]` when the page logged
* errors, exit 0. On failure: `ERROR: ...`, exit 1. When NEITHER browser is
* available the first line is `NEEDS_ASIDE` / `ASIDE_NOT_RUNNING` (the BROWSER
* SETUP contract) and the error names both remedies: open Aside, or build
* gstack's browser with ./setup (GSTACK_BROWSE_BIN / BROWSE_BIN override the
* fallback binary).
*
* The file's directory is served on 127.0.0.1 for the duration of the render
* (Aside refuses file:// URLs; the daemon gets the same origin so relative
* fetches behave identically) — relative <img>/<script>/<link> paths inside
* that directory resolve; anything outside it does not.
*/
import * as path from 'node:path';
import {
pickEngine, render, lengthToInches, paperInches,
type RenderSpec, type RenderStep, type PdfStepOptions,
} from '../lib/aside-render';
function usage(msg?: string): never {
if (msg) console.error(`ERROR: ${msg}`);
console.error('usage: gstack-render <file.html> [--serve-root DIR] [--wait-selector SEL] [--wait-expr JS] [--timeout MS] (--pdf OUT [pdf opts] | --screenshot OUT [--width N] [--height N] [--selector CSS] [--jpeg] | --eval JS [--out FILE])...');
process.exit(1);
}
const argv = process.argv.slice(2);
if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') usage();
const file = path.resolve(argv[0]);
const spec: RenderSpec = { file, steps: [] };
let quiet = false;
let i = 1;
const take = (flag: string): string => {
const v = argv[++i];
if (v === undefined) usage(`${flag} needs a value`);
return v;
};
let current: RenderStep | null = null;
const commit = () => { if (current) spec.steps.push(current); current = null; };
const pdfOf = (): PdfStepOptions => {
if (!current || current.kind !== 'pdf') usage('pdf option given before --pdf');
current.options ??= {};
return current.options;
};
const shotOf = () => {
if (!current || current.kind !== 'screenshot') usage('screenshot option given before --screenshot');
return current;
};
for (; i < argv.length; i++) {
const a = argv[i];
switch (a) {
case '--serve-root': spec.serveRoot = path.resolve(take(a)); break;
case '--wait-selector': (spec.waitFor ??= {}).selector = take(a); break;
case '--wait-expr': (spec.waitFor ??= {}).expression = take(a); break;
case '--wait-timeout': (spec.waitFor ??= {}).timeoutMs = Number(take(a)); break;
case '--timeout': spec.timeoutMs = Number(take(a)); break;
case '--quiet': quiet = true; break;
case '--pdf': commit(); current = { kind: 'pdf', out: path.resolve(take(a)), options: {} }; break;
case '--screenshot': commit(); current = { kind: 'screenshot', out: path.resolve(take(a)) }; break;
case '--eval': commit(); current = { kind: 'eval', expression: take(a) }; break;
case '--out': {
if (!current || current.kind !== 'eval') usage('--out belongs to --eval');
current.out = path.resolve(take(a)); break;
}
// pdf options
case '--paper': {
const p = paperInches(take(a));
if (!p) usage(`unknown paper format ${argv[i]}`);
const o = pdfOf(); [o.paperWidth, o.paperHeight] = p; break;
}
case '--paper-in': {
const m = take(a).match(/^([0-9.]+)x([0-9.]+)$/i);
if (!m) usage('--paper-in wants WxH in inches, e.g. 8.5x11');
const o = pdfOf(); o.paperWidth = Number(m[1]); o.paperHeight = Number(m[2]); break;
}
case '--margin': { const v = lengthToInches(take(a)); const o = pdfOf(); o.marginTop = o.marginRight = o.marginBottom = o.marginLeft = v; break; }
case '--margin-top': pdfOf().marginTop = lengthToInches(take(a)); break;
case '--margin-right': pdfOf().marginRight = lengthToInches(take(a)); break;
case '--margin-bottom': pdfOf().marginBottom = lengthToInches(take(a)); break;
case '--margin-left': pdfOf().marginLeft = lengthToInches(take(a)); break;
case '--header': { const o = pdfOf(); o.displayHeaderFooter = true; o.headerTemplate = take(a); o.footerTemplate ??= '<div></div>'; break; }
case '--footer': { const o = pdfOf(); o.displayHeaderFooter = true; o.footerTemplate = take(a); o.headerTemplate ??= '<div></div>'; break; }
case '--page-numbers': {
const o = pdfOf(); o.displayHeaderFooter = true; o.headerTemplate ??= '<div></div>';
o.footerTemplate = '<div style="font-size:9pt; font-family:Helvetica,Arial,sans-serif; color:#666; width:100%; text-align:center;"><span class="pageNumber"></span> of <span class="totalPages"></span></div>';
break;
}
case '--tagged': pdfOf().generateTaggedPDF = true; break;
case '--outline': pdfOf().generateDocumentOutline = true; break;
case '--print-background': pdfOf().printBackground = true; break;
case '--prefer-css-page-size': pdfOf().preferCSSPageSize = true; break;
case '--landscape': pdfOf().landscape = true; break;
case '--wait-pagedjs': pdfOf().waitForPagedJs = true; break;
// screenshot options
case '--width': shotOf().width = Number(take(a)); break;
case '--height': shotOf().height = Number(take(a)); break;
case '--selector': shotOf().selector = take(a); break;
case '--viewport-only': shotOf().fullPage = false; break;
case '--jpeg': shotOf().type = 'jpeg'; break;
case '--quality': shotOf().quality = Number(take(a)); break;
default: usage(`unknown argument ${a}`);
}
}
commit();
if (spec.steps.length === 0) usage('no steps given (--pdf, --screenshot, or --eval)');
const engine = pickEngine();
if (!engine.engine) {
console.log(engine.probe.reason);
console.error(`ERROR: ${engine.error}`);
process.exit(1);
}
console.log(`ENGINE=${engine.engine}`);
const result = await render(spec);
if (!result.ok) {
console.error(`ERROR: ${result.error}`);
if (!quiet) console.error(result.stdout.trim().split('\n').slice(-12).join('\n'));
process.exit(1);
}
for (const out of result.outputs) console.log(`OK ${out}`);
for (const [idx, text] of Object.entries(result.evals)) console.log(`EVAL ${idx}: ${text}`);
const errs = result.stdout.match(/^PAGE_ERRORS=(.+)$/m)?.[1];
if (errs && errs !== '[]') console.log(`PAGE_ERRORS=${errs}`);
+2 -73
View File
@@ -1,73 +1,2 @@
/**
* claude-bin.ts — Cross-platform `claude` binary resolution.
*
* Uses Bun.which() for the platform handling (PATH parsing, Windows PATHEXT,
* X_OK, case-insensitive Path/PATH on Windows). Adds the gstack-specific
* override + arg-prefix logic on top.
*
* Override precedence:
* 1. GSTACK_CLAUDE_BIN (or CLAUDE_BIN as fallback) — absolute path or
* PATH-resolvable command. `wsl` resolves through Bun.which('wsl') just
* like a bare `claude` lookup would.
* 2. Plain `Bun.which('claude')` if no override is set.
*
* Arg prefix:
* GSTACK_CLAUDE_BIN_ARGS (or CLAUDE_BIN_ARGS) prepends arguments to every
* spawn. Accepts a JSON array (e.g. '["claude", "--no-cache"]') or a single
* scalar string treated as one argument. Only applied when an override is
* active — bare `claude` resolution doesn't pick up an arg prefix.
*
* Returns null when nothing resolves; callers should degrade (e.g. transcript
* classifier returns degraded:true) rather than throw.
*/
import * as path from 'path';
export interface ClaudeCommand {
command: string;
argsPrefix: string[];
}
function stripWrappingQuotes(value: string): string {
return value.replace(/^"(.*)"$/, '$1');
}
function parseOverrideArgs(env: NodeJS.ProcessEnv): string[] {
const raw = env.GSTACK_CLAUDE_BIN_ARGS ?? env.CLAUDE_BIN_ARGS;
if (!raw?.trim()) return [];
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed) && parsed.every((v) => typeof v === 'string')) {
return parsed;
}
} catch {
// Not JSON — treat as a single scalar argument.
}
return [stripWrappingQuotes(raw.trim())];
}
export function resolveClaudeCommand(
env: NodeJS.ProcessEnv = process.env,
): ClaudeCommand | null {
const argsPrefix = parseOverrideArgs(env);
const override = (env.GSTACK_CLAUDE_BIN ?? env.CLAUDE_BIN)?.trim();
// Honor case-insensitive Path/PATH on Windows. Bun.which itself reads
// process.env so we forward whichever the caller passed.
const PATH = env.PATH ?? env.Path ?? '';
if (override) {
const trimmed = stripWrappingQuotes(override);
// Absolute path: use as-is. Otherwise PATH-resolve through Bun.which so
// overrides like GSTACK_CLAUDE_BIN=wsl find the actual binary.
const resolved = path.isAbsolute(trimmed) ? trimmed : Bun.which(trimmed, { PATH });
return resolved ? { command: resolved, argsPrefix } : null;
}
const command = Bun.which('claude', { PATH });
return command ? { command, argsPrefix: [] } : null;
}
/** Convenience wrapper for callers that only need the command path. */
export function resolveClaudeBinary(env: NodeJS.ProcessEnv = process.env): string | null {
return resolveClaudeCommand(env)?.command ?? null;
}
// Canonical copy lives in lib/claude-bin.ts (shared by test helpers and scripts).
export * from '../../lib/claude-bin';
+2 -72
View File
@@ -1,72 +1,2 @@
/**
* Shared error-handling utilities for browse server and CLI.
*
* Each wrapper uses selective catches (checks err.code) to avoid masking
* unexpected errors. Empty catches would be flagged by slop-scan.
*/
import * as fs from 'fs';
// ─── Filesystem ────────────────────────────────────────────────
/** Remove a file, ignoring ENOENT (already gone). Rethrows other errors. */
export function safeUnlink(filePath: string): void {
try {
fs.unlinkSync(filePath);
} catch (err: any) {
if (err?.code !== 'ENOENT') throw err;
}
}
/** Remove a file, ignoring ALL errors. Use only in best-effort cleanup (shutdown, emergency). */
export function safeUnlinkQuiet(filePath: string): void {
try { fs.unlinkSync(filePath); } catch {}
}
// ─── Process ───────────────────────────────────────────────────
/** Send a signal to a process, ignoring ESRCH (already dead). Rethrows other errors. */
export function safeKill(pid: number, signal: NodeJS.Signals | number): void {
try {
process.kill(pid, signal);
} catch (err: any) {
if (err?.code !== 'ESRCH') throw err;
}
}
/**
* Check if a PID is alive. Pure boolean probe — never throws.
*
* Signal 0 on EVERY platform (#1952). Node maps `process.kill(pid, 0)` to an
* OpenProcess existence check on Windows — and on Windows the browse daemon
* runs under Node (dist/server-node.mjs + bun-polyfill, the documented
* fallback for oven-sh/bun#4253) — so the POSIX idiom is portable here.
*
* Windows used to shell out to `tasklist /FI "PID eq <pid>"` and
* string-match the CSV. That was wrong in two ways, both hit in production:
*
* 1. FALSE NEGATIVES UNDER LOAD (#2414/#2295): tasklist takes ~700-1700ms
* on an idle box and far longer under memory pressure. A Bun.spawnSync
* that hits its `timeout` still RETURNS, carrying partial stdout — so
* the `.includes()` match came back false and a LIVE process was
* reported dead. Callers that validate liveness before killing
* (killAgentByRecord, the terminal-agent watchdog) then skipped the
* kill and respawned around the survivor — one leaked terminal-agent
* per tick, self-reinforcing (each orphan slows the next tasklist).
* 2. A console window per probe (#1952): the watchdog blinked a conhost
* window into the foreground every 60s for the whole session.
*
* Signal 0 spawns nothing, cannot time out, and is orders of magnitude
* faster (~0.004ms vs ~270ms measured in #2414).
*
* EPERM means the process EXISTS but we lack rights to signal it. That is
* alive — returning false there would reintroduce failure mode 1.
*/
export function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (err: any) {
return err?.code === 'EPERM';
}
}
// Canonical copy lives in lib/error-handling.ts (shared by test helpers and scripts).
export * from '../../lib/error-handling';
+541
View File
@@ -0,0 +1,541 @@
/**
* lib/aside-render.ts — render local HTML through a browser: Aside first,
* gstack's own headless browser as the fallback.
*
* The Aside AI browser (macOS 15+, aside.com) is the primary browser for
* every skill. When it is not installed or not running (Linux, Windows, a
* closed app), the same RenderSpec runs through the `browse` daemon (gstack's
* Playwright/Chromium engine, built by ./setup) — `render()` picks the engine,
* `RenderResult.engine` says which one ran. Local-HTML jobs (make-pdf's print
* pipeline, the diagram render bundle, design previews) all come through here.
*
* How the Aside path works (every fact verified against Aside CLI 1.26):
* 1. Aside refuses `file://` URLs ("Cannot navigate to a file URL without
* local file access"), so the HTML's directory is served over loopback
* with Bun.serve on an ephemeral port for the duration of ONE render.
* 2. One `aside repl` process runs ONE generated script: open the page,
* wait, run the steps in order, close the tab. Nothing persists between
* `aside repl` calls and tabs die with the script, so a render is always
* a single script.
* 3. Artifacts are written inside Aside's sandbox (`pwd` = the per-run
* session directory; the sandbox `fs` cannot write anywhere else), the
* script prints `ASIDE_DIR=<pwd>`, and this module copies them out.
* 4. PDFs go through raw CDP `Page.printToPDF` (via `page._sendToTarget`)
* so header/footer templates, tagged PDF, and document outline keep
* working — `page.pdf()` exposes only the Playwright subset.
* 5. Screenshots at a given width use CDP `Emulation.setDeviceMetricsOverride`
* (there is no `setViewportSize`).
* 6. The CLI exit code is 0 even when the script throws; truth is the
* `GSTACK_RENDER_OK` sentinel on stdout. A `[error` line means failure.
*
* How the browse path works: the same loopback server (so relative fetches
* and assets behave identically), then one daemon CLI call per action —
* `newtab --json`, `goto`, `js` polling for readiness, `pdf --from-file`,
* `viewport` + `screenshot`, `js --out` (the daemon decodes data: URLs), and
* `closetab` in a finally. Artifacts are written under /tmp (the daemon's
* safe-dirs policy) and copied to the caller's paths. The console-error
* bookkeeping is best-effort: after a `cookie-import` the daemon refuses `js`
* on other origins, and a pdf/screenshot-only spec must still print. Not
* mirrored on this path: pageRanges/scale, screenshot quality, the 2x default
* device scale.
*
* Node builtins + Bun only (bun build --compile embeds this into make-pdf).
*/
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { spawnSync } from 'node:child_process';
export const RENDER_SENTINEL = 'GSTACK_RENDER_OK';
const DEFAULT_TIMEOUT_MS = 120_000;
// ─── Availability ────────────────────────────────────────────────────────────
export type AsideProbe =
| { ok: true; version: string }
| { ok: false; reason: 'NEEDS_ASIDE' | 'ASIDE_NOT_RUNNING'; detail: string };
/** Same probe the skills run in BROWSER SETUP: binary present, app answering. */
export function probeAside(timeoutMs = 30_000): AsideProbe {
if (process.env.GSTACK_SKIP_ASIDE === '1') {
return { ok: false, reason: 'NEEDS_ASIDE', detail: 'GSTACK_SKIP_ASIDE=1 — Aside skipped by request' };
}
const which = spawnSync('aside', ['--version'], { encoding: 'utf8', timeout: 10_000 });
if (which.error || which.status !== 0) {
return { ok: false, reason: 'NEEDS_ASIDE', detail: 'the `aside` CLI is not on PATH — install the Aside browser (macOS 15+) from aside.com' };
}
const probe = spawnSync('aside', ['repl', 'console.log("ASIDE_READY " + pwd)'], { encoding: 'utf8', timeout: timeoutMs });
const out = `${probe.stdout ?? ''}${probe.stderr ?? ''}`;
if (!/^ASIDE_READY /m.test(out)) {
return { ok: false, reason: 'ASIDE_NOT_RUNNING', detail: (out.trim() || probe.error?.message || 'no answer from the Aside app').slice(0, 400) };
}
return { ok: true, version: (which.stdout ?? '').trim() };
}
// ─── Spec ────────────────────────────────────────────────────────────────────
/** CDP Page.printToPDF options, plus make-pdf's Paged.js wait. Inches for paper/margins. */
export interface PdfStepOptions {
paperWidth?: number;
paperHeight?: number;
landscape?: boolean;
marginTop?: number;
marginRight?: number;
marginBottom?: number;
marginLeft?: number;
displayHeaderFooter?: boolean;
headerTemplate?: string;
footerTemplate?: string;
printBackground?: boolean;
preferCSSPageSize?: boolean;
generateTaggedPDF?: boolean;
generateDocumentOutline?: boolean;
pageRanges?: string;
scale?: number;
/** Wait (≤3s, non-fatal) for `window.__pagedjsAfterFired` before printing. */
waitForPagedJs?: boolean;
}
export type RenderStep =
| { kind: 'pdf'; out: string; options?: PdfStepOptions }
| { kind: 'screenshot'; out: string; width?: number; height?: number; deviceScaleFactor?: number; mobile?: boolean; fullPage?: boolean; selector?: string; type?: 'png' | 'jpeg'; quality?: number }
/**
* Evaluate a JS expression in the page (promises are awaited). With `out`,
* the result is written to that file: strings verbatim; `data:` URLs are
* decoded to bytes; other values as JSON. Without `out`, the result comes
* back in `RenderResult.evals` (strings are truncated to `maxInline` chars).
*/
| { kind: 'eval'; expression: string; out?: string; maxInline?: number };
export interface RenderSpec {
/** Absolute path of the HTML file to open. */
file: string;
/** Directory served over loopback (default: the file's directory). Must contain `file`. */
serveRoot?: string;
/** Readiness: a selector that must be attached, and/or an expression that must be truthy. */
waitFor?: { selector?: string; expression?: string; timeoutMs?: number };
steps: RenderStep[];
/** Whole-script budget passed to the `aside repl` process. Aside caps a script at 120s. */
timeoutMs?: number;
}
export type RenderEngine = 'aside' | 'browse';
export interface RenderResult {
ok: boolean;
/** Which browser ran the spec (absent when none could). */
engine?: RenderEngine;
/** Files written on the caller's side, in step order (steps without `out` contribute nothing). */
outputs: string[];
/** Inline eval results keyed by step index. */
evals: Record<number, string>;
stdout: string;
error?: string;
}
// ─── Paper + margin helpers (make-pdf's option shapes → CDP inches) ──────────
const PAPER_INCHES: Record<string, [number, number]> = {
letter: [8.5, 11], legal: [8.5, 14], tabloid: [11, 17], ledger: [17, 11],
a0: [33.1, 46.8], a1: [23.4, 33.1], a2: [16.54, 23.4], a3: [11.7, 16.54], a4: [8.27, 11.7], a5: [5.83, 8.27], a6: [4.13, 5.83],
};
/** "1in" | "20mm" | "72px" | "2cm" | "12pt" | bare number (px) → inches. */
export function lengthToInches(v: string | number | undefined): number | undefined {
if (v === undefined || v === null || v === '') return undefined;
if (typeof v === 'number') return v / 96;
const m = String(v).trim().match(/^([0-9]*\.?[0-9]+)\s*(in|mm|cm|px|pt)?$/i);
if (!m) throw new Error(`unsupported length: ${v}`);
const n = parseFloat(m[1]);
switch ((m[2] || 'px').toLowerCase()) {
case 'in': return n;
case 'mm': return n / 25.4;
case 'cm': return n / 2.54;
case 'pt': return n / 72;
default: return n / 96;
}
}
/** Paper format name → [width, height] in inches; undefined for unknown names. */
export function paperInches(format: string | undefined): [number, number] | undefined {
if (!format) return undefined;
return PAPER_INCHES[format.toLowerCase()];
}
// ─── Script generation ───────────────────────────────────────────────────────
const HOOK = `(() => { window.__gstackErrs = window.__gstackErrs || []; const oe = console.error; console.error = (...a) => { window.__gstackErrs.push(a.map(String).join(" ")); oe.apply(console, a); }; window.addEventListener("error", e => window.__gstackErrs.push("uncaught: " + e.message)); })()`;
function artifactName(i: number, out: string): string {
const ext = path.extname(out) || '.bin';
return `gstack-render-${i}${ext}`;
}
export function buildRenderScript(url: string, spec: RenderSpec): string {
const L: string[] = [];
L.push(`const HOOK = ${JSON.stringify(HOOK)};`);
L.push(`const pg = await openTab("about:blank");`);
L.push(`await pg._sendToTarget("Page.addScriptToEvaluateOnNewDocument", { source: HOOK });`);
// "load", not Aside's default "interactive" readiness: a 9MB single-file
// bundle (lib/diagram-render) never satisfies the interactive heuristic and
// times out at 30s, while `load` fires in ~0.5s. Readiness is then explicit
// via waitFor (selector attached / expression truthy).
L.push(`await pg.goto(${JSON.stringify(url)}, { waitUntil: "load", timeout: ${spec.waitFor?.timeoutMs ?? 90_000} });`);
const wait = spec.waitFor;
if (wait?.selector) {
L.push(`await pg.waitForSelector(${JSON.stringify(wait.selector)}, { state: "attached", timeout: ${wait.timeoutMs ?? 30_000} });`);
}
if (wait?.expression) {
L.push(`{ const deadline = Date.now() + ${wait.timeoutMs ?? 30_000}; let ok = false; while (Date.now() < deadline) { try { ok = !!(await pg.evaluate((src) => (0, eval)(src), ${JSON.stringify(wait.expression)})); } catch (e) {} if (ok) break; await sleep(150); } if (!ok) throw new Error("waitFor expression never became truthy: " + ${JSON.stringify(wait.expression)}); }`);
}
spec.steps.forEach((step, i) => {
if (step.kind === 'pdf') {
const o = step.options ?? {};
if (o.waitForPagedJs) {
L.push(`{ const deadline = Date.now() + 3000; let ready = false; while (Date.now() < deadline) { try { ready = await pg.evaluate(() => !!window.__pagedjsAfterFired); } catch (e) {} if (ready) break; await sleep(150); } }`);
}
const cdp: Record<string, unknown> = {};
for (const k of ['paperWidth', 'paperHeight', 'landscape', 'marginTop', 'marginRight', 'marginBottom', 'marginLeft', 'displayHeaderFooter', 'headerTemplate', 'footerTemplate', 'printBackground', 'preferCSSPageSize', 'generateTaggedPDF', 'generateDocumentOutline', 'pageRanges', 'scale'] as const) {
if (o[k] !== undefined) cdp[k] = o[k];
}
L.push(`{ const r = await pg._sendToTarget("Page.printToPDF", ${JSON.stringify(cdp)}); await fs.writeFile(path.join(pwd, ${JSON.stringify(artifactName(i, step.out))}), Buffer.from(r.data, "base64")); console.log("STEP_OK ${i}"); }`);
} else if (step.kind === 'screenshot') {
const name = artifactName(i, step.out);
const shot: Record<string, unknown> = { path: name, fullPage: step.fullPage !== false };
if (step.type) shot.type = step.type;
if (step.quality !== undefined) shot.quality = step.quality;
if (step.width) {
L.push(`await pg._sendToTarget("Emulation.setDeviceMetricsOverride", ${JSON.stringify({ width: step.width, height: step.height ?? Math.round(step.width * 0.75), deviceScaleFactor: step.deviceScaleFactor ?? 2, mobile: step.mobile ?? step.width < 1024 })}); await sleep(250);`);
}
if (step.selector) {
const sel: Record<string, unknown> = { path: name };
if (step.type) sel.type = step.type;
L.push(`await pg.locator(${JSON.stringify(step.selector)}).screenshot(${JSON.stringify(sel)});`);
} else {
L.push(`await pg.screenshot(${JSON.stringify(shot)});`);
}
if (step.width) L.push(`await pg._sendToTarget("Emulation.clearDeviceMetricsOverride", {});`);
L.push(`console.log("STEP_OK ${i}");`);
} else {
// eval: promises are awaited by evaluate; write or inline the result
L.push(`{ const v = await pg.evaluate((src) => (0, eval)(src), ${JSON.stringify(step.expression)});`);
if (step.out) {
L.push(` const name = ${JSON.stringify(artifactName(i, step.out))};`);
L.push(` if (typeof v === "string" && /^data:[^;]+;base64,/.test(v)) await fs.writeFile(path.join(pwd, name), Buffer.from(v.slice(v.indexOf(",") + 1), "base64"));`);
L.push(` else if (typeof v === "string") await fs.writeFile(path.join(pwd, name), v);`);
L.push(` else await fs.writeFile(path.join(pwd, name), JSON.stringify(v));`);
L.push(` console.log("STEP_OK ${i}"); }`);
} else {
const max = step.maxInline ?? 20_000;
L.push(` const s = typeof v === "string" ? v : JSON.stringify(v); console.log("EVAL_START ${i}"); console.log(String(s ?? "").slice(0, ${max})); console.log("EVAL_END ${i}"); console.log("STEP_OK ${i}"); }`);
}
}
});
L.push(`console.log("PAGE_ERRORS=" + JSON.stringify(await pg.evaluate(() => window.__gstackErrs || [])));`);
L.push(`console.log("ASIDE_DIR=" + pwd);`);
L.push(`await closeTab(pg);`);
L.push(`console.log(${JSON.stringify(RENDER_SENTINEL)});`);
return L.join('\n');
}
// ─── Loopback server ─────────────────────────────────────────────────────────
function serveDir(root: string): { url: string; stop: () => void } {
const realRoot = fs.realpathSync(root);
const server = Bun.serve({
hostname: '127.0.0.1',
port: 0,
fetch(req) {
const pathname = decodeURIComponent(new URL(req.url).pathname);
const target = path.resolve(realRoot, '.' + pathname);
if (!target.startsWith(realRoot + path.sep) && target !== realRoot) return new Response('forbidden', { status: 403 });
if (!fs.existsSync(target) || fs.statSync(target).isDirectory()) return new Response('not found', { status: 404 });
return new Response(Bun.file(target));
},
});
return { url: `http://127.0.0.1:${server.port}`, stop: () => server.stop(true) };
}
// ─── Async spawn (keeps the loopback server's event loop free) ────────────────
async function runProc(cmd: string, args: string[], timeoutMs: number): Promise<{ code: number | null; stdout: string; stderr: string; error?: string }> {
let child: ReturnType<typeof Bun.spawn>;
try {
child = Bun.spawn([cmd, ...args], { stdout: 'pipe', stderr: 'pipe', stdin: 'ignore' });
} catch (e) {
return { code: null, stdout: '', stderr: '', error: (e as Error).message };
}
let timedOut = false;
const timer = setTimeout(() => { timedOut = true; try { child.kill(); } catch {} }, timeoutMs);
const [stdout, stderr] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text()]);
const code = await child.exited;
clearTimeout(timer);
return { code, stdout, stderr, error: timedOut ? `timed out after ${timeoutMs}ms` : undefined };
}
// ─── Render: Aside ───────────────────────────────────────────────────────────
export async function renderWithAside(spec: RenderSpec): Promise<RenderResult> {
return { ...(await asideRender(spec)), engine: 'aside' };
}
async function asideRender(spec: RenderSpec): Promise<RenderResult> {
const file = path.resolve(spec.file);
if (!fs.existsSync(file)) return { ok: false, outputs: [], evals: {}, stdout: '', error: `HTML file not found: ${file}` };
const root = path.resolve(spec.serveRoot ?? path.dirname(file));
const rel = path.relative(root, file);
if (rel.startsWith('..')) return { ok: false, outputs: [], evals: {}, stdout: '', error: `file ${file} is outside serveRoot ${root}` };
const srv = serveDir(root);
try {
const url = `${srv.url}/${rel.split(path.sep).map(encodeURIComponent).join('/')}`;
const script = buildRenderScript(url, spec);
// Async spawn: a synchronous wait would block this event loop, and the
// loopback server above runs on it — Page.navigate would then time out.
const proc = await runProc('aside', ['repl', script], spec.timeoutMs ?? DEFAULT_TIMEOUT_MS + 10_000);
const stdout = `${proc.stdout}${proc.stderr}`.replace(/\x1b\[[0-9;]*m/g, '');
const evals: Record<number, string> = {};
for (const m of stdout.matchAll(/^EVAL_START (\d+)\n([\s\S]*?)\nEVAL_END \1$/gm)) evals[Number(m[1])] = m[2];
if (proc.error) return { ok: false, outputs: [], evals, stdout, error: `aside repl did not run: ${proc.error}` };
if (!stdout.split('\n').some((l) => l.trim() === RENDER_SENTINEL)) {
const errLine = stdout.split('\n').find((l) => /^(\[error|Error:|\w*Error:)/.test(l.trim())) ?? stdout.trim().split('\n').slice(-3).join(' | ');
return { ok: false, outputs: [], evals, stdout, error: `render script did not finish: ${errLine || 'no output'}` };
}
const dir = stdout.match(/^ASIDE_DIR=(.+)$/m)?.[1]?.trim();
if (!dir) return { ok: false, outputs: [], evals, stdout, error: 'render script printed no ASIDE_DIR' };
const outputs: string[] = [];
for (const [i, step] of spec.steps.entries()) {
if (!('out' in step) || !step.out) continue;
const src = path.join(dir, artifactName(i, step.out));
if (!fs.existsSync(src)) return { ok: false, outputs, evals, stdout, error: `step ${i} produced no artifact (${src})` };
fs.mkdirSync(path.dirname(path.resolve(step.out)), { recursive: true });
fs.copyFileSync(src, step.out);
outputs.push(step.out);
}
return { ok: true, outputs, evals, stdout };
} finally {
srv.stop();
}
}
/** Where callers may stage HTML so the loopback server can reach it. */
export function renderTmpDir(): string {
const dir = path.join(os.tmpdir(), 'gstack-render');
fs.mkdirSync(dir, { recursive: true });
return dir;
}
// ─── Render: browse (gstack's own headless browser, the fallback) ────────────
/** Roots that may hold browse/dist/browse or the browse/bin/find-browse shim. */
const BROWSE_ROOTS = [
path.resolve(import.meta.dir, '..'), // repo checkout: lib/ → root
path.resolve(path.dirname(process.execPath), '../..'), // compiled make-pdf/dist/pdf → root (repo and global install alike)
path.join(os.homedir(), '.claude/skills/gstack'),
];
/** The daemon only reads/writes under its safe dirs; /tmp is always one of them. */
const BROWSE_TMP = process.platform === 'win32' ? os.tmpdir() : '/tmp';
/** A regular, executable file — probing .exe/.cmd/.bat on Windows, where X_OK degrades to an existence check. */
function executable(p: string): string | null {
for (const c of process.platform === 'win32' ? [p, `${p}.exe`, `${p}.cmd`, `${p}.bat`] : [p]) {
try {
if (fs.statSync(c).isFile()) { fs.accessSync(c, fs.constants.X_OK); return c; }
} catch { /* next candidate */ }
}
return null;
}
/**
* Locate gstack's own browse binary: $GSTACK_BROWSE_BIN → $BROWSE_BIN →
* <root>/browse/dist/browse → <root>/browse/bin/find-browse (per root, repo
* then install) → `browse` on PATH. Null when nothing resolves.
*/
export function resolveBrowseBin(env: NodeJS.ProcessEnv = process.env, roots: string[] = BROWSE_ROOTS): string | null {
const PATH = env.PATH ?? env.Path ?? '';
const override = (env.GSTACK_BROWSE_BIN ?? env.BROWSE_BIN ?? '').trim().replace(/^"(.*)"$/, '$1');
if (override) {
const found = path.isAbsolute(override) ? executable(override) : Bun.which(override, { PATH });
if (found) return found;
}
for (const root of roots) {
const built = executable(path.join(root, 'browse/dist/browse'));
if (built) return built;
const shim = executable(path.join(root, 'browse/bin/find-browse'));
if (!shim) continue;
const r = spawnSync(shim, [], { encoding: 'utf8', timeout: 10_000 });
const found = r.status === 0 ? executable((r.stdout ?? '').trim()) : null;
if (found) return found;
}
return Bun.which('browse', { PATH }) ?? null;
}
/** PdfStepOptions (CDP, inches) → the browse `pdf --from-file` payload (Playwright shapes, string lengths). */
export function browsePdfPayload(o: PdfStepOptions, output: string): Record<string, unknown> {
const p: Record<string, unknown> = { output };
let [w, h] = [o.paperWidth, o.paperHeight];
if (o.landscape) [w, h] = [h ?? 11, w ?? 8.5]; // browse has no landscape flag: swap (Letter when unset)
if (w !== undefined && h !== undefined) { p.width = `${w}in`; p.height = `${h}in`; }
for (const k of ['marginTop', 'marginRight', 'marginBottom', 'marginLeft'] as const) {
if (o[k] !== undefined) p[k] = `${o[k]}in`;
}
if (o.displayHeaderFooter) {
p.headerTemplate = o.headerTemplate ?? '<div></div>';
p.footerTemplate = o.footerTemplate ?? '<div></div>';
}
if (o.generateTaggedPDF) p.tagged = true;
if (o.generateDocumentOutline) p.outline = true;
if (o.printBackground) p.printBackground = true;
if (o.preferCSSPageSize) p.preferCSSPageSize = true;
if (o.waitForPagedJs) p.toc = true;
return p;
}
type ScreenshotStep = Extract<RenderStep, { kind: 'screenshot' }>;
/** Screenshot step → browse `screenshot` args (the path's extension picks png/jpeg). */
export function browseScreenshotArgs(step: ScreenshotStep, output: string): string[] {
const args = ['screenshot'];
if (step.fullPage === false) args.push('--viewport');
if (step.selector) args.push('--selector', step.selector);
args.push(output);
return args;
}
function screenshotName(i: number, step: ScreenshotStep): string {
const ext = step.type === 'jpeg' ? '.jpg' : step.type === 'png' ? '.png' : (path.extname(step.out) || '.png');
return `gstack-render-${i}${ext}`;
}
/**
* Run a RenderSpec through the browse daemon. Same loopback server as the
* Aside path, one CLI call per action, artifacts staged under /tmp and copied
* to the caller's paths. The tab is closed in a finally; the daemon stays up.
*/
export async function renderWithBrowse(spec: RenderSpec, bin: string | null = resolveBrowseBin()): Promise<RenderResult> {
const outputs: string[] = [];
const evals: Record<number, string> = {};
const log: string[] = [];
const fail = (error: string): RenderResult => ({ ok: false, engine: 'browse', outputs, evals, stdout: log.join('\n'), error });
if (!bin) return fail(`${NO_BROWSER}: ${NO_BROWSER_HELP}`);
const file = path.resolve(spec.file);
if (!fs.existsSync(file)) return fail(`HTML file not found: ${file}`);
const root = path.resolve(spec.serveRoot ?? path.dirname(file));
const rel = path.relative(root, file);
if (rel.startsWith('..')) return fail(`file ${file} is outside serveRoot ${root}`);
const deadline = Date.now() + (spec.timeoutMs ?? DEFAULT_TIMEOUT_MS);
const run = async (args: string[]): Promise<string> => {
const r = await runProc(bin, args, Math.max(1_000, Math.min(120_000, deadline - Date.now())));
log.push(`$ browse ${args.join(' ').slice(0, 300)}\n${r.stdout}${r.stderr}`.trim());
if (r.error || r.code !== 0) {
throw new Error(`browse ${args[0]} failed: ${(r.stderr || r.stdout || r.error || '').trim().split('\n')[0]}`);
}
return r.stdout;
};
const copyOut = (src: string, out: string, i: number) => {
if (!fs.existsSync(src)) throw new Error(`step ${i} produced no artifact (${src})`);
fs.mkdirSync(path.dirname(path.resolve(out)), { recursive: true });
fs.copyFileSync(src, out);
outputs.push(out);
};
const work = fs.mkdtempSync(path.join(BROWSE_TMP, 'gstack-render-browse-'));
const srv = serveDir(root);
let tab: number | undefined;
try {
const opened = (await run(['newtab', '--json'])).match(/\{[^\n]*"tabId"[^\n]*\}/)?.[0];
tab = opened ? JSON.parse(opened).tabId : undefined;
if (typeof tab !== 'number') throw new Error('browse newtab --json returned no tabId');
const T = ['--tab-id', String(tab)];
const js = async (expr: string, extra: string[] = []) => (await run(['js', expr, ...extra, ...T])).replace(/\n$/, '');
// Poll until truthy. A throw inside the page (e.g. `window.later.ok` before
// `later` exists) is "not yet", exactly as the Aside script treats it —
// never a render failure. `run` still throws when the daemon itself refuses.
const until = async (expr: string, what: string, timeoutMs: number) => {
const end = Date.now() + timeoutMs;
while (Date.now() < end) {
if ((await js(`(() => { try { return !!(${expr}); } catch (e) { return false; } })()`)) === 'true') return;
await Bun.sleep(150);
}
throw new Error(`${what} (waited ${timeoutMs}ms)`);
};
await run(['goto', `${srv.url}/${rel.split(path.sep).map(encodeURIComponent).join('/')}`, ...T]);
// Best-effort: once `$B cookie-import` has run, the daemon blocks `js` on
// every other origin (127.0.0.1 included). pdf/screenshot/`js --out` steps
// must still run; a waitFor or eval step that is genuinely blocked fails
// below with the daemon's own message.
const bestEffortJs = async (expr: string, what: string) => { try { return await js(expr); } catch (e) { log.push(`${what} unavailable: ${(e as Error).message}`); return null; } };
await bestEffortJs(HOOK, 'console hook');
const wait = spec.waitFor;
if (wait?.selector) await until(`document.querySelector(${JSON.stringify(wait.selector)})`, `waitFor selector never attached: ${wait.selector}`, wait.timeoutMs ?? 30_000);
if (wait?.expression) await until(wait.expression, `waitFor expression never became truthy: ${wait.expression}`, wait.timeoutMs ?? 30_000);
for (const [i, step] of spec.steps.entries()) {
if (step.kind === 'pdf') {
const tmp = path.join(work, artifactName(i, step.out));
const payload = path.join(work, `pdf-${i}.json`);
fs.writeFileSync(payload, JSON.stringify(browsePdfPayload(step.options ?? {}, tmp)));
await run(['pdf', '--from-file', payload, ...T]);
copyOut(tmp, step.out, i);
} else if (step.kind === 'screenshot') {
const tmp = path.join(work, screenshotName(i, step));
if (step.width) {
const vp = [`${step.width}x${step.height ?? Math.round(step.width * 0.75)}`];
if (step.deviceScaleFactor) vp.push('--scale', String(step.deviceScaleFactor));
await run(['viewport', ...vp, ...T]);
}
await run([...browseScreenshotArgs(step, tmp), ...T]);
copyOut(tmp, step.out, i);
} else if (step.out) {
const tmp = path.join(work, artifactName(i, step.out));
await js(step.expression, ['--out', tmp]); // the daemon decodes data: URLs to bytes itself
copyOut(tmp, step.out, i);
} else {
evals[i] = (await js(step.expression)).slice(0, step.maxInline ?? 20_000);
}
}
const errs = await bestEffortJs('JSON.stringify(window.__gstackErrs || [])', 'PAGE_ERRORS');
if (errs !== null) log.push(`PAGE_ERRORS=${errs}`);
return { ok: true, engine: 'browse', outputs, evals, stdout: log.join('\n') };
} catch (e) {
return fail((e as Error).message);
} finally {
if (tab !== undefined) await runProc(bin, ['closetab', String(tab)], 15_000);
srv.stop();
fs.rmSync(work, { recursive: true, force: true });
}
}
// ─── Engine choice ───────────────────────────────────────────────────────────
export const NO_BROWSER = 'no browser available';
export const NO_BROWSER_HELP = "open the Aside app (macOS 15+, aside.com), or run ./setup in the gstack repo to build gstack's own headless browser (or point GSTACK_BROWSE_BIN at a browse binary)";
export type EngineChoice =
| { engine: 'aside'; version: string }
| { engine: 'browse'; bin: string }
| { engine: null; probe: AsideProbe; error: string };
let chosen: EngineChoice | undefined;
/** Aside when it answers, else gstack's own browser, else neither. Cached per process (the Aside probe is a round-trip). */
export function pickEngine(fresh = false): EngineChoice {
if (chosen && !fresh) return chosen;
const probe = probeAside();
if (probe.ok) return (chosen = { engine: 'aside', version: probe.version });
const bin = resolveBrowseBin();
if (bin) return (chosen = { engine: 'browse', bin });
return (chosen = { engine: null, probe, error: `${NO_BROWSER}: ${NO_BROWSER_HELP} (${probe.reason}: ${probe.detail})` });
}
/** Render through whichever browser is available; `error` starts with NO_BROWSER when neither is. */
export async function render(spec: RenderSpec): Promise<RenderResult> {
const c = pickEngine();
if (c.engine === 'aside') return renderWithAside(spec);
if (c.engine === 'browse') return renderWithBrowse(spec, c.bin);
return { ok: false, outputs: [], evals: {}, stdout: '', error: c.error };
}
+73
View File
@@ -0,0 +1,73 @@
/**
* claude-bin.ts — Cross-platform `claude` binary resolution.
*
* Uses Bun.which() for the platform handling (PATH parsing, Windows PATHEXT,
* X_OK, case-insensitive Path/PATH on Windows). Adds the gstack-specific
* override + arg-prefix logic on top.
*
* Override precedence:
* 1. GSTACK_CLAUDE_BIN (or CLAUDE_BIN as fallback) — absolute path or
* PATH-resolvable command. `wsl` resolves through Bun.which('wsl') just
* like a bare `claude` lookup would.
* 2. Plain `Bun.which('claude')` if no override is set.
*
* Arg prefix:
* GSTACK_CLAUDE_BIN_ARGS (or CLAUDE_BIN_ARGS) prepends arguments to every
* spawn. Accepts a JSON array (e.g. '["claude", "--no-cache"]') or a single
* scalar string treated as one argument. Only applied when an override is
* active — bare `claude` resolution doesn't pick up an arg prefix.
*
* Returns null when nothing resolves; callers should degrade (e.g. transcript
* classifier returns degraded:true) rather than throw.
*/
import * as path from 'path';
export interface ClaudeCommand {
command: string;
argsPrefix: string[];
}
function stripWrappingQuotes(value: string): string {
return value.replace(/^"(.*)"$/, '$1');
}
function parseOverrideArgs(env: NodeJS.ProcessEnv): string[] {
const raw = env.GSTACK_CLAUDE_BIN_ARGS ?? env.CLAUDE_BIN_ARGS;
if (!raw?.trim()) return [];
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed) && parsed.every((v) => typeof v === 'string')) {
return parsed;
}
} catch {
// Not JSON — treat as a single scalar argument.
}
return [stripWrappingQuotes(raw.trim())];
}
export function resolveClaudeCommand(
env: NodeJS.ProcessEnv = process.env,
): ClaudeCommand | null {
const argsPrefix = parseOverrideArgs(env);
const override = (env.GSTACK_CLAUDE_BIN ?? env.CLAUDE_BIN)?.trim();
// Honor case-insensitive Path/PATH on Windows. Bun.which itself reads
// process.env so we forward whichever the caller passed.
const PATH = env.PATH ?? env.Path ?? '';
if (override) {
const trimmed = stripWrappingQuotes(override);
// Absolute path: use as-is. Otherwise PATH-resolve through Bun.which so
// overrides like GSTACK_CLAUDE_BIN=wsl find the actual binary.
const resolved = path.isAbsolute(trimmed) ? trimmed : Bun.which(trimmed, { PATH });
return resolved ? { command: resolved, argsPrefix } : null;
}
const command = Bun.which('claude', { PATH });
return command ? { command, argsPrefix: [] } : null;
}
/** Convenience wrapper for callers that only need the command path. */
export function resolveClaudeBinary(env: NodeJS.ProcessEnv = process.env): string | null {
return resolveClaudeCommand(env)?.command ?? null;
}
+72
View File
@@ -0,0 +1,72 @@
/**
* Shared error-handling utilities for gstack's TypeScript tools and tests.
*
* Each wrapper uses selective catches (checks err.code) to avoid masking
* unexpected errors. Empty catches would be flagged by slop-scan.
*/
import * as fs from 'fs';
// ─── Filesystem ────────────────────────────────────────────────
/** Remove a file, ignoring ENOENT (already gone). Rethrows other errors. */
export function safeUnlink(filePath: string): void {
try {
fs.unlinkSync(filePath);
} catch (err: any) {
if (err?.code !== 'ENOENT') throw err;
}
}
/** Remove a file, ignoring ALL errors. Use only in best-effort cleanup (shutdown, emergency). */
export function safeUnlinkQuiet(filePath: string): void {
try { fs.unlinkSync(filePath); } catch {}
}
// ─── Process ───────────────────────────────────────────────────
/** Send a signal to a process, ignoring ESRCH (already dead). Rethrows other errors. */
export function safeKill(pid: number, signal: NodeJS.Signals | number): void {
try {
process.kill(pid, signal);
} catch (err: any) {
if (err?.code !== 'ESRCH') throw err;
}
}
/**
* Check if a PID is alive. Pure boolean probe — never throws.
*
* Signal 0 on EVERY platform (#1952). Node maps `process.kill(pid, 0)` to an
* OpenProcess existence check on Windows — and on Windows the browse daemon
* runs under Node (dist/server-node.mjs + bun-polyfill, the documented
* fallback for oven-sh/bun#4253) — so the POSIX idiom is portable here.
*
* Windows used to shell out to `tasklist /FI "PID eq <pid>"` and
* string-match the CSV. That was wrong in two ways, both hit in production:
*
* 1. FALSE NEGATIVES UNDER LOAD (#2414/#2295): tasklist takes ~700-1700ms
* on an idle box and far longer under memory pressure. A Bun.spawnSync
* that hits its `timeout` still RETURNS, carrying partial stdout — so
* the `.includes()` match came back false and a LIVE process was
* reported dead. Callers that validate liveness before killing
* (killAgentByRecord, the terminal-agent watchdog) then skipped the
* kill and respawned around the survivor — one leaked terminal-agent
* per tick, self-reinforcing (each orphan slows the next tasklist).
* 2. A console window per probe (#1952): the watchdog blinked a conhost
* window into the foreground every 60s for the whole session.
*
* Signal 0 spawns nothing, cannot time out, and is orders of magnitude
* faster (~0.004ms vs ~270ms measured in #2414).
*
* EPERM means the process EXISTS but we lack rights to signal it. That is
* alive — returning false there would reintroduce failure mode 1.
*/
export function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (err: any) {
return err?.code === 'EPERM';
}
}
+1 -1
View File
@@ -19,7 +19,7 @@
import '../lib/conductor-env-shim';
import { query, type SDKMessage } from '@anthropic-ai/claude-agent-sdk';
import { readOverlay } from './resolvers/model-overlay';
import { resolveClaudeBinary } from '../browse/src/claude-bin';
import { resolveClaudeBinary } from '../lib/claude-bin';
async function main() {
const failures: string[] = [];
+235
View File
@@ -0,0 +1,235 @@
/**
* lib/aside-render.ts — the local-HTML renderer for make-pdf, diagrams, and
* design previews: Aside first, gstack's own browse daemon as the fallback.
*
* Pure pins run everywhere; the live Aside render runs only where Aside is
* installed and open (macOS dev machines); the live fallback render runs
* wherever a browse binary resolves (Linux CI builds one via build:gates).
*/
import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
buildRenderScript, lengthToInches, paperInches, renderWithAside, RENDER_SENTINEL,
resolveBrowseBin, browsePdfPayload, browseScreenshotArgs, renderWithBrowse, NO_BROWSER,
} from '../lib/aside-render';
import { asideAvailable } from './helpers/aside-available';
const LIVE_HTML = '<!doctype html><title>Live Probe</title><h1>Hello</h1><div id="done"></div><script>window.__v = "x".repeat(200000)</script>';
describe('aside-render: option mapping', () => {
test('lengths convert to inches (CDP unit)', () => {
expect(lengthToInches('1in')).toBe(1);
expect(lengthToInches('25.4mm')).toBeCloseTo(1, 6);
expect(lengthToInches('2.54cm')).toBeCloseTo(1, 6);
expect(lengthToInches('72pt')).toBe(1);
expect(lengthToInches('96px')).toBe(1);
expect(lengthToInches(48)).toBe(0.5);
expect(lengthToInches(undefined)).toBeUndefined();
expect(() => lengthToInches('1 furlong')).toThrow();
});
test('paper formats resolve case-insensitively', () => {
expect(paperInches('Letter')).toEqual([8.5, 11]);
expect(paperInches('a4')![0]).toBeCloseTo(8.27, 2);
expect(paperInches('tabloid')).toEqual([11, 17]);
expect(paperInches('napkin')).toBeUndefined();
});
});
describe('aside-render: generated script follows the Aside contract', () => {
const script = buildRenderScript('http://127.0.0.1:1/x.html', {
file: '/x.html',
waitFor: { selector: '#done', expression: 'window.ready' },
steps: [
{ kind: 'pdf', out: '/tmp/a.pdf', options: { paperWidth: 8.5, paperHeight: 11, generateTaggedPDF: true, headerTemplate: '<b>h</b>', displayHeaderFooter: true, waitForPagedJs: true } },
{ kind: 'screenshot', out: '/tmp/m.jpg', width: 375, type: 'jpeg', quality: 60 },
{ kind: 'screenshot', out: '/tmp/el.png', selector: '#hero' },
{ kind: 'eval', expression: 'window.__svg', out: '/tmp/d.svg' },
{ kind: 'eval', expression: 'document.title' },
],
});
test('opens about:blank, installs the console hook, then loads with waitUntil load', () => {
expect(script).toContain('openTab("about:blank")');
expect(script.indexOf('Page.addScriptToEvaluateOnNewDocument')).toBeLessThan(script.indexOf('pg.goto('));
expect(script).toContain('waitUntil: "load"');
expect(script).toContain('waitForSelector("#done", { state: "attached"');
expect(script).toContain('waitFor expression never became truthy');
});
test('pdf goes through CDP printToPDF with the full option set and the Paged.js wait', () => {
expect(script).toContain('Page.printToPDF');
expect(script).toContain('"generateTaggedPDF":true');
expect(script).toContain('"headerTemplate":"<b>h</b>"');
expect(script).toContain('__pagedjsAfterFired');
expect(script).not.toContain('pg.pdf(');
});
test('sized screenshots emulate device metrics and clear them; element shots use the locator', () => {
expect(script).toContain('Emulation.setDeviceMetricsOverride');
expect(script).toContain('"width":375');
expect(script).toContain('"mobile":true');
expect(script).toContain('Emulation.clearDeviceMetricsOverride');
expect(script).toContain('pg.locator("#hero").screenshot(');
expect(script).not.toContain('setViewportSize');
});
test('evals run in-page via eval, data URLs decode to bytes, inline results are fenced', () => {
expect(script).toContain('(0, eval)(src)');
expect(script).toContain('/^data:[^;]+;base64,/');
expect(script).toContain('EVAL_START 4');
expect(script).toContain('EVAL_END 4');
});
test('every artifact stays inside the sandbox dir and the script ends with close + sentinel', () => {
expect(script).toContain('path.join(pwd, "gstack-render-0.pdf")');
expect(script).toContain('"gstack-render-3.svg"');
expect(script).toContain('console.log("ASIDE_DIR=" + pwd)');
const tail = script.trim().split('\n').slice(-2);
expect(tail[0]).toBe('await closeTab(pg);');
expect(tail[1]).toBe(`console.log(${JSON.stringify(RENDER_SENTINEL)});`);
});
});
/** The same spec both engines must satisfy: PDF, sized JPEG, eval-to-file (200KB string + data URL), inline eval. */
async function liveRoundTrip(engine: 'aside' | 'browse', renderFn: typeof renderWithAside): Promise<void> {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `${engine}-render-live-`));
fs.writeFileSync(path.join(dir, 'doc.html'), LIVE_HTML);
try {
const out = await renderFn({
file: path.join(dir, 'doc.html'),
waitFor: { selector: '#done', expression: 'window.__v.length === 200000' },
steps: [
{ kind: 'pdf', out: path.join(dir, 'out.pdf'), options: { paperWidth: 8.5, paperHeight: 11, generateTaggedPDF: true, printBackground: true, displayHeaderFooter: true, headerTemplate: '<div></div>', footerTemplate: '<div style="font-size:8pt">f</div>' } },
{ kind: 'screenshot', out: path.join(dir, 'm.jpg'), width: 375, type: 'jpeg', quality: 50 },
{ kind: 'eval', expression: 'window.__v', out: path.join(dir, 'v.txt') },
{ kind: 'eval', expression: 'document.title' },
{ kind: 'eval', expression: '"data:application/octet-stream;base64," + btoa("hello")', out: path.join(dir, 'bytes.bin') },
],
timeoutMs: 90_000,
});
expect(out.error).toBeUndefined();
expect(out.ok).toBe(true);
expect(out.engine).toBe(engine);
expect(out.outputs).toEqual([path.join(dir, 'out.pdf'), path.join(dir, 'm.jpg'), path.join(dir, 'v.txt'), path.join(dir, 'bytes.bin')]);
expect(fs.readFileSync(path.join(dir, 'out.pdf')).subarray(0, 4).toString()).toBe('%PDF');
expect(fs.readFileSync(path.join(dir, 'm.jpg')).subarray(0, 2)).toEqual(Buffer.from([0xff, 0xd8])); // JPEG SOI
expect(fs.statSync(path.join(dir, 'v.txt')).size).toBe(200000);
expect(fs.readFileSync(path.join(dir, 'bytes.bin'), 'utf8')).toBe('hello'); // data URL decoded to bytes
expect(out.evals[3]).toBe('Live Probe');
expect(out.stdout).toMatch(/^PAGE_ERRORS=\[\]$/m);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
/** `--wait-expr` is poll-until-truthy: an expression that THROWS until its object exists must not fail the render. */
async function lateReadiness(engine: 'aside' | 'browse', renderFn: typeof renderWithAside): Promise<void> {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `${engine}-render-late-`));
fs.writeFileSync(path.join(dir, 'late.html'), '<!doctype html><title>Late</title><body><script>setTimeout(() => { window.later = { ok: true }; }, 800);</script></body>');
try {
const out = await renderFn({ file: path.join(dir, 'late.html'), waitFor: { expression: 'window.later.ok', timeoutMs: 10_000 }, steps: [{ kind: 'eval', expression: 'document.title' }], timeoutMs: 60_000 });
expect(out.error).toBeUndefined();
expect(out.ok).toBe(true);
expect(out.engine).toBe(engine);
expect(out.evals[0]).toBe('Late');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
describe('aside-render: live render (needs the Aside app)', () => {
test.skipIf(!asideAvailable())('renders a served HTML file to PDF, screenshot, and eval outputs', () => liveRoundTrip('aside', renderWithAside), 120_000);
test.skipIf(!asideAvailable())('--wait-expr polls through a throwing expression until it becomes truthy', () => lateReadiness('aside', renderWithAside), 60_000);
});
describe('aside-render: browse fallback — binary resolution', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-resolve-'));
const fakeBin = (root: string, rel: string): string => {
const p = path.join(root, rel);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, '#!/bin/sh\necho fake\n', { mode: 0o755 });
return p;
};
const rootA = path.join(home, 'a');
const rootB = path.join(home, 'b');
const builtA = fakeBin(rootA, 'browse/dist/browse');
const builtB = fakeBin(rootB, 'browse/dist/browse');
const override = fakeBin(home, 'elsewhere/browse');
const legacy = fakeBin(home, 'legacy/browse');
const empty = path.join(home, 'empty');
fs.mkdirSync(empty);
const noPath = { PATH: '' };
test('GSTACK_BROWSE_BIN wins, then BROWSE_BIN, then the first root with browse/dist/browse', () => {
expect(resolveBrowseBin({ ...noPath, GSTACK_BROWSE_BIN: override, BROWSE_BIN: legacy }, [rootA])).toBe(override);
expect(resolveBrowseBin({ ...noPath, BROWSE_BIN: legacy }, [rootA])).toBe(legacy);
expect(resolveBrowseBin(noPath, [rootA, rootB])).toBe(builtA);
expect(resolveBrowseBin(noPath, [empty, rootB])).toBe(builtB);
});
test('an override that does not exist falls through (main parity); nothing anywhere is null, never a throw', () => {
expect(resolveBrowseBin({ ...noPath, GSTACK_BROWSE_BIN: path.join(home, 'nope') }, [rootA])).toBe(builtA);
expect(resolveBrowseBin(noPath, [empty])).toBeNull();
expect(resolveBrowseBin({ ...noPath, GSTACK_BROWSE_BIN: ' ' }, [empty])).toBeNull();
});
test('the find-browse shim is consulted when a root has no built binary', () => {
const rootC = path.join(home, 'c');
const shim = path.join(rootC, 'browse/bin/find-browse');
fs.mkdirSync(path.dirname(shim), { recursive: true });
fs.writeFileSync(shim, `#!/bin/sh\necho ${builtB}\n`, { mode: 0o755 });
expect(resolveBrowseBin(noPath, [rootC])).toBe(builtB);
});
test('directories are never "executables"', () => {
const rootD = path.join(home, 'd');
fs.mkdirSync(path.join(rootD, 'browse/dist/browse'), { recursive: true });
expect(resolveBrowseBin(noPath, [rootD])).toBeNull();
});
});
describe('aside-render: browse fallback — command builders (pure)', () => {
test('pdf payload: CDP inches → browse string lengths, empty header/footer slots filled, flags mapped by name', () => {
const p = browsePdfPayload({
paperWidth: 8.5, paperHeight: 11, marginTop: 1, marginRight: 0, marginBottom: 0.5, marginLeft: 0,
displayHeaderFooter: true, footerTemplate: '<i>f</i>',
generateTaggedPDF: true, generateDocumentOutline: true, printBackground: true, preferCSSPageSize: true, waitForPagedJs: true,
}, '/tmp/x/out.pdf');
expect(p).toEqual({
output: '/tmp/x/out.pdf', width: '8.5in', height: '11in',
marginTop: '1in', marginRight: '0in', marginBottom: '0.5in', marginLeft: '0in',
headerTemplate: '<div></div>', footerTemplate: '<i>f</i>',
tagged: true, outline: true, printBackground: true, preferCSSPageSize: true, toc: true,
});
});
test('pdf payload: no header/footer unless displayHeaderFooter; landscape swaps width/height (Letter when unset)', () => {
expect(browsePdfPayload({ paperWidth: 8.5, paperHeight: 11, headerTemplate: '<b>h</b>' }, 'o.pdf')).toEqual({ output: 'o.pdf', width: '8.5in', height: '11in' });
expect(browsePdfPayload({ paperWidth: 8.5, paperHeight: 11, landscape: true }, 'o.pdf')).toEqual({ output: 'o.pdf', width: '11in', height: '8.5in' });
expect(browsePdfPayload({ landscape: true }, 'o.pdf')).toEqual({ output: 'o.pdf', width: '11in', height: '8.5in' });
expect(browsePdfPayload({}, 'o.pdf')).toEqual({ output: 'o.pdf' });
});
test('screenshot args: full page by default, --viewport for viewport-only, --selector for element shots, path last', () => {
expect(browseScreenshotArgs({ kind: 'screenshot', out: '/x/a.png' }, '/tmp/w/gstack-render-0.png')).toEqual(['screenshot', '/tmp/w/gstack-render-0.png']);
expect(browseScreenshotArgs({ kind: 'screenshot', out: '/x/a.png', fullPage: false }, '/tmp/w/s.png')).toEqual(['screenshot', '--viewport', '/tmp/w/s.png']);
expect(browseScreenshotArgs({ kind: 'screenshot', out: '/x/a.png', selector: '#hero' }, '/tmp/w/s.png')).toEqual(['screenshot', '--selector', '#hero', '/tmp/w/s.png']);
});
test('renderWithBrowse with no binary reports the no-browser error without touching the filesystem', async () => {
const r = await renderWithBrowse({ file: '/nonexistent/x.html', steps: [] }, null);
expect(r.ok).toBe(false);
expect(r.engine).toBe('browse');
expect(r.error?.startsWith(NO_BROWSER)).toBe(true);
expect(r.error).toContain('./setup');
});
});
describe('aside-render: live fallback render (needs a browse binary)', () => {
const bin = resolveBrowseBin();
test.skipIf(!bin)("renders the same spec through gstack's own browser", () => liveRoundTrip('browse', (spec) => renderWithBrowse(spec, bin)), 180_000);
test.skipIf(!bin)('--wait-expr polls through a throwing expression until it becomes truthy (Aside parity)', () => lateReadiness('browse', (spec) => renderWithBrowse(spec, bin)), 60_000);
});
+1 -1
View File
@@ -35,7 +35,7 @@ import {
} from '@anthropic-ai/claude-agent-sdk';
import * as fs from 'fs';
import * as path from 'path';
import { resolveClaudeBinary as resolveClaudeBinaryShared } from '../../browse/src/claude-bin';
import { resolveClaudeBinary as resolveClaudeBinaryShared } from '../../lib/claude-bin';
import { hermeticChildEnv } from './hermetic-env';
import type { SkillTestResult } from './session-runner';
+1 -1
View File
@@ -36,7 +36,7 @@ import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { promotedEnv } from '../../lib/conductor-env-shim';
import { isProcessAlive, safeUnlink } from '../../browse/src/error-handling';
import { isProcessAlive, safeUnlink } from '../../lib/error-handling';
import { skillCensus, frontmatterName } from './skill-census';
/** Exact env names a hermetic child keeps. Everything not listed (or matched
+1 -1
View File
@@ -4,7 +4,7 @@ import { execFileSync, spawnSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { resolveClaudeCommand } from '../../../browse/src/claude-bin';
import { resolveClaudeCommand } from '../../../lib/claude-bin';
/**
* Claude adapter — wraps the `claude` CLI via claude -p.