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}