import { spawnSync } from "node:child_process"; import { createRequire } from "node:module"; import { dirname, join } from "node:path"; import { chromium, type Browser, type BrowserContext } from "playwright-core"; export interface ScreenshotOptions { url: string; size: { w: number; h: number }; deviceScaleFactor: number; outPath: string; authStateRef?: string; browser?: Browser; waitFor?: string[]; ignoreSelectors?: string[]; } export interface Readiness { fontsReady: boolean; imagesComplete: boolean; } /** Rectangle in CSS pixels. */ export interface Rect { x: number; y: number; width: number; height: number; } export interface ScreenshotResult { path: string; width: number; height: number; readiness: Readiness; ignoreRects: Rect[]; } export async function screenshotPage(opts: ScreenshotOptions): Promise { const w = Math.round(opts.size.w); const h = Math.round(opts.size.h); const ownsBrowser = !opts.browser; let browser: Browser | undefined = opts.browser; let context: BrowserContext | undefined; try { browser = browser ?? (await launchChromium()); context = await browser.newContext({ viewport: { width: w, height: h }, deviceScaleFactor: opts.deviceScaleFactor, reducedMotion: "reduce", ...(opts.authStateRef ? { storageState: opts.authStateRef } : {}), }); const page = await context.newPage(); try { await page.goto(opts.url, { waitUntil: "load" }); } catch (err) { const m = err instanceof Error ? err.message : String(err); if (/ERR_CONNECTION_REFUSED|ERR_NAME_NOT_RESOLVED|ERR_CONNECTION|ERR_SOCKET/i.test(m)) { throw new Error(`could not load ${opts.url} — is the server running?`); } throw err; } for (const sel of opts.waitFor ?? []) { try { await page.waitForSelector(sel, { state: "visible", timeout: 15000 }); } catch { throw new Error(`waitFor: selector "${sel}" did not appear within 15s`); } } const fontsReady = await withTimeout( page.evaluate(async () => { await document.fonts.ready; return document.fonts.status === "loaded"; }), 5000, false ); const imagesComplete = await withTimeout( page.evaluate(async () => { const imgs = Array.from(document.images); await Promise.all( imgs.map((img) => img.complete ? Promise.resolve() : new Promise((res) => { img.addEventListener("load", () => res(), { once: true }); img.addEventListener("error", () => res(), { once: true }); }) ) ); return imgs.every((img) => img.complete); }), 5000, false ); await page.waitForLoadState("networkidle", { timeout: 2000 }).catch(() => {}); await page.addStyleTag({ content: "*{animation:none!important;transition:none!important;caret-color:transparent!important}", }); const ignoreRects: Rect[] = []; for (const sel of opts.ignoreSelectors ?? []) { for (const handle of await page.$$(sel)) { const bb = await handle.boundingBox(); if (bb) ignoreRects.push({ x: bb.x, y: bb.y, width: bb.width, height: bb.height }); } } await page.screenshot({ path: opts.outPath, clip: { x: 0, y: 0, width: w, height: h }, }); return { path: opts.outPath, width: Math.round(w * opts.deviceScaleFactor), height: Math.round(h * opts.deviceScaleFactor), readiness: { fontsReady, imagesComplete }, ignoreRects, }; } finally { await context?.close(); if (ownsBrowser) await browser?.close(); } } /** Launch a Chromium instance callers can reuse across many screenshots. */ export async function launchBrowser(): Promise { return launchChromium(); } function withTimeout(promise: Promise, ms: number, fallback: T): Promise { return Promise.race([ promise.catch(() => fallback), new Promise((resolve) => setTimeout(() => resolve(fallback), ms)), ]); } async function launchChromium(): Promise { try { return await chromium.launch(); } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (!/Executable doesn.?t exist|playwright install|browserType\.launch/i.test(msg)) throw err; process.stderr.write("Chromium not found — downloading it once…\n"); installChromium(); return chromium.launch(); } } function installChromium(): void { const require = createRequire(import.meta.url); const cli = join(dirname(require.resolve("playwright-core/package.json")), "cli.js"); const res = spawnSync(process.execPath, [cli, "install", "chromium"], { stdio: "inherit" }); if (res.status !== 0) { throw new Error("automatic Chromium install failed — run: bunx playwright-core install chromium"); } }