shots.ts
11.9 kB · typescript · 254 lines
1import { createHash } from "node:crypto";2import { existsSync, mkdirSync, readFileSync, rmSync, statSync } from "node:fs";3import { extname, join, relative, resolve } from "node:path";45/* WHERE */67export type Size = [number, number, boolean];89export type Block = { routes: string[]; sizes: Record<string, Size> };1011const CHROME = process.env.CHROME ?? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";12const PORT = 9335;13const SERVE = 3335;14const LIVE = (process.env.SITE_URL ?? "").replace(/\/$/, "");15const CSP = process.env.CSP ?? "";16const TALL = 6000;17const PATIENCE = 15000;18const REPLY = 30000;1920export function block(config: Record<string, unknown>): Block {21 const found = config.shots as Block | undefined;22 if (!found?.routes?.length || !found.sizes) throw new Error("shots: site.json has no shots block");23 return found;24}2526/* SERVER */2728const TYPES: Record<string, string> = {29 ".css": "text/css",30 ".html": "text/html; charset=utf-8",31 ".js": "text/javascript",32 ".json": "application/json",33 ".png": "image/png",34 ".svg": "image/svg+xml",35 ".wasm": "application/wasm",36 ".woff2": "font/woff2",37};3839const doc = (bytes: Uint8Array) => new TextDecoder().decode(bytes.subarray(0, 15)).toLowerCase().startsWith("<!doctype html");4041function serve(dist: string) {42 const file = (path: string): Response | null => {43 const want = join(dist, path.endsWith("/") ? `${path}index.html` : path);44 if (!want.startsWith(dist) || !existsSync(want) || !statSync(want).isFile()) return null;45 const bytes = new Uint8Array(readFileSync(want));46 const type = doc(bytes) ? TYPES[".html"]! : (TYPES[extname(want)] ?? "application/octet-stream");47 const headers: Record<string, string> = { "content-type": type };48 if (CSP) headers["content-security-policy"] = CSP;49 return new Response(bytes, { headers });50 };51 return Bun.serve({52 port: SERVE,53 fetch(req) {54 const path = decodeURIComponent(new URL(req.url).pathname);55 const hit = file(path) ?? file(`${path}/`);56 if (hit) return hit;57 const lost = file("/404.html");58 return lost ? new Response(lost.body, { status: 404, headers: lost.headers }) : new Response("not found", { status: 404 });59 },60 });61}6263/* CHROME */6465const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));6667function deadline<T>(job: Promise<T>, ms: number, what: string): Promise<T> {68 return new Promise<T>((ok, no) => {69 const timer = setTimeout(() => no(new Error(`shots: ${what} did not answer within ${ms / 1000} s`)), ms);70 const clear = (run: () => void) => {71 clearTimeout(timer);72 run();73 };74 job.then(75 (value) => clear(() => ok(value)),76 (error) => clear(() => no(error)),77 );78 });79}8081type Target = { type: string; webSocketDebuggerUrl: string };8283async function targets(): Promise<Target[] | null> {84 try {85 return (await (await fetch(`http://127.0.0.1:${PORT}/json`)).json()) as Target[];86 } catch {87 return null;88 }89}9091async function launch(profile: string) {92 if (await targets()) throw new Error(`shots: something already answers on port ${PORT}; kill it first`);93 if (!LIVE) rmSync(profile, { recursive: true, force: true });94 mkdirSync(profile, { recursive: true });95 const proc = Bun.spawn(96 [CHROME, "--headless=new", "--disable-gpu", "--enable-unsafe-swiftshader", "--use-angle=swiftshader", "--no-first-run", "--hide-scrollbars", `--user-data-dir=${profile}`, `--remote-debugging-port=${PORT}`, "about:blank"],97 { stdout: "ignore", stderr: "ignore" },98 );99 for (let i = 0; i < 50; i++) {100 await wait(200);101 if (proc.exitCode !== null) break;102 const page = (await targets())?.find((one) => one.type === "page");103 if (page) return { proc, page };104 }105 proc.kill();106 throw new Error(`shots: no chrome at ${CHROME}; set CHROME to the binary`);107}108109const flat = (one: any) => (one?.value !== undefined ? String(one.value) : (one?.description ?? one?.preview?.description ?? one?.unserializableValue ?? one?.type ?? ""));110111function driver(ws: WebSocket) {112 let id = 0;113 const pending = new Map<number, (v: any) => void>();114 const events = new Map<string, () => void>();115 const noise: string[] = [];116 const said = (text: string) => {117 const line = text.trim().replace(/\s+/g, " ").slice(0, 400);118 if (line) noise.push(line);119 };120 ws.onmessage = (event) => {121 const m = JSON.parse(String(event.data));122 if (m.id && pending.has(m.id)) {123 pending.get(m.id)!(m.result ?? m.error);124 pending.delete(m.id);125 } else if (m.method === "Runtime.consoleAPICalled" && /error|warning|assert/.test(m.params.type)) {126 said(`${m.params.type}: ${(m.params.args ?? []).map(flat).join(" ")}`);127 } else if (m.method === "Runtime.exceptionThrown") {128 said(`exception: ${m.params.exceptionDetails.exception?.description ?? m.params.exceptionDetails.text}`);129 } else if (m.method === "Log.entryAdded" && /error|warning/.test(m.params.entry.level)) {130 said(`${m.params.entry.level}: ${m.params.entry.text} ${m.params.entry.url ?? ""}`);131 } else if (m.method && events.has(m.method)) {132 events.get(m.method)!();133 events.delete(m.method);134 }135 };136 const send = (method: string, params = {}) =>137 deadline(138 new Promise<any>((r) => {139 pending.set(++id, r);140 ws.send(JSON.stringify({ id, method, params }));141 }),142 REPLY,143 `${method} reply`,144 );145 const once = (method: string) => deadline(new Promise<void>((r) => events.set(method, r)), PATIENCE, method);146 return { send, once, noise };147}148149/* PROBES */150151const MOUNTED = `new Promise((r) => { const root = document.querySelector("#root, #app"); if (!root) return r(); const t0 = Date.now(); const poll = () => (root.children.length || Date.now() - t0 > 8000 ? r() : setTimeout(poll, 50)); poll(); })`;152153const STILL = `document.head.insertAdjacentHTML("beforeend", "<style>canvas:not(.mark) { visibility: hidden !important; }</style>")`;154155const READY = `${MOUNTED}.then(() => Promise.all([...document.images].map((i) => { i.loading = "eager"; return (i.complete ? Promise.resolve() : new Promise((r) => { i.onload = i.onerror = r; })).then(() => i.decode().catch(() => 0)); }))).then(() => document.fonts.ready).then(() => { ${STILL}; }).then(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))).then(() => 1)`;156157const WIDE = `JSON.stringify([...document.querySelectorAll("body *")].filter((el) => !el.closest(".pane, .scrim") && getComputedStyle(el).visibility !== "hidden").map((el) => [el, el.getBoundingClientRect()]).filter(([, r]) => r.right > innerWidth + 1 && r.width > 0).sort((a, b) => b[1].right - a[1].right).slice(0, 4).map(([el, r]) => el.tagName.toLowerCase() + (typeof el.className === "string" && el.className ? "." + el.className.trim().split(/\\s+/).join(".") : "") + " right=" + Math.round(r.right) + " width=" + Math.round(r.width)))`;158159const sha = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex").slice(0, 8);160161/* RUN */162163export async function main(root: string, config?: Record<string, unknown>): Promise<void> {164 const conf = config ?? (JSON.parse(readFileSync(join(root, "site.json"), "utf8")) as Record<string, unknown>);165 const { routes, sizes } = block(conf);166 const desk = resolve(root, "../..");167 const DATA_DIR = join(desk, "data", relative(desk, root), "scripts");168 const args = process.argv.slice(2);169 const print = args.includes("--print");170 const baseline = args.includes("--baseline");171 const probe = args.includes("--js") ? (args[args.indexOf("--js") + 1] ?? "") : "";172 const scheme = args.includes("--theme") ? (args[args.indexOf("--theme") + 1] ?? "") : "";173 const asked = args.filter((a, i) => !a.startsWith("--") && args[i - 1] !== "--js" && args[i - 1] !== "--theme");174 const pages = asked.length ? asked : routes;175 const out = join(DATA_DIR, "shots", baseline ? "baseline" : "latest");176 const base = join(DATA_DIR, "shots", "baseline");177 const name = (route: string, size: string, act: number) =>178 `${route.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "") || "home"}${act >= 0 ? `-open${act}` : ""}-${size}${print ? "-print" : ""}${scheme ? `-${scheme}` : ""}.png`;179 mkdirSync(out, { recursive: true });180 const server = LIVE ? null : serve(join(root, "dist"));181 const { proc, page } = await launch(join(DATA_DIR, "profile"));182 let shot = 0;183 let changed = 0;184 let fresh = 0;185 try {186 const ws = new WebSocket(page.webSocketDebuggerUrl);187 await new Promise((r) => (ws.onopen = r));188 const { send, once, noise } = driver(ws);189 await send("Page.enable");190 await send("Runtime.enable");191 await send("Log.enable");192 await send("Emulation.setEmulatedMedia", { media: print ? "print" : "", features: [{ name: "prefers-reduced-motion", value: "reduce" }, ...(scheme ? [{ name: "prefers-color-scheme", value: scheme }] : [])] });193 for (const [turn, want] of pages.entries()) {194 const [route = "/", act] = want.split("@");195 for (const [size, [width, height, mobile]] of Object.entries(sizes)) {196 await send("Emulation.setDeviceMetricsOverride", { width, height, deviceScaleFactor: 1, mobile });197 const loaded = once("Page.loadEventFired");198 noise.length = 0;199 await send("Page.navigate", { url: `${LIVE || `http://127.0.0.1:${SERVE}`}${route}` });200 const file = name(route, size, act ? turn : -1);201 try {202 await loaded;203 } catch (error) {204 console.log(`shots: ${file} FAILED ${(error as Error).message}`);205 continue;206 }207 await Promise.race([send("Runtime.evaluate", { expression: READY, awaitPromise: true, returnByValue: true }).catch(() => 0), wait(PATIENCE)]);208 await wait(300);209 if (act) {210 const { result, exceptionDetails } = await send("Runtime.evaluate", { expression: act, returnByValue: true, awaitPromise: true });211 if (exceptionDetails) console.log(` act failed: ${exceptionDetails.exception?.description ?? exceptionDetails.text}`);212 else if (result?.value !== undefined && result.value !== 0) console.log(` act: ${JSON.stringify(result.value)}`);213 await wait(600);214 }215 const { cssContentSize } = await send("Page.getLayoutMetrics");216 const full = act ? height : Math.min(Math.ceil(cssContentSize.height), TALL);217 const cap = await send("Page.captureScreenshot", { format: "png", captureBeyondViewport: !act, clip: { x: 0, y: 0, width, height: full, scale: 1 } });218 const bytes = new Uint8Array(Buffer.from(cap.data, "base64"));219 await Bun.write(join(out, file), bytes);220 shot++;221 const wide = Math.ceil(cssContentSize.width) > width;222 const was = join(base, file);223 let verdict = "";224 if (!baseline && existsSync(was)) {225 const same = sha(new Uint8Array(readFileSync(was))) === sha(bytes);226 verdict = same ? " same" : " DIFF";227 if (!same) changed++;228 } else if (!baseline) {229 verdict = " new";230 fresh++;231 }232 console.log(`shots: ${file} ${width}x${full} ${sha(bytes)}${wide ? " OVERFLOW" : ""}${verdict}${noise.length ? ` ${noise.length} NOISE` : ""}`);233 for (const line of [...new Set(noise)]) console.log(` ${line}`);234 if (probe) {235 const { result, exceptionDetails } = await send("Runtime.evaluate", { expression: probe, returnByValue: true, awaitPromise: true });236 console.log(` ${exceptionDetails ? `probe failed: ${exceptionDetails.text}` : JSON.stringify(result.value)}`);237 }238 if (wide) {239 const { result } = await send("Runtime.evaluate", { expression: WIDE, returnByValue: true });240 for (const line of JSON.parse(result.value) as string[]) console.log(` ${line}`);241 }242 }243 }244 ws.close();245 } finally {246 proc.kill();247 await proc.exited;248 if (!LIVE) rmSync(join(DATA_DIR, "profile"), { recursive: true, force: true });249 server?.stop(true);250 }251 const tail = baseline ? "baseline written" : `${changed} differ from baseline, ${fresh} new`;252 console.log(`shots: ${shot} shots in ${out}, ${tail}, chrome killed`);253 process.exit(0);254}