/** * Render the built app and write a PNG you can open and judge. * * The defects this exists for cannot be asserted. "The ocean has no specular * response", "the board ends in a hard diamond edge", "the aircraft are * illegible at board scale" are all true of code that typechecks, passes every * test and meets every performance budget. The only instrument that finds them * is a picture, so this makes taking one cheap. * * node scripts/look.mjs [--url ] [--phone] [--at ] * [--wait ] [--click ] * * Writes /tmp/tera-look/.png. `--at` pins the clock, because the sun's * position is computed from the real one and a shot taken at 03:00 tells you * nothing about how the water reads at noon. * * This box has an AMD card and no monitor; the ANGLE/Vulkan flags below are what * make Chrome render headless here rather than falling back to a blank canvas. */ import { spawn } from "node:child_process"; import { mkdirSync, readFileSync } from "node:fs"; import { createServer } from "node:net"; import { chromium } from "playwright"; const args = process.argv.slice(2); const name = args[0] ?? "look"; const flag = (f, d) => { const i = args.indexOf(f); return i === -1 ? d : args[i + 1]; }; const has = (f) => args.includes(f); const OUT = "/tmp/tera-look"; mkdirSync(OUT, { recursive: true }); /** * The port is asked for, not guessed, and the build is then verified. * * This used to draw a random port in 4700–4899 and start `vite preview * --strictPort` on it. When the draw collided with an abandoned preview from an * earlier run — and this box accumulated a hundred and forty-seven of them in * one afternoon — the new preview exited on the strict-port check, `page.goto` * succeeded against the squatter, and Playwright silently photographed somebody * else's dist. Three shots came back byte-identical while the bundle under them * provably changed. A screenshot tool that can photograph the wrong build is * worse than no screenshot tool, because you believe it. * * So: take a port from the kernel rather than from `Math.random`, then read the * URL the preview actually bound out of its own stdout, then fetch `/` and * assert it is the `dist/index.html` sitting on this disk. Any one of the three * would have caught it; all three cost nothing. */ const PORT = await new Promise((resolve, reject) => { const probe = createServer(); probe.once("error", reject); probe.listen(0, "127.0.0.1", () => { const { port } = probe.address(); probe.close(() => resolve(port)); }); }); // `detached`, and vite's own binary rather than `npx`, because of the *other* // half of the orphan story: `npx` is a wrapper, `server.kill()` killed only the // wrapper, and the preview it had spawned went on holding its port forever. A // hundred and forty-seven of them accumulated in one afternoon. Detached gives // the pair a process group, and killing the group at the end kills both. const server = spawn( new URL("../node_modules/.bin/vite", import.meta.url).pathname, ["preview", "--port", String(PORT), "--strictPort"], { detached: true, stdio: ["ignore", "pipe", "pipe"] }, ); const shutdown = () => { try { process.kill(-server.pid, "SIGTERM"); } catch { /* already gone */ } }; process.on("exit", shutdown); const bound = await new Promise((resolve) => { let seen = ""; const settle = setTimeout(() => resolve(null), 30000); const read = (chunk) => { seen += String(chunk); const match = /http:\/\/(?:localhost|127\.0\.0\.1):(\d+)/.exec(seen); if (match) { clearTimeout(settle); resolve(Number(match[1])); } }; server.stdout.on("data", read); server.stderr.on("data", read); }); if (bound === null) { console.error(`look: vite preview never announced a URL on ${PORT}; is dist/ built?`); shutdown(); process.exit(1); } if (bound !== PORT) { console.error(`look: preview bound ${bound}, not ${PORT} — refusing to photograph it`); shutdown(); process.exit(1); } // And the served page is this checkout's build, hashed script tag and all. { const served = await fetch(`http://localhost:${PORT}/index.html`).then((r) => r.text()); const onDisk = readFileSync(new URL("../dist/index.html", import.meta.url), "utf8"); const bundle = (html) => /src="([^"]*\/assets\/[^"]+\.js)"/.exec(html)?.[1] ?? null; if (bundle(served) === null || bundle(served) !== bundle(onDisk)) { console.error( `look: :${PORT} is serving ${bundle(served)}, dist/ holds ${bundle(onDisk)} — ` + `something else owns that port. Not photographing it.`, ); shutdown(); process.exit(1); } } const phone = has("--phone"); const browser = await chromium.launch({ channel: "chrome", args: [ "--use-gl=angle", "--use-angle=vulkan", "--enable-unsafe-swiftshader", "--ignore-gpu-blocklist", ], }); const context = await browser.newContext({ viewport: phone ? { width: 390, height: 844 } : { width: 1600, height: 1000 }, deviceScaleFactor: 2, timezoneId: "America/Los_Angeles", ...(phone ? { isMobile: true, hasTouch: true, userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 " + "(KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", } : {}), }); await context.clock.setFixedTime(new Date(flag("--at", "2026-08-21T20:00:00Z"))); const page = await context.newPage(); const errors = []; page.on("console", (m) => { if (m.type() === "error") errors.push(m.text()); }); await page.goto(`http://localhost:${PORT}${flag("--url", "/")}`, { waitUntil: "networkidle", timeout: 60000, }); await page.waitForTimeout(Number(flag("--wait", "10000"))); // The first-run flow covers the scene it is teaching you about. try { await page.getByText(/^Skip$/).first().click({ timeout: 2500 }); await page.waitForTimeout(1200); } catch { /* already dismissed, or not shown */ } const click = flag("--click", ""); if (click !== "") { try { await page.getByText(new RegExp(click, "i")).first().click({ timeout: 5000 }); await page.waitForTimeout(8000); } catch { console.log(`look: could not click ${click}`); } } const path = `${OUT}/${name}.png`; await page.screenshot({ path }); console.log(`look: wrote ${path}`); // Two 404s on a static preview are the zero-config path working: see deploy/STATIC.md. const real = errors.filter((e) => !/404|Failed to load resource/.test(e)); console.log(real.length === 0 ? "look: no console errors" : `look: ERRORS ${JSON.stringify(real)}`); await browser.close(); shutdown(); // Explicit, because the preview's stdout pipes are inherited by a grandchild // and Node will happily wait on them for the rest of the afternoon. process.exit(0);