diff --git a/user/agents/skills/ticket/scripts/capture-proof b/user/agents/skills/ticket/scripts/capture-proof new file mode 100755 index 0000000..9f5f8eb --- /dev/null +++ b/user/agents/skills/ticket/scripts/capture-proof @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Screenshots a running app for a ticket's proof with Playwright. See: capture-proof help +# The first run installs a pinned Playwright and its Chromium into ~/.cache/ticket-page. +set -euo pipefail +version=1.60.0 +here="$(dirname "$(readlink -f "$0")")" +pw="${XDG_CACHE_HOME:-$HOME/.cache}/ticket-page/playwright" +command -v node >/dev/null 2>&1 || { echo "capture-proof: node is not on PATH" >&2; exit 127; } +if [[ "$(node -p "try{require('$pw/node_modules/playwright/package.json').version}catch{''}" 2>/dev/null)" != "$version" ]]; then + echo "capture-proof: installing Playwright $version into $pw" >&2 + mkdir -p "$pw" + [[ -f "$pw/package.json" ]] || echo '{"private":true}' > "$pw/package.json" + (cd "$pw" && npm install --silent --no-audit --no-fund "playwright@$version") >&2 + node "$pw/node_modules/playwright/cli.js" install chromium >&2 +fi +exec node "$here/capture-proof.ts" "$@" diff --git a/user/agents/skills/ticket/scripts/capture-proof.ts b/user/agents/skills/ticket/scripts/capture-proof.ts new file mode 100644 index 0000000..4780ebd --- /dev/null +++ b/user/agents/skills/ticket/scripts/capture-proof.ts @@ -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 sign in once in a visible browser, save the session +// capture-proof shot --out 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(); + 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; + +async function shot(url: string, opt: Map) { + const out = resolve(opt.get("out") ?? fail("shot needs --out ")); + 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 + Opens a visible browser at . Sign in, then close the window to save the session. +capture-proof shot --out [options] + --selector capture one element instead of the viewport + --full capture the whole scrolling page + --wait-for wait until this is visible first: a CSS selector, or text=Some label + --settle pause after loading, 500 by default + --script run \`export default async (page) => {...}\` first: clicks, typing, opening a dialog + --hide 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); +}