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);