#!/usr/bin/env node/** * Screenshots of the mock-backend page (docs/driving-the-app.md) over a grid of * scenes × themes × languages × widths, for PR images and visual QA. One shared * driver, so a task does not write its own, and a bounded one: every shot has a * deadline and so does the whole run, so a hung page or browser fails loudly * instead of holding a pueue slot. * * Usage: node scripts/shoot.mjs --out DIR [options] * --out DIR where the PNGs go (created); keep it outside the checkout, * which the dev server watches * --url URL shoot a page already served (`pnpm perf:bench --serve`); * without it, this serves its own and stops it at the end * --dev serve with the dev server (no production build) * --scenes FILE a module whose default export maps scene names to scenes * (below); without it the one scene is the page as it opens * --only a,b shoot only these scenes * --query Q a URL query each scene is shot with, such as * 'build=failed' (repeatable: every query is its own row) * --theme a,b light, dark or system (default light,dark) * --lang a,b en, zh-CN or system (default en) * --locale a,b the browser's language, which lang=system follows * --width a,b viewport widths in CSS px (default 1440) * --height N viewport height (default 900) * --scale N device pixel ratio (default 2) * --engine NAME chromium (default) or webkit (Playwright's WebKit, * closer to the WKWebView release builds render in) * --ready SELECTOR what to wait for before a scene runs (default: the * fixture's root document in the editor; the welcome * screen of `--query welcome=1` needs `.welcome-screen`) * --settle MS quiet time after the page is ready and after the scene (default 500) * --shot-timeout S the most one shot may take, load included (default 60) * --timeout S the most the whole run may take, build included (default 600) * --headful show the browser * * A scene is `async (page, shot) => {}`, run on a Playwright page after the app * is ready, or an object { run, query, ready, selector }: `query` is added to * the URL, `ready` overrides --ready, and `selector` screenshots one element * instead of the viewport. `shot` holds the cell (theme, lang, locale, width, * height, engine, url) and `shot.capture(label)`, which writes an extra image * mid-scene. For example: * * export default { * palette: async (page) => { await page.keyboard.press("Meta+Shift+P"); }, * failed: { query: "build=failed", run: async (page) => {} }, * }; * * Files are named <scene>[-<query>]-<theme>-<lang>[-<locale>]-<width>[-webkit].png. * The first failed shot stops the run with exit 1, printing its error, the * page's console errors and failed requests, and a screenshot of the failure. * The overall timeout exits 124 with what was running. */import { spawn } from "node:child_process";import { mkdirSync } from "node:fs";import path from "node:path";import { setTimeout as sleep } from "node:timers/promises";import { fileURLToPath, pathToFileURL, URLSearchParams } from "node:url";import { chromium, webkit } from "playwright-core";import { launchChrome } from "./perf-bench/cdp.mjs";import { APP_READY } from "./perf-bench/selectors.mjs";import { exitOnSignals, onShutdown, once } from "./perf-bench/shutdown.mjs"; const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");const list = (value) => value.split(",").map((item) => item.trim()).filter(Boolean); function parseArgs(argv) { const options = { out: null, url: null, dev: false, scenes: null, only: null, queries: [], themes: ["light", "dark"], langs: ["en"], locales: [null], widths: [1440], height: 900, scale: 2, engine: "chromium", ready: null, settle: 500, shotTimeout: 60, timeout: 600, headful: false, }; const number = (arg, value) => { const parsed = Number(value); if (!Number.isFinite(parsed) || parsed <= 0) throw new Error(`${arg} takes a positive number`); return parsed; }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; const value = () => { const next = argv[++index]; if (next === undefined) throw new Error(`${arg} takes a value`); return next; }; if (arg === "--out") options.out = path.resolve(value()); else if (arg === "--url") options.url = value(); else if (arg === "--dev") options.dev = true; else if (arg === "--scenes") options.scenes = path.resolve(value()); else if (arg === "--only") options.only = list(value()); else if (arg === "--query") options.queries.push(value().replace(/^\?/, "")); else if (arg === "--theme") options.themes = list(value()); else if (arg === "--lang") options.langs = list(value()); else if (arg === "--locale") options.locales = list(value()); else if (arg === "--width") options.widths = list(value()).map((width) => number(arg, width)); else if (arg === "--height") options.height = number(arg, value()); else if (arg === "--scale") options.scale = number(arg, value()); else if (arg === "--engine") options.engine = value(); else if (arg === "--ready") options.ready = value(); else if (arg === "--settle") options.settle = Number(value()); else if (arg === "--shot-timeout") options.shotTimeout = number(arg, value()); else if (arg === "--timeout") options.timeout = number(arg, value()); else if (arg === "--headful") options.headful = true; else throw new Error(`Unknown option ${arg}`); } if (!options.out) throw new Error("--out DIR is required"); if (options.url && options.dev) throw new Error("--dev serves its own page; it cannot take --url"); if (!["chromium", "webkit"].includes(options.engine)) throw new Error("--engine takes chromium or webkit"); for (const theme of options.themes) if (!["light", "dark", "system"].includes(theme)) throw new Error(`Unknown theme ${theme}`); for (const lang of options.langs) if (!["en", "zh-CN", "system"].includes(lang)) throw new Error(`Unknown lang ${lang}`); if (!Number.isFinite(options.settle) || options.settle < 0) throw new Error("--settle takes milliseconds"); return options;} async function loadScenes(options) { const loaded = options.scenes ? (await import(pathToFileURL(options.scenes).href)).default : { page: async () => {} }; if (!loaded || typeof loaded !== "object") throw new Error(`${options.scenes} must default-export an object of scenes`); const scenes = Object.entries(loaded).map(([name, scene]) => ({ name, ...(typeof scene === "function" ? { run: scene } : scene) })); for (const scene of scenes) if (scene.run && typeof scene.run !== "function") throw new Error(`Scene ${scene.name}: run must be a function`); const kept = options.only ? scenes.filter((scene) => options.only.includes(scene.name)) : scenes; if (!kept.length) throw new Error(`No scene matches ${options.only ?? "(the scenes file is empty)"}`); return kept;} /** Every cell of the grid, in the order shot: scene by scene, so one scene's variants sit together in the output. */function grid(options, scenes) { const slug = (query) => query.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, ""); const cells = []; for (const scene of scenes) for (const query of options.queries.length ? options.queries : [""]) for (const theme of options.themes) for (const lang of options.langs) for (const locale of options.locales) for (const width of options.widths) { const name = [scene.name, slug(query), theme, lang, locale, width, options.engine === "webkit" ? "webkit" : null].filter(Boolean).join("-"); cells.push({ name, scene, query, theme, lang, locale, width, height: options.height, engine: options.engine }); } const seen = new Set(); for (const { name } of cells) { if (seen.has(name)) throw new Error(`Two shots would both write ${name}.png: give the scenes or queries distinct names`); seen.add(name); } return cells;} function cellUrl(base, cell) { const url = new URL(base); url.searchParams.set("theme", cell.theme); url.searchParams.set("lang", cell.lang); for (const query of [cell.query, cell.scene.query ?? ""]) { for (const [key, value] of new URLSearchParams(query)) url.searchParams.set(key, value); } return url.toString();} /** * Resolves with `promise`, or rejects once `ms` pass; never leaves a timer * holding the process open. `what` may be a function, read when time runs out. */function deadline(promise, ms, what) { let timer; const expired = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`${typeof what === "function" ? what() : what} took over ${ms / 1000} s`)), ms); }); return Promise.race([promise, expired]).finally(() => clearTimeout(timer));} /** * `pnpm perf:bench --serve` as a child: its build progress goes to our * stderr, and the URL it prints once the page answers is the base of every * shot. Stopped with SIGTERM, which lets it remove its build directory. */function servePage(options) { const args = [path.join(repo, "scripts/perf-bench.mjs"), "--serve", "--port", "0", ...(options.dev ? ["--dev"] : [])]; const child = spawn(process.execPath, args, { cwd: repo, stdio: ["ignore", "pipe", "inherit"] }); const gone = new Promise((resolve) => child.once("exit", resolve)); const stop = once(async () => { if (child.exitCode !== null || child.signalCode !== null) return; child.kill("SIGTERM"); // perf-bench's own grace for cleanup is 15 s. if ((await Promise.race([gone.then(() => true), sleep(20_000, false)])) === false) child.kill("SIGKILL"); }); const unregister = onShutdown(stop); process.on("exit", () => child.exitCode === null && child.signalCode === null && child.kill("SIGKILL")); const url = new Promise((resolve, reject) => { let output = ""; child.stdout.on("data", (chunk) => { output += chunk; const match = output.match(/^\s+(http:\/\/\S+)$/m); if (match) resolve(match[1]); }); gone.then((code) => reject(new Error(`perf-bench --serve exited (${code}) before printing its URL:\n${output}`))); }); return { url, stop: () => stop().finally(unregister) };} /** A browser that gives each shot its own context: theme, language and storage never leak between shots. */async function openBrowser(options) { if (options.engine === "webkit") { const browser = await webkit.launch({ headless: !options.headful }); return { browser, close: () => browser.close() }; } // perf-bench's Chrome finder, which falls back to Playwright's Chrome for // Testing where Google Chrome is not installed, and removes its profile. const chrome = await launchChrome({ headless: !options.headful }); const browser = await chromium.connectOverCDP(chrome.endpoint); return { browser, close: () => browser.close().catch(() => {}).finally(() => chrome.close()) };} /** Waits for `ready` (a CSS selector, or a function of the page), then for fonts, two frames and `settle` ms of quiet. */async function settle(page, ready, settleMs) { if (typeof ready === "function") await ready(page); else if (ready) await page.waitForSelector(ready, { state: "visible", timeout: 0 }); else await page.waitForFunction(APP_READY, null, { timeout: 0, polling: 100 }); await page.evaluate("document.fonts.ready.then(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))))"); await page.waitForTimeout(settleMs);} async function shoot(cell, { browser, base, options, setStep }) { const context = await browser.newContext({ viewport: { width: cell.width, height: cell.height }, deviceScaleFactor: options.scale, ...(cell.locale ? { locale: cell.locale } : {}), }); const page = await context.newPage(); const problems = []; page.on("console", (message) => message.type() === "error" && problems.push(`[console] ${message.text()}`)); page.on("pageerror", (error) => problems.push(`[exception] ${error.message}`)); page.on("requestfailed", (request) => problems.push(`[request failed] ${request.failure()?.errorText ?? ""} ${request.url()}`)); page.on("response", (response) => response.status() >= 400 && problems.push(`[request ${response.status()}] ${response.url()}`)); const url = cellUrl(base, cell); const file = (label) => path.join(options.out, `${cell.name}${label ? `~${label}` : ""}.png`); let stage = "starting"; const at = (next) => { stage = next; setStep(`${cell.name}: ${next}`); }; const work = async () => { at("loading"); await page.goto(url, { waitUntil: "load", timeout: 0 }); const ready = cell.scene.ready ?? options.ready; at(`waiting for ${typeof ready === "function" ? "the scene's ready()" : ready ?? "the app to open the fixture project"}`); await settle(page, ready, options.settle); at("running the scene"); const shot = { ...cell, scene: cell.scene.name, url, capture: async (label) => page.screenshot({ path: file(label), animations: "disabled", caret: "hide" }) }; await cell.scene.run?.(page, shot); await page.waitForTimeout(options.settle); at("capturing"); const target = cell.scene.selector ? page.locator(cell.scene.selector).first() : page; await target.screenshot({ path: file(), animations: "disabled", caret: "hide", timeout: 0 }); }; try { await deadline(work(), options.shotTimeout * 1000, () => `the shot ${cell.name} (${stage})`); console.log(`ok ${file()}`); } catch (error) { console.log(`FAILED ${cell.name}: ${error.message.split("\n")[0]}`); console.log(` Page: ${url}`); console.log(` Console errors and failed requests:\n${problems.length ? problems.slice(0, 30).map((line) => ` ${line.slice(0, 1_000)}`).join("\n") : " (none)"}`); const failed = path.join(options.out, `${cell.name}.FAILED.png`); if (await deadline(page.screenshot({ path: failed, timeout: 0 }), 5_000, "the failure screenshot").then(() => true, () => false)) console.log(` Screenshot: ${failed}`); throw error; } finally { // A page stuck in a busy loop can hold close() too. await deadline(context.close(), 10_000, "closing the shot's page").catch(() => {}); }} async function main() { const options = parseArgs(process.argv.slice(2)); const scenes = await loadScenes(options); const cells = grid(options, scenes); mkdirSync(options.out, { recursive: true }); let step = "starting"; const setStep = (next) => (step = next); const resources = []; const cleanup = once(() => Promise.all(resources.map((close) => deadline(Promise.resolve().then(close), 20_000, "cleanup").catch(() => {})))); exitOnSignals({ describe: (signal) => `shoot: ${signal} while ${step}` }); // The hard stop: whatever hangs (a build, a browser that never answers, a // scene awaiting something that never comes), the run ends here. const hardStop = setTimeout(() => { console.error(`shoot: the run took over ${options.timeout} s, stopping while ${step}`); cleanup().finally(() => process.exit(124)); }, options.timeout * 1000); try { let base = options.url; if (!base) { setStep("serving the bench page"); const served = servePage(options); resources.push(served.stop); base = await served.url; } setStep(`starting ${options.engine}`); const browser = await openBrowser(options); resources.unshift(browser.close); console.error(`shoot: ${cells.length} shot${cells.length === 1 ? "" : "s"} into ${options.out}`); for (const cell of cells) await shoot(cell, { browser: browser.browser, base, options, setStep }); console.log(`Done: ${cells.length} shot${cells.length === 1 ? "" : "s"} in ${options.out}`); } catch (error) { console.error(`shoot: ${error.message.split("\n")[0]}`); process.exitCode = 1; } finally { setStep("cleaning up"); await cleanup(); clearTimeout(hardStop); }} await main().catch((error) => { console.error(`shoot: ${error.message}`); process.exit(1);});
#!/usr/bin/env node
/**
* Screenshots of the mock-backend page (docs/driving-the-app.md) over a grid of
* scenes × themes × languages × widths, for PR images and visual QA. One shared
* driver, so a task does not write its own, and a bounded one: every shot has a
* deadline and so does the whole run, so a hung page or browser fails loudly
* instead of holding a pueue slot.
*
* Usage: node scripts/shoot.mjs --out DIR [options]
* --out DIR where the PNGs go (created); keep it outside the checkout,
* which the dev server watches
* --url URL shoot a page already served (`pnpm perf:bench --serve`);
* without it, this serves its own and stops it at the end
* --dev serve with the dev server (no production build)
* --scenes FILE a module whose default export maps scene names to scenes
* (below); without it the one scene is the page as it opens
* --only a,b shoot only these scenes
* --query Q a URL query each scene is shot with, such as
* 'build=failed' (repeatable: every query is its own row)
* --theme a,b light, dark or system (default light,dark)
* --lang a,b en, zh-CN or system (default en)
* --locale a,b the browser's language, which lang=system follows
* --width a,b viewport widths in CSS px (default 1440)
* --height N viewport height (default 900)
* --scale N device pixel ratio (default 2)
* --engine NAME chromium (default) or webkit (Playwright's WebKit,
* closer to the WKWebView release builds render in)
* --ready SELECTOR what to wait for before a scene runs (default: the
* fixture's root document in the editor; the welcome
* screen of `--query welcome=1` needs `.welcome-screen`)
* --settle MS quiet time after the page is ready and after the scene (default 500)
* --shot-timeout S the most one shot may take, load included (default 60)
* --timeout S the most the whole run may take, build included (default 600)
* --headful show the browser
*
* A scene is `async (page, shot) => {}`, run on a Playwright page after the app
* is ready, or an object { run, query, ready, selector }: `query` is added to
* the URL, `ready` overrides --ready, and `selector` screenshots one element
* instead of the viewport. `shot` holds the cell (theme, lang, locale, width,
* height, engine, url) and `shot.capture(label)`, which writes an extra image
* mid-scene. For example:
*
* export default {
* palette: async (page) => { await page.keyboard.press("Meta+Shift+P"); },
* failed: { query: "build=failed", run: async (page) => {} },
* };
*
* Files are named <scene>[-<query>]-<theme>-<lang>[-<locale>]-<width>[-webkit].png.
* The first failed shot stops the run with exit 1, printing its error, the
* page's console errors and failed requests, and a screenshot of the failure.
* The overall timeout exits 124 with what was running.
*/
import { spawn } from "node:child_process";
import { mkdirSync } from "node:fs";
import path from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
import { fileURLToPath, pathToFileURL, URLSearchParams } from "node:url";
import { chromium, webkit } from "playwright-core";
import { launchChrome } from "./perf-bench/cdp.mjs";
import { APP_READY } from "./perf-bench/selectors.mjs";
import { exitOnSignals, onShutdown, once } from "./perf-bench/shutdown.mjs";
const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const list = (value) => value.split(",").map((item) => item.trim()).filter(Boolean);
function parseArgs(argv) {
const options = {
out: null, url: null, dev: false, scenes: null, only: null, queries: [], themes: ["light", "dark"], langs: ["en"], locales: [null],
widths: [1440], height: 900, scale: 2, engine: "chromium", ready: null, settle: 500, shotTimeout: 60, timeout: 600, headful: false,
};
const number = (arg, value) => {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) throw new Error(`${arg} takes a positive number`);
return parsed;
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
const value = () => {
const next = argv[++index];
if (next === undefined) throw new Error(`${arg} takes a value`);
return next;
};
if (arg === "--out") options.out = path.resolve(value());
else if (arg === "--url") options.url = value();
else if (arg === "--dev") options.dev = true;
else if (arg === "--scenes") options.scenes = path.resolve(value());
else if (arg === "--only") options.only = list(value());
else if (arg === "--query") options.queries.push(value().replace(/^\?/, ""));
else if (arg === "--theme") options.themes = list(value());
else if (arg === "--lang") options.langs = list(value());
else if (arg === "--locale") options.locales = list(value());
else if (arg === "--width") options.widths = list(value()).map((width) => number(arg, width));
else if (arg === "--height") options.height = number(arg, value());
else if (arg === "--scale") options.scale = number(arg, value());
else if (arg === "--engine") options.engine = value();
else if (arg === "--ready") options.ready = value();
else if (arg === "--settle") options.settle = Number(value());
else if (arg === "--shot-timeout") options.shotTimeout = number(arg, value());
else if (arg === "--timeout") options.timeout = number(arg, value());
else if (arg === "--headful") options.headful = true;
else throw new Error(`Unknown option ${arg}`);
}
if (!options.out) throw new Error("--out DIR is required");
if (options.url && options.dev) throw new Error("--dev serves its own page; it cannot take --url");
if (!["chromium", "webkit"].includes(options.engine)) throw new Error("--engine takes chromium or webkit");
for (const theme of options.themes) if (!["light", "dark", "system"].includes(theme)) throw new Error(`Unknown theme ${theme}`);
for (const lang of options.langs) if (!["en", "zh-CN", "system"].includes(lang)) throw new Error(`Unknown lang ${lang}`);
if (!Number.isFinite(options.settle) || options.settle < 0) throw new Error("--settle takes milliseconds");
return options;
}
async function loadScenes(options) {
const loaded = options.scenes ? (await import(pathToFileURL(options.scenes).href)).default : { page: async () => {} };
if (!loaded || typeof loaded !== "object") throw new Error(`${options.scenes} must default-export an object of scenes`);
const scenes = Object.entries(loaded).map(([name, scene]) => ({ name, ...(typeof scene === "function" ? { run: scene } : scene) }));
for (const scene of scenes) if (scene.run && typeof scene.run !== "function") throw new Error(`Scene ${scene.name}: run must be a function`);
const kept = options.only ? scenes.filter((scene) => options.only.includes(scene.name)) : scenes;
if (!kept.length) throw new Error(`No scene matches ${options.only ?? "(the scenes file is empty)"}`);
return kept;
}
/** Every cell of the grid, in the order shot: scene by scene, so one scene's variants sit together in the output. */
function grid(options, scenes) {
const slug = (query) => query.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "");
const cells = [];
for (const scene of scenes) for (const query of options.queries.length ? options.queries : [""])
for (const theme of options.themes) for (const lang of options.langs) for (const locale of options.locales) for (const width of options.widths) {
const name = [scene.name, slug(query), theme, lang, locale, width, options.engine === "webkit" ? "webkit" : null].filter(Boolean).join("-");
cells.push({ name, scene, query, theme, lang, locale, width, height: options.height, engine: options.engine });
}
const seen = new Set();
for (const { name } of cells) {
if (seen.has(name)) throw new Error(`Two shots would both write ${name}.png: give the scenes or queries distinct names`);
seen.add(name);
}
return cells;
}
function cellUrl(base, cell) {
const url = new URL(base);
url.searchParams.set("theme", cell.theme);
url.searchParams.set("lang", cell.lang);
for (const query of [cell.query, cell.scene.query ?? ""]) {
for (const [key, value] of new URLSearchParams(query)) url.searchParams.set(key, value);
}
return url.toString();
}
/**
* Resolves with `promise`, or rejects once `ms` pass; never leaves a timer
* holding the process open. `what` may be a function, read when time runs out.
*/
function deadline(promise, ms, what) {
let timer;
const expired = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`${typeof what === "function" ? what() : what} took over ${ms / 1000} s`)), ms);
});
return Promise.race([promise, expired]).finally(() => clearTimeout(timer));
}
/**
* `pnpm perf:bench --serve` as a child: its build progress goes to our
* stderr, and the URL it prints once the page answers is the base of every
* shot. Stopped with SIGTERM, which lets it remove its build directory.
*/
function servePage(options) {
const args = [path.join(repo, "scripts/perf-bench.mjs"), "--serve", "--port", "0", ...(options.dev ? ["--dev"] : [])];
const child = spawn(process.execPath, args, { cwd: repo, stdio: ["ignore", "pipe", "inherit"] });
const gone = new Promise((resolve) => child.once("exit", resolve));
const stop = once(async () => {
if (child.exitCode !== null || child.signalCode !== null) return;
child.kill("SIGTERM");
// perf-bench's own grace for cleanup is 15 s.
if ((await Promise.race([gone.then(() => true), sleep(20_000, false)])) === false) child.kill("SIGKILL");
});
const unregister = onShutdown(stop);
process.on("exit", () => child.exitCode === null && child.signalCode === null && child.kill("SIGKILL"));
const url = new Promise((resolve, reject) => {
let output = "";
child.stdout.on("data", (chunk) => {
output += chunk;
const match = output.match(/^\s+(http:\/\/\S+)$/m);
if (match) resolve(match[1]);
});
gone.then((code) => reject(new Error(`perf-bench --serve exited (${code}) before printing its URL:\n${output}`)));
});
return { url, stop: () => stop().finally(unregister) };
}
/** A browser that gives each shot its own context: theme, language and storage never leak between shots. */
async function openBrowser(options) {
if (options.engine === "webkit") {
const browser = await webkit.launch({ headless: !options.headful });
return { browser, close: () => browser.close() };
}
// perf-bench's Chrome finder, which falls back to Playwright's Chrome for
// Testing where Google Chrome is not installed, and removes its profile.
const chrome = await launchChrome({ headless: !options.headful });
const browser = await chromium.connectOverCDP(chrome.endpoint);
return { browser, close: () => browser.close().catch(() => {}).finally(() => chrome.close()) };
}
/** Waits for `ready` (a CSS selector, or a function of the page), then for fonts, two frames and `settle` ms of quiet. */
async function settle(page, ready, settleMs) {
if (typeof ready === "function") await ready(page);
else if (ready) await page.waitForSelector(ready, { state: "visible", timeout: 0 });
else await page.waitForFunction(APP_READY, null, { timeout: 0, polling: 100 });
await page.evaluate("document.fonts.ready.then(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))))");
await page.waitForTimeout(settleMs);
}
async function shoot(cell, { browser, base, options, setStep }) {
const context = await browser.newContext({
viewport: { width: cell.width, height: cell.height },
deviceScaleFactor: options.scale,
...(cell.locale ? { locale: cell.locale } : {}),
});
const page = await context.newPage();
const problems = [];
page.on("console", (message) => message.type() === "error" && problems.push(`[console] ${message.text()}`));
page.on("pageerror", (error) => problems.push(`[exception] ${error.message}`));
page.on("requestfailed", (request) => problems.push(`[request failed] ${request.failure()?.errorText ?? ""} ${request.url()}`));
page.on("response", (response) => response.status() >= 400 && problems.push(`[request ${response.status()}] ${response.url()}`));
const url = cellUrl(base, cell);
const file = (label) => path.join(options.out, `${cell.name}${label ? `~${label}` : ""}.png`);
let stage = "starting";
const at = (next) => {
stage = next;
setStep(`${cell.name}: ${next}`);
};
const work = async () => {
at("loading");
await page.goto(url, { waitUntil: "load", timeout: 0 });
const ready = cell.scene.ready ?? options.ready;
at(`waiting for ${typeof ready === "function" ? "the scene's ready()" : ready ?? "the app to open the fixture project"}`);
await settle(page, ready, options.settle);
at("running the scene");
const shot = { ...cell, scene: cell.scene.name, url, capture: async (label) => page.screenshot({ path: file(label), animations: "disabled", caret: "hide" }) };
await cell.scene.run?.(page, shot);
await page.waitForTimeout(options.settle);
at("capturing");
const target = cell.scene.selector ? page.locator(cell.scene.selector).first() : page;
await target.screenshot({ path: file(), animations: "disabled", caret: "hide", timeout: 0 });
};
try {
await deadline(work(), options.shotTimeout * 1000, () => `the shot ${cell.name} (${stage})`);
console.log(`ok ${file()}`);
} catch (error) {
console.log(`FAILED ${cell.name}: ${error.message.split("\n")[0]}`);
console.log(` Page: ${url}`);
console.log(` Console errors and failed requests:\n${problems.length ? problems.slice(0, 30).map((line) => ` ${line.slice(0, 1_000)}`).join("\n") : " (none)"}`);
const failed = path.join(options.out, `${cell.name}.FAILED.png`);
if (await deadline(page.screenshot({ path: failed, timeout: 0 }), 5_000, "the failure screenshot").then(() => true, () => false)) console.log(` Screenshot: ${failed}`);
throw error;
} finally {
// A page stuck in a busy loop can hold close() too.
await deadline(context.close(), 10_000, "closing the shot's page").catch(() => {});
}
}
async function main() {
const options = parseArgs(process.argv.slice(2));
const scenes = await loadScenes(options);
const cells = grid(options, scenes);
mkdirSync(options.out, { recursive: true });
let step = "starting";
const setStep = (next) => (step = next);
const resources = [];
const cleanup = once(() => Promise.all(resources.map((close) => deadline(Promise.resolve().then(close), 20_000, "cleanup").catch(() => {}))));
exitOnSignals({ describe: (signal) => `shoot: ${signal} while ${step}` });
// The hard stop: whatever hangs (a build, a browser that never answers, a
// scene awaiting something that never comes), the run ends here.
const hardStop = setTimeout(() => {
console.error(`shoot: the run took over ${options.timeout} s, stopping while ${step}`);
cleanup().finally(() => process.exit(124));
}, options.timeout * 1000);
try {
let base = options.url;
if (!base) {
setStep("serving the bench page");
const served = servePage(options);
resources.push(served.stop);
base = await served.url;
}
setStep(`starting ${options.engine}`);
const browser = await openBrowser(options);
resources.unshift(browser.close);
console.error(`shoot: ${cells.length} shot${cells.length === 1 ? "" : "s"} into ${options.out}`);
for (const cell of cells) await shoot(cell, { browser: browser.browser, base, options, setStep });
console.log(`Done: ${cells.length} shot${cells.length === 1 ? "" : "s"} in ${options.out}`);
} catch (error) {
console.error(`shoot: ${error.message.split("\n")[0]}`);
process.exitCode = 1;
} finally {
setStep("cleaning up");
await cleanup();
clearTimeout(hardStop);
}
}
await main().catch((error) => {
console.error(`shoot: ${error.message}`);
process.exit(1);
});