import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { pathToFileURL } from "node:url"; /** * Shared headless-browser helper for the verifying sub-agents (W6): * the demo-builder executes demos and captures errors; the visual-maker * rasterizes SVGs so it can look at them. Both use the SAME system Chrome * via puppeteer-core — NO bundled chromium is downloaded. * * Chrome resolution: process.env.CHROME_PATH first, then the common * macOS/Linux install paths. If none exists we fall back to puppeteer's * `channel: "chrome"` at launch time; `available()` is cheap and only knows * about the explicit candidates, so a channel-only setup reports unavailable * and the builders refuse to run (the house rule: never skip testing). * * Test seam: `headlessOverride.current` replaces the whole API (tests inject * a fake; nothing here touches a real browser). Builders call `getHeadless()`. */ export interface DemoRunResult { /** console.error / console messages of type "error". */ consoleErrors: string[]; /** Uncaught exceptions (window.onerror / unhandled rejections). */ pageErrors: string[]; /** Network requests that failed (CDN scripts that didn't load, …). */ failedRequests: string[]; /** Value of the optional probe expression, when requested and successful. */ probeResult?: unknown; /** True when nothing above recorded anything. */ ok: boolean; } export interface RunDemoOptions { /** How long to let the page settle (animations, async CDN loads) — default 2s. */ settleMs?: number; /** Optional JS expression evaluated in the page after settling; its value * is returned to the builder so it can assert on internal state. */ probe?: string; } export interface RenderSvgOptions { /** Viewport size — defaults to the SVG's width/height attrs, else 900×700. */ width?: number; height?: number; } export interface HeadlessApi { /** True when a Chrome executable is resolvable (cheap, no launch). */ available(): boolean; /** Why not — for error messages. */ unavailableReason(): string | null; /** Load the HTML in headless Chrome, capture errors, optionally probe. */ runDemo(html: string, opts?: RunDemoOptions): Promise; /** Rasterize an SVG to a PNG buffer (for verify-by-looking). */ renderSvg(svg: string, opts?: RenderSvgOptions): Promise; } /** Test seam — undefined in production. */ export const headlessOverride: { current?: HeadlessApi } = {}; export function getHeadless(): HeadlessApi { return headlessOverride.current ?? realHeadless; } // ─── Chrome resolution ─────────────────────────────────────────────────────── const CHROME_CANDIDATES = [ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", "/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser", ]; export function resolveChromeExecutablePath(): string | null { const candidates = [process.env.CHROME_PATH, ...CHROME_CANDIDATES].filter( (p): p is string => typeof p === "string" && p.length > 0, ); for (const candidate of candidates) { try { if (fs.existsSync(candidate)) return candidate; } catch { /* not readable — try the next */ } } return null; } const NO_CHROME_MESSAGE = "No Chrome/Chromium found for headless testing. Install Google Chrome or set CHROME_PATH to a Chrome executable. " + "(Verification is mandatory — demos and visuals are never delivered untested.)"; async function launchBrowser(): Promise { const puppeteer = (await import("puppeteer-core")).default; const executablePath = resolveChromeExecutablePath(); const browser = await puppeteer.launch({ headless: true, // Explicit path when we found one; otherwise let puppeteer resolve the // system Chrome install itself via the "chrome" channel. ...(executablePath ? { executablePath } : { channel: "chrome" as const }), args: ["--disable-gpu", "--no-sandbox", "--disable-dev-shm-usage"], }); return browser; } /** A temp dir per invocation; removed in finally. */ function tempDir(prefix: string): string { return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); } // ─── Demo execution ────────────────────────────────────────────────────────── async function runDemo(html: string, opts: RunDemoOptions = {}): Promise { const settleMs = opts.settleMs ?? 2000; const dir = tempDir("lh-demo-"); const file = path.join(dir, "demo.html"); fs.writeFileSync(file, html, "utf-8"); const consoleErrors: string[] = []; const pageErrors: string[] = []; const failedRequests: string[] = []; let probeResult: unknown; let probed = false; const browser = await launchBrowser(); try { const page = await browser.newPage(); page.on("console", (msg) => { if (msg.type() === "error") consoleErrors.push(msg.text()); }); page.on("pageerror", (err) => pageErrors.push(err instanceof Error ? err.message : String(err))); page.on("requestfailed", (req) => failedRequests.push(`${req.url()} (${req.failure()?.errorText ?? "failed"})`)); try { await page.goto(pathToFileURL(file).href, { waitUntil: "load", timeout: 30_000 }); await new Promise((resolve) => setTimeout(resolve, settleMs)); if (opts.probe) { try { probeResult = await page.evaluate(opts.probe); } catch (err) { pageErrors.push(`probe failed: ${err instanceof Error ? err.message : String(err)}`); } probed = true; } } catch (err) { pageErrors.push(err instanceof Error ? err.message : String(err)); } } finally { await browser.close().catch(() => {}); try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ } } return { consoleErrors, pageErrors, failedRequests, ...(probed ? { probeResult } : {}), ok: consoleErrors.length === 0 && pageErrors.length === 0 && failedRequests.length === 0, }; } // ─── SVG rasterization ─────────────────────────────────────────────────────── function svgViewport(svg: string, opts: RenderSvgOptions): { width: number; height: number } { if (opts.width && opts.height) return { width: opts.width, height: opts.height }; const width = Number(svg.match(/\bwidth\s*=\s*"?(\d+(?:\.\d+)?)/)?.[1]); const height = Number(svg.match(/\bheight\s*=\s*"?(\d+(?:\.\d+)?)/)?.[1]); return { width: Math.ceil(Number.isFinite(width) && width > 0 ? width : 900), height: Math.ceil(Number.isFinite(height) && height > 0 ? height : 700), }; } async function renderSvg(svg: string, opts: RenderSvgOptions = {}): Promise { const dir = tempDir("lh-visual-"); const file = path.join(dir, "figure.html"); fs.writeFileSync( file, `${svg}`, "utf-8", ); const browser = await launchBrowser(); try { const page = await browser.newPage(); const { width, height } = svgViewport(svg, opts); await page.setViewport({ width, height }); await page.goto(pathToFileURL(file).href, { waitUntil: "load", timeout: 30_000 }); const png = (await page.screenshot({ fullPage: true, type: "png" })) as Buffer; return png; } finally { await browser.close().catch(() => {}); try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ } } } // ─── the real API ──────────────────────────────────────────────────────────── export const realHeadless: HeadlessApi = { available() { return resolveChromeExecutablePath() !== null; }, unavailableReason() { return resolveChromeExecutablePath() === null ? NO_CHROME_MESSAGE : null; }, runDemo, renderSvg, };