Add capture-proof for sharp Playwright proof screenshots

This commit is contained in:
Gabriel Brown
2026-09-22 15:18:38 -04:00
parent 88032490c0
commit de3591a25b
2 changed files with 144 additions and 0 deletions
@@ -0,0 +1,128 @@
// capture-proof: sharp, repeatable screenshots of a running app for a ticket's proof/,
// taken with Playwright instead of a screen-capture tool. Run through the `capture-proof`
// wrapper, which installs a pinned Playwright into a cache on first use.
//
// capture-proof login <url> sign in once in a visible browser, save the session
// capture-proof shot <url> --out <file.png> take a screenshot with the saved session
//
// Sessions live under ~/.cache/ticket-page/auth/, one per origin, readable only by you.
// They are never written inside a repository.
import { chmodSync, existsSync, mkdirSync } from "node:fs";
import { createRequire } from "node:module";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import type { Browser, BrowserContextOptions, Page } from "playwright";
const cache = join(process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), "ticket-page");
const { chromium } = createRequire(join(cache, "playwright", "package.json"))("playwright") as typeof import("playwright");
// The Next.js dev badge and toast stacks sit over the content in dev builds.
const ALWAYS_HIDE = ["nextjs-portal", "[data-nextjs-toast]", "[data-sonner-toaster]"];
class UsageError extends Error {}
function fail(msg: string): never { throw new UsageError(msg); }
function authFile(url: string): string {
const u = new URL(url);
return join(cache, "auth", `${u.hostname}_${u.port || (u.protocol === "https:" ? "443" : "80")}.json`);
}
function parse(argv: string[]) {
const pos: string[] = [];
const opt = new Map<string, string>();
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (!a.startsWith("--")) { pos.push(a); continue; }
const next = argv[i + 1];
opt.set(a.slice(2), next === undefined || next.startsWith("--") ? "true" : (i++, next));
}
return { pos, opt };
}
/** Opens a visible browser at the app. Gib signs in and closes the window, and the session
* is saved for every later `shot` against the same origin. */
async function login(url: string) {
const browser = await chromium.launch({ headless: false });
const context = await browser.newContext({ viewport: null });
const page = await context.newPage();
await page.goto(url);
console.log("Sign in in the browser window, then close it. The session is saved when the window closes.");
await page.waitForEvent("close", { timeout: 0 });
const file = authFile(url);
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
await context.storageState({ path: file });
chmodSync(file, 0o600);
await browser.close();
console.log(`Saved the session for ${new URL(url).origin}`);
}
type Actions = (page: Page) => Promise<void>;
async function shot(url: string, opt: Map<string, string>) {
const out = resolve(opt.get("out") ?? fail("shot needs --out <file.png>"));
const file = authFile(url);
const context: BrowserContextOptions = {
viewport: { width: Number(opt.get("width") ?? 1440), height: Number(opt.get("height") ?? 900) },
deviceScaleFactor: Number(opt.get("scale") ?? 2),
colorScheme: opt.has("dark") ? "dark" : "light",
reducedMotion: "reduce",
...(existsSync(file) && !opt.has("anonymous") ? { storageState: file } : {}),
};
let browser: Browser | undefined;
try {
browser = await chromium.launch();
const page = await (await browser.newContext(context)).newPage();
await page.goto(url, { waitUntil: "networkidle" });
if (!opt.has("anonymous") && /sign-?in|login|auth\//i.test(new URL(page.url()).pathname)) {
fail(`${url} landed on ${page.url()}, a sign-in page. Run: capture-proof login ${new URL(url).origin}`);
}
const script = opt.get("script");
if (script) {
const mod: { default: Actions } = await import(pathToFileURL(resolve(script)).href);
await mod.default(page);
await page.waitForLoadState("networkidle");
}
// Network idle is not enough for content rendered on the client, which can still show
// a spinner. Wait for the thing the proof is about when it is named, then settle.
const waitFor = opt.get("wait-for");
if (waitFor) await page.locator(waitFor).first().waitFor({ state: "visible", timeout: 20_000 });
await page.waitForTimeout(Number(opt.get("settle") ?? 500));
await page.evaluate(() => document.fonts.ready);
const hide = [...ALWAYS_HIDE, ...(opt.get("hide")?.split(",") ?? [])].join(", ");
await page.addStyleTag({ content: `${hide} { visibility: hidden !important; }` });
const selector = opt.get("selector");
mkdirSync(dirname(out), { recursive: true });
const settings = { path: out, animations: "disabled", caret: "hide" } as const;
if (selector) await page.locator(selector).first().screenshot(settings);
else await page.screenshot({ ...settings, fullPage: opt.has("full") });
console.log(out);
} finally {
await browser?.close();
}
}
const HELP = `capture-proof login <url>
Opens a visible browser at <url>. Sign in, then close the window to save the session.
capture-proof shot <url> --out <file.png> [options]
--selector <css> capture one element instead of the viewport
--full capture the whole scrolling page
--wait-for <loc> wait until this is visible first: a CSS selector, or text=Some label
--settle <ms> pause after loading, 500 by default
--script <file.mjs> run \`export default async (page) => {...}\` first: clicks, typing, opening a dialog
--hide <css,css> hide more overlays, beyond the Next.js dev badge and toasts
--width 1440 --height 900 --scale 2 viewport and pixel density
--dark dark color scheme
--anonymous ignore the saved session, as a signed-out user`;
const [cmd, ...rest] = process.argv.slice(2);
const { pos, opt } = parse(rest);
try {
if (cmd === "login") await login(pos[0] ?? fail("login needs a url"));
else if (cmd === "shot") await shot(pos[0] ?? fail("shot needs a url"), opt);
else { console.log(HELP); process.exit(cmd && cmd !== "help" ? 2 : 0); }
} catch (err) {
console.error(`capture-proof ${cmd}: ${err instanceof Error ? err.message : err}`);
process.exit(err instanceof UsageError ? 2 : 1);
}