W6: demo + visual artifacts, verifying builders
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
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<DemoRunResult>;
|
||||
/** Rasterize an SVG to a PNG buffer (for verify-by-looking). */
|
||||
renderSvg(svg: string, opts?: RenderSvgOptions): Promise<Buffer>;
|
||||
}
|
||||
|
||||
/** 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<import("puppeteer-core").Browser> {
|
||||
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<DemoRunResult> {
|
||||
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<Buffer> {
|
||||
const dir = tempDir("lh-visual-");
|
||||
const file = path.join(dir, "figure.html");
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
`<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0;padding:0;background:#fff}svg{display:block}</style></head><body>${svg}</body></html>`,
|
||||
"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,
|
||||
};
|
||||
Reference in New Issue
Block a user