shots.ts
8.9 kB · typescript · 211 lines
1import { createHash } from "node:crypto";2import { existsSync, mkdirSync, readFileSync, rmSync, statSync } from "node:fs";3import { extname, join, resolve } from "node:path";45/* WHERE */67const org = resolve(import.meta.dir, "..");8const dist = join(org, "dist");9const DATA_DIR = resolve(org, "../../data/mrlyprod/site/scripts");10const SHOTS = join(DATA_DIR, "shots");11const PROFILE = join(DATA_DIR, "profile");12const CHROME = process.env.CHROME ?? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";13const PORT = 9335;14const SERVE = 3335;1516/* WHAT */1718const ROUTES = ["/", "/demos/sponge/", "/papers/spin-harmonics/", "/research/discoveries/", "/git/site/kit/ssg/build.ts"];1920const SIZES: [string, number, number, boolean][] = [21 ["phone", 390, 844, true],22 ["desktop", 1440, 900, false],23];2425const TALL = 6000;2627const PATIENCE = 15000;2829/* ARGS */3031const args = process.argv.slice(2);32const print = args.includes("--print");33const baseline = args.includes("--baseline");34const probe = args.includes("--js") ? args[args.indexOf("--js") + 1] ?? "" : "";35const routes = args.filter((a, i) => !a.startsWith("--") && args[i - 1] !== "--js");36const pages = routes.length ? routes : ROUTES;37const out = join(SHOTS, baseline ? "baseline" : "latest");38const base = join(SHOTS, "baseline");3940/* SERVER */4142const TYPES: Record<string, string> = {43 ".css": "text/css",44 ".html": "text/html; charset=utf-8",45 ".js": "text/javascript",46 ".json": "application/json",47 ".png": "image/png",48 ".svg": "image/svg+xml",49 ".wasm": "application/wasm",50 ".woff2": "font/woff2",51};5253const html = (bytes: Uint8Array) => new TextDecoder().decode(bytes.subarray(0, 15)).toLowerCase().startsWith("<!doctype html");5455function file(path: string): Response | null {56 const want = join(dist, path.endsWith("/") ? `${path}index.html` : path);57 if (!want.startsWith(dist) || !existsSync(want) || !statSync(want).isFile()) return null;58 const bytes = new Uint8Array(readFileSync(want));59 const type = html(bytes) ? TYPES[".html"]! : (TYPES[extname(want)] ?? "application/octet-stream");60 return new Response(bytes, { headers: { "content-type": type } });61}6263const server = Bun.serve({64 port: SERVE,65 fetch(req) {66 const path = decodeURIComponent(new URL(req.url).pathname);67 const hit = file(path) ?? file(`${path}/`);68 if (hit) return hit;69 const lost = file("/404.html");70 return lost ? new Response(lost.body, { status: 404, headers: lost.headers }) : new Response("not found", { status: 404 });71 },72});7374/* CHROME */7576const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));7778type Target = { type: string; webSocketDebuggerUrl: string };7980async function targets(): Promise<Target[] | null> {81 try {82 return (await (await fetch(`http://127.0.0.1:${PORT}/json`)).json()) as Target[];83 } catch {84 return null;85 }86}8788async function launch() {89 if (await targets()) throw new Error(`shots: something already answers on port ${PORT}; kill it first`);90 rmSync(PROFILE, { recursive: true, force: true });91 mkdirSync(PROFILE, { recursive: true });92 const proc = Bun.spawn(93 [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"],94 { stdout: "ignore", stderr: "ignore" },95 );96 for (let i = 0; i < 50; i++) {97 await wait(200);98 if (proc.exitCode !== null) break;99 const page = (await targets())?.find((one) => one.type === "page");100 if (page) return { proc, page };101 }102 proc.kill();103 throw new Error(`shots: no chrome at ${CHROME}; set CHROME to the binary`);104}105106type Reply = { result?: Record<string, unknown>; exceptionDetails?: { text: string } };107108function driver(ws: WebSocket) {109 let id = 0;110 const pending = new Map<number, (v: Reply) => void>();111 const events = new Map<string, () => void>();112 ws.onmessage = (event) => {113 const m = JSON.parse(String(event.data));114 if (m.id && pending.has(m.id)) {115 pending.get(m.id)!(m.result ?? m.error);116 pending.delete(m.id);117 } else if (m.method && events.has(m.method)) {118 events.get(m.method)!();119 events.delete(m.method);120 }121 };122 const send = (method: string, params = {}) =>123 new Promise<any>((r) => {124 pending.set(++id, r);125 ws.send(JSON.stringify({ id, method, params }));126 });127 const once = (method: string) =>128 new Promise<void>((r, fail) => {129 events.set(method, r);130 setTimeout(() => fail(new Error(`shots: ${method} never came within ${PATIENCE / 1000} s`)), PATIENCE);131 });132 return { send, once };133}134135const MOUNTED = `new Promise((r) => { const root = document.getElementById("root"); if (!root) return r(); const t0 = Date.now(); const poll = () => (root.children.length || Date.now() - t0 > 8000 ? r() : setTimeout(poll, 50)); poll(); })`;136137const STILL = `document.head.insertAdjacentHTML("beforeend", "<style>canvas:not(.mark) { visibility: hidden !important; }</style>")`;138139const 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)`;140141const 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)))`;142143const name = (route: string, size: string) => `${route.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "") || "home"}-${size}${print ? "-print" : ""}.png`;144145const sha = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex").slice(0, 8);146147/* RUN */148149mkdirSync(out, { recursive: true });150const { proc, page } = await launch();151let shot = 0;152let changed = 0;153let fresh = 0;154try {155 const ws = new WebSocket(page.webSocketDebuggerUrl);156 await new Promise((r) => (ws.onopen = r));157 const { send, once } = driver(ws);158 await send("Page.enable");159 await send("Emulation.setEmulatedMedia", { media: print ? "print" : "", features: [{ name: "prefers-reduced-motion", value: "reduce" }] });160 for (const route of pages) {161 for (const [size, width, height, mobile] of SIZES) {162 await send("Emulation.setDeviceMetricsOverride", { width, height, deviceScaleFactor: 1, mobile });163 const loaded = once("Page.loadEventFired");164 await send("Page.navigate", { url: `http://127.0.0.1:${SERVE}${route}` });165 try {166 await loaded;167 } catch (error) {168 console.log(`shots: ${name(route, size)} FAILED ${(error as Error).message}`);169 continue;170 }171 await Promise.race([send("Runtime.evaluate", { expression: READY, awaitPromise: true, returnByValue: true }), wait(PATIENCE)]);172 await wait(300);173 const { cssContentSize } = await send("Page.getLayoutMetrics");174 const full = Math.min(Math.ceil(cssContentSize.height), TALL);175 const cap = await send("Page.captureScreenshot", { format: "png", captureBeyondViewport: true, clip: { x: 0, y: 0, width, height: full, scale: 1 } });176 const bytes = new Uint8Array(Buffer.from(cap.data, "base64"));177 const file = name(route, size);178 await Bun.write(join(out, file), bytes);179 shot++;180 const wide = Math.ceil(cssContentSize.width) > width;181 const was = join(base, file);182 let verdict = "";183 if (!baseline && existsSync(was)) {184 const same = sha(new Uint8Array(readFileSync(was))) === sha(bytes);185 verdict = same ? " same" : " DIFF";186 if (!same) changed++;187 } else if (!baseline) {188 verdict = " new";189 fresh++;190 }191 console.log(`shots: ${file} ${width}x${full} ${sha(bytes)}${wide ? " OVERFLOW" : ""}${verdict}`);192 if (probe) {193 const { result, exceptionDetails } = await send("Runtime.evaluate", { expression: probe, returnByValue: true, awaitPromise: true });194 console.log(` ${exceptionDetails ? `probe failed: ${exceptionDetails.text}` : JSON.stringify(result.value)}`);195 }196 if (wide) {197 const { result } = await send("Runtime.evaluate", { expression: WIDE, returnByValue: true });198 for (const line of JSON.parse(result.value) as string[]) console.log(` ${line}`);199 }200 }201 }202 ws.close();203} finally {204 proc.kill();205 await proc.exited;206 rmSync(PROFILE, { recursive: true, force: true });207 server.stop(true);208}209const tail = baseline ? "baseline written" : `${changed} differ from baseline, ${fresh} new`;210console.log(`shots: ${shot} shots in ${out}, ${tail}, chrome killed`);211process.exit(0);