drive.mjs
332 lines · scripts/drive.mjs at 1482e84
1import { chromePort } from "./chrome-port.mjs";
2import { spawn } from "node:child_process";
3import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4import { homedir, tmpdir } from "node:os";
5import { dirname, join, resolve } from "node:path";
6import { pathToFileURL } from "node:url";
7import { LINE_ENDS } from "./line-ends.mjs";
9if (typeof WebSocket === "undefined" && !process.execArgv.includes("--experimental-websocket")) {
10 const child = spawn(process.execPath, ["--experimental-websocket", ...process.argv.slice(1)], { stdio: "inherit" });
11 child.on("exit", (code) => process.exit(code ?? 1));
12} else {
13 await main();
14}
16function usage() {
17 console.log(`Drive a figure in headless Chrome: move, click, type, wait, screenshot, and build contact sheets.
19 node drive.mjs <file.html | url> '<actions as JSON>' [--width 1440] [--height 1100] [--scale 1] [--reduced] [--theme dark|light] [--no-webgl] [--preload file.js]
20 node drive.mjs <file.html | url> --actions steps.json
22 --reduced emulate prefers-reduced-motion: reduce
23 --theme t emulate prefers-color-scheme: dark or light (only pages that read the media query follow it)
24 --no-webgl make canvas.getContext("webgl"/"webgl2") return null, to check the SVG fallback
25 --preload js run a script before the page's own scripts (counters, stubs, hooks)
26 Kit pages set their theme at build time (body data-theme), so drive the -light.html build for light.
27 Put anything with eval strings in an --actions file: inline JSON quoting breaks easily.
29Actions run in order. A missing or null selector means the first .iso-plate (the key action focuses [tabindex="0"]).
30Fractions are 0..1 across the element.
31 ["scroll", sel] scroll an element into the middle of the viewport
32 ["wait", ms] wait
33 ["move", sel, fx, fy] glide the mouse to a point of the element (fires pointermove)
34 ["click", sel, fx?, fy?] click a point of the element (centre by default)
35 ["drag", sel, fx0, fy0, fx1, fy1, steps?]
36 press at one point of the element, glide to another in steps (20) one frame apart,
37 release: real pointer events (pointerId 1), so setPointerCapture works
38 ["key", key, sel?, code?] focus sel (default [tabindex="0"]) and press a key ("i", "Escape", "ArrowRight", " ", "7");
39 pass code for keys that share a name ("Shift" + "ShiftRight", "Enter" + "NumpadEnter")
40 ["slow", factor] run the page's requestAnimationFrame and performance.now clock at factor x real
41 time (0.05 = 20x slow motion) so sheets catch 100 ms events; ["slow", 1] restores it
42 ["until", js, timeoutMs?] poll a JS expression until it is truthy (default 10 s); a timeout stops the run with exit 1
43 ["eval", js] run JS in the page and print the result
44 ["shot", out.png, sel?, scale?, [x,y,w,h]?] screenshot an element, or a region of it in CSS px
45 ["lines", out.png?, sel?, options?] check every texture line's two ends: each must land on a stroke, a dot or an outline
46 (within options.tolerance, 1 viewBox px), or hide under a fill painted later; a short
47 straight mark (a tick) needs one rooted end; lines with data-free and fade ramps are
48 exempt. Prints each floating end in card px; out.png marks them with rings. Any failure
49 makes the run exit 1
50 ["turn", value, group?] set a 3D figure's turn through window.__isoTurn (a number sets the first turn group,
51 group names another), then wait two animation frames
52 ["orbit", out.png, from, to, step, sel?, scale?, [x,y,w,h]?, columns?]
53 a contact sheet across angles of a 3D figure: each frame is turned, waits two
54 animation frames, is captured and labelled with its angle
55 ["sheet", out.png, every, count, sel?, scale?, [x,y,w,h]?, columns?]
56 take count frames every ms and tile them into one PNG, to catch pops and seams;
57 each capture also takes 60-200 ms (more on heavy WebGL pages), so combine with ["slow", 0.05] for transitions
59Prints the readout after each shot, then console errors and exceptions.`);
60}
62function chromePath() {
63 const candidates = [
64 process.env.CHROME_PATH,
65 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
66 "/Applications/Chromium.app/Contents/MacOS/Chromium",
67 "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
68 "/usr/bin/google-chrome",
69 "/usr/bin/google-chrome-stable",
70 "/usr/bin/chromium",
71 "/usr/bin/chromium-browser",
72 "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
73 "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
74 ...(process.env.LOCALAPPDATA ? [join(process.env.LOCALAPPDATA, "Google\\Chrome\\Application\\chrome.exe")] : []),
75 "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
76 "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
77 ].filter(Boolean);
78 const playwright = join(homedir(), "Library/Caches/ms-playwright");
79 if (existsSync(playwright)) {
80 for (const dir of readdirSync(playwright).sort().reverse()) {
81 for (const sub of ["chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing", "chrome-mac/Chromium.app/Contents/MacOS/Chromium", "chrome-linux/chrome"]) {
82 candidates.push(join(playwright, dir, sub));
83 }
84 }
85 }
86 const linux = join(homedir(), ".cache/ms-playwright");
87 if (existsSync(linux)) for (const dir of readdirSync(linux).sort().reverse()) for (const sub of ["chrome-linux64/chrome", "chrome-linux/chrome"]) candidates.push(join(linux, dir, sub));
88 const win = join(homedir(), "AppData/Local/ms-playwright");
89 if (existsSync(win)) for (const dir of readdirSync(win).sort().reverse()) for (const sub of ["chrome-win/chrome.exe", "chromium-win64/chrome.exe"]) candidates.push(join(win, dir, sub));
90 return candidates.find((path) => existsSync(path));
91}
93async function main() {
94 const argv = process.argv.slice(2);
95 if (!argv.length || argv.includes("--help") || argv.includes("-h")) {
96 usage();
97 process.exit(argv.length ? 0 : 1);
98 }
99 const options = { width: 1440, height: 1100, scale: 1, reduced: false, theme: null, actions: null, noWebgl: false, preload: null };
100 const rest = [];
101 for (let index = 0; index < argv.length; index++) {
102 const arg = argv[index];
103 if (arg === "--width") options.width = Number(argv[++index]);
104 else if (arg === "--height") options.height = Number(argv[++index]);
105 else if (arg === "--scale") options.scale = Number(argv[++index]);
106 else if (arg === "--reduced") options.reduced = true;
107 else if (arg === "--theme") options.theme = argv[++index];
108 else if (arg === "--no-webgl") options.noWebgl = true;
109 else if (arg === "--preload") options.preload = readFileSync(argv[++index], "utf8");
110 else if (arg === "--actions") options.actions = JSON.parse(readFileSync(argv[++index], "utf8"));
111 else rest.push(arg);
112 }
113 const [target, script] = rest;
114 const actions = options.actions ?? JSON.parse(script ?? "[]");
115 const kinds = new Set(["scroll", "wait", "move", "click", "drag", "key", "slow", "until", "eval", "shot", "lines", "turn", "orbit", "sheet"]);
116 if (!Array.isArray(actions)) throw new Error("actions must be an array");
117 for (const action of actions) {
118 if (!Array.isArray(action) || !kinds.has(action[0])) throw new Error(`unknown action ${Array.isArray(action) ? action[0] : action}`);
119 if (action[0] === "orbit") {
120 const [, , from, to, step] = action;
121 if (![from, to, step].every(Number.isFinite) || step === 0 || (step > 0 ? from > to : from < to)) throw new Error(`orbit needs numeric from, to and a nonzero step that moves from ${from} toward ${to}, got step ${step}`);
122 }
123 if (action[0] === "sheet") {
124 const [, , every, count] = action;
125 if (!Number.isFinite(every) || every < 0 || !Number.isInteger(count) || count < 1) throw new Error(`sheet needs a non-negative every and a whole count of at least 1, got every ${every} and count ${count}`);
126 }
127 }
128 const url = /^https?:|^file:/.test(target) ? target : pathToFileURL(resolve(target)).href;
129 const binary = chromePath();
130 if (!binary) {
131 console.error("No Chrome found. Set CHROME_PATH to a Chrome or Chromium binary.");
132 process.exit(1);
133 }
134 const profile = mkdtempSync(join(tmpdir(), "iso-drive-"));
135 const chrome = spawn(binary, ["--headless=new", ...(process.platform === "linux" ? ["--no-sandbox"] : []), ...(process.platform === "win32" ? ["--do-not-de-elevate"] : []), "--remote-debugging-port=0", `--user-data-dir=${profile}`, "--no-first-run", "--no-default-browser-check", "--hide-scrollbars", "--allow-file-access-from-files", "--enable-unsafe-swiftshader", "--ignore-gpu-blocklist", "about:blank"]);
136 const port = await chromePort(chrome, profile);
137 const finish = (code) => {
138 chrome.kill("SIGKILL");
139 try {
140 rmSync(profile, { recursive: true, force: true });
141 } catch {}
142 process.exit(code);
143 };
144 try {
145 const page = await (await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: "PUT" })).json();
146 const socket = new WebSocket(page.webSocketDebuggerUrl);
147 let id = 0;
148 const pending = new Map();
149 const problems = [];
150 let loaded = () => {};
151 socket.onmessage = (message) => {
152 const data = JSON.parse(message.data);
153 if (data.method === "Page.loadEventFired") loaded();
154 if (data.id && pending.has(data.id)) {
155 pending.get(data.id)(data);
156 pending.delete(data.id);
157 } else if (data.method === "Runtime.consoleAPICalled" && (data.params.type === "error" || data.params.type === "warning")) {
158 problems.push(`console.${data.params.type}: ${data.params.args.map((arg) => arg.value ?? arg.description ?? "").join(" ").slice(0, 600)}`);
159 } else if (data.method === "Runtime.exceptionThrown") {
160 const detail = data.params.exceptionDetails;
161 problems.push(`exception: ${detail.exception?.description ?? detail.text}`.slice(0, 600));
162 }
163 };
164 await new Promise((done) => (socket.onopen = done));
165 const send = (method, params = {}, patience = 60000) =>
166 new Promise((done, fail) => {
167 const call = ++id;
168 const timer = setTimeout(() => {
169 pending.delete(call);
170 fail(new Error(`Chrome did not answer ${method} within ${Math.round(patience / 1000)} s`));
171 }, patience);
172 pending.set(call, (data) => {
173 clearTimeout(timer);
174 done(data);
175 });
176 socket.send(JSON.stringify({ id: call, method, params }));
177 });
178 const evaluate = async (expression, patience) => {
179 const reply = await send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true }, patience);
180 if (reply.error) throw new Error(reply.error.message);
181 const exception = reply.result?.exceptionDetails;
182 if (exception) throw new Error(exception.exception?.description ?? exception.text);
183 return reply.result?.result?.value;
184 };
185 const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
186 await send("Runtime.enable");
187 await send("Page.enable");
188 const features = [];
189 if (options.reduced) features.push({ name: "prefers-reduced-motion", value: "reduce" });
190 if (options.theme) features.push({ name: "prefers-color-scheme", value: options.theme });
191 if (features.length) await send("Emulation.setEmulatedMedia", { features });
192 const SLOW = `(()=>{const raf=window.requestAnimationFrame.bind(window);let real=null,virtual=0;window.__driveSpeed=1;window.requestAnimationFrame=(callback)=>raf((now)=>{if(real===null){real=now;virtual=now}virtual+=(now-real)*window.__driveSpeed;real=now;callback(virtual)});const clock=performance.now.bind(performance);let base=null,shown=0;performance.now=()=>{const now=clock();if(base===null){base=now;shown=now}shown+=(now-base)*window.__driveSpeed;base=now;return shown}})();`;
193 const NO_GL = `(()=>{const get=HTMLCanvasElement.prototype.getContext;HTMLCanvasElement.prototype.getContext=function(kind,...rest){if(/webgl/.test(String(kind)))return null;return get.call(this,kind,...rest)}})();`;
194 const early = [SLOW, options.noWebgl ? NO_GL : "", options.preload ?? ""].filter(Boolean).join("\n");
195 await send("Page.addScriptToEvaluateOnNewDocument", { source: early });
196 await send("Emulation.setDeviceMetricsOverride", { width: options.width, height: options.height, deviceScaleFactor: options.scale, mobile: options.width < 600 });
197 const load = new Promise((done) => {
198 loaded = done;
199 setTimeout(done, 10000);
200 });
201 const navigation = await send("Page.navigate", { url });
202 if (navigation.error) throw new Error(`Navigation failed: ${navigation.error.message}`);
203 if (navigation.result?.errorText) throw new Error(`Navigation failed: ${navigation.result.errorText}`);
204 await load;
205 await sleep(1200);
206 const targetOf = (selector, fallback = ".iso-plate") => `(()=>{const select=${JSON.stringify(selector ?? null)};const el=document.querySelector(select??${JSON.stringify(fallback)});if(select!==null&&!el)throw new Error("Drive target not found: "+select);return el})()`;
207 const boxOf = (selector) => evaluate(`(()=>{const el=${targetOf(selector)}||document.body;const r=el.getBoundingClientRect();return {x:r.left,y:r.top,w:r.width,h:r.height,sx:scrollX,sy:scrollY}})()`);
208 const readout = (selector) => evaluate(selector == null ? `(document.querySelector("[data-readout]")||{}).textContent||""` : `(()=>{const el=${targetOf(selector)};return(el.matches('[data-readout]')?el:el.querySelector('[data-readout]')||el.closest('.iso-plate')?.querySelector('[data-readout]')||{}).textContent||""})()`);
209 const capture = async (selector, scale = 1, region) => {
210 const box = await boxOf(selector);
211 const x = box.x + box.sx;
212 const y = box.y + box.sy;
213 const clip = region ? { x: x + region[0], y: y + region[1], width: region[2], height: region[3], scale } : { x: x - 4, y: y - 4, width: box.w + 8, height: box.h + 8, scale };
214 const shot = await send("Page.captureScreenshot", { format: "png", clip, captureBeyondViewport: true });
215 return shot.result.data;
216 };
217 const save = (out, base64) => {
218 mkdirSync(dirname(resolve(out)), { recursive: true });
219 writeFileSync(out, Buffer.from(base64, "base64"));
220 };
221 let mouse = [4, 4];
222 let failed = 0;
223 for (const action of actions) {
224 const [kind, ...args] = action;
225 if (kind === "wait") await sleep(args[0]);
226 else if (kind === "scroll") await evaluate(`(()=>{const el=${targetOf(args[0])};if(el)el.scrollIntoView({block:"center"})})()`);
227 else if (kind === "move") {
228 const box = await boxOf(args[0]);
229 const to = [box.x + box.w * args[1], box.y + box.h * args[2]];
230 for (let step = 1; step <= 10; step++) {
231 await send("Input.dispatchMouseEvent", { type: "mouseMoved", x: mouse[0] + ((to[0] - mouse[0]) * step) / 10, y: mouse[1] + ((to[1] - mouse[1]) * step) / 10 });
232 await sleep(16);
233 }
234 mouse = to;
235 } else if (kind === "click") {
236 const box = await boxOf(args[0]);
237 const to = [box.x + box.w * (args[1] ?? 0.5), box.y + box.h * (args[2] ?? 0.5)];
238 await send("Input.dispatchMouseEvent", { type: "mouseMoved", x: to[0], y: to[1] });
239 await send("Input.dispatchMouseEvent", { type: "mousePressed", x: to[0], y: to[1], button: "left", clickCount: 1 });
240 await send("Input.dispatchMouseEvent", { type: "mouseReleased", x: to[0], y: to[1], button: "left", clickCount: 1 });
241 mouse = to;
242 } else if (kind === "drag") {
243 const box = await boxOf(args[0]);
244 const from = [box.x + box.w * args[1], box.y + box.h * args[2]];
245 const to = [box.x + box.w * args[3], box.y + box.h * args[4]];
246 const steps = Math.max(1, args[5] ?? 20);
247 await send("Input.dispatchMouseEvent", { type: "mouseMoved", x: from[0], y: from[1] });
248 await send("Input.dispatchMouseEvent", { type: "mousePressed", x: from[0], y: from[1], button: "left", buttons: 1, clickCount: 1 });
249 for (let step = 1; step <= steps; step++) {
250 await send("Input.dispatchMouseEvent", { type: "mouseMoved", x: from[0] + ((to[0] - from[0]) * step) / steps, y: from[1] + ((to[1] - from[1]) * step) / steps, button: "left", buttons: 1 });
251 await sleep(16);
252 }
253 await send("Input.dispatchMouseEvent", { type: "mouseReleased", x: to[0], y: to[1], button: "left", buttons: 0, clickCount: 1 });
254 mouse = to;
255 } else if (kind === "key") {
256 await evaluate(`(()=>{const el=${targetOf(args[1], '[tabindex="0"]')};if(el)el.focus()})()`);
257 const key = args[0];
258 const named = { " ": "Space", "-": "Minus", "=": "Equal", ",": "Comma", ".": "Period", "/": "Slash", ";": "Semicolon", "'": "Quote", "[": "BracketLeft", "]": "BracketRight", "\\": "Backslash", "`": "Backquote" };
259 const code = args[2] ?? (named[key] ?? (/^[0-9]$/.test(key) ? `Digit${key}` : key.length === 1 ? `Key${key.toUpperCase()}` : key));
260 await send("Input.dispatchKeyEvent", { type: "keyDown", key, code, text: key === "Enter" ? "\r" : key.length === 1 ? key : undefined, windowsVirtualKeyCode: key === " " ? 32 : key === "Enter" ? 13 : 0 });
261 await send("Input.dispatchKeyEvent", { type: "keyUp", key, code, windowsVirtualKeyCode: key === "Enter" ? 13 : undefined });
262 } else if (kind === "eval") console.log("eval:", JSON.stringify(await evaluate(args[0])));
263 else if (kind === "slow") await evaluate(`window.__driveSpeed=${Number(args[0])}`);
264 else if (kind === "until") {
265 const limit = Date.now() + (args[1] ?? 10000);
266 let ok = false;
267 while (Date.now() < limit) {
268 ok = await evaluate(`Promise.race([Promise.resolve(${args[0]}).then(Boolean), new Promise((done) => setTimeout(() => done(false), ${Math.max(0, limit - Date.now())}))])`, Math.max(0, limit - Date.now()) + 60000);
269 if (ok) break;
270 await sleep(25);
271 }
272 if (!ok) throw new Error(`until: timed out waiting for ${args[0]}`);
273 }
274 else if (kind === "shot") {
275 const [out, selector, scale, region] = args;
276 save(out, await capture(selector, scale ?? 1, region));
277 console.log(`shot ${out} · readout "${await readout(selector)}"`);
278 } else if (kind === "sheet") {
279 const [out, every, count, selector, scale, region, columns] = args;
280 const frames = [];
281 const texts = [];
282 for (let index = 0; index < count; index++) {
283 if (index) await sleep(every);
284 frames.push(await capture(selector, scale ?? 0.5, region));
285 texts.push(await readout(selector));
286 }
287 const tiled = await evaluate(`(async()=>{const frames=${JSON.stringify(frames)};const cols=${columns ?? Math.min(count, 6)};const images=await Promise.all(frames.map(src=>new Promise(done=>{const image=new Image();image.onload=()=>done(image);image.src="data:image/png;base64,"+src})));const w=images[0].width,h=images[0].height,rows=Math.ceil(images.length/cols);const canvas=document.createElement("canvas");canvas.width=w*cols;canvas.height=h*rows;const ctx=canvas.getContext("2d");ctx.fillStyle="#808080";ctx.fillRect(0,0,canvas.width,canvas.height);images.forEach((image,index)=>{ctx.drawImage(image,(index%cols)*w,Math.floor(index/cols)*h);ctx.fillStyle="#ff00aa";ctx.font="bold 12px monospace";ctx.fillText(String(index),(index%cols)*w+6,Math.floor(index/cols)*h+16)});return canvas.toDataURL("image/png").split(",")[1]})()`);
288 save(out, tiled);
289 console.log(`sheet ${out} · ${count} frames every ${every} ms · readouts ${texts.map((text, index) => `${index}:"${text}"`).join(" ")}`);
290 } else if (kind === "turn") {
291 const [value, group] = args;
292 const call = group ? `window.__isoTurn.set(${JSON.stringify({ [group]: value })})` : `window.__isoTurn.set(${typeof value === "object" ? JSON.stringify(value) : Number(value)})`;
293 await evaluate(`(()=>{if(typeof window.__isoTurn?.set!=="function")throw new Error("${kind} action requires window.__isoTurn.set");${call};return new Promise((done)=>requestAnimationFrame(()=>requestAnimationFrame(()=>done(true))))})()`);
294 } else if (kind === "orbit") {
295 const [out, from, to, step, selector, scale, region, columns] = args;
296 const frames = [];
297 const labels = [];
298 for (let at = from; step > 0 ? at <= to + 1e-9 : at >= to - 1e-9; at += step) {
299 const angle = Math.round(at * 1000) / 1000;
300 await evaluate(`(()=>{if(typeof window.__isoTurn?.set!=="function")throw new Error("${kind} action requires window.__isoTurn.set");window.__isoTurn.set(${angle});return new Promise((done)=>requestAnimationFrame(()=>requestAnimationFrame(()=>done(true))))})()`);
301 frames.push(await capture(selector, scale ?? 0.5, region));
302 labels.push(`${angle}°`);
303 }
304 const tiled = await evaluate(`(async()=>{const frames=${JSON.stringify(frames)};const labels=${JSON.stringify(labels)};const cols=${columns ?? 6};const images=await Promise.all(frames.map(src=>new Promise(done=>{const image=new Image();image.onload=()=>done(image);image.src="data:image/png;base64,"+src})));const w=images[0].width,h=images[0].height,rows=Math.ceil(images.length/cols);const canvas=document.createElement("canvas");canvas.width=w*cols;canvas.height=h*rows;const ctx=canvas.getContext("2d");ctx.fillStyle="#808080";ctx.fillRect(0,0,canvas.width,canvas.height);images.forEach((image,index)=>{ctx.drawImage(image,(index%cols)*w,Math.floor(index/cols)*h);ctx.fillStyle="#ff00aa";ctx.font="bold 12px monospace";ctx.fillText(labels[index],(index%cols)*w+6,Math.floor(index/cols)*h+16)});return canvas.toDataURL("image/png").split(",")[1]})()`);
305 save(out, tiled);
306 console.log(`orbit ${out} · ${frames.length} frames ${from}°…${to}° every ${step}°`);
307 } else if (kind === "lines") {
308 const [out, selector, extra] = args;
309 const result = await evaluate(`(${LINE_ENDS})(${JSON.stringify({ select: selector ?? null, show: Boolean(out), ...(extra ?? {}) })})`);
310 if (!result) {
311 console.log("lines: the check did not run");
312 failed++;
313 continue;
314 }
315 console.log(`line ends: ${result.problems.length} of ${result.ends} ends on ${result.lines} lines float`);
316 for (const problem of result.problems) console.log(` ${problem.what} ${problem.end}${problem.mark ? " (a mark: neither end rooted)" : ""} ${problem.gap == null ? "nothing nearby" : `${problem.gap.toFixed(2)} px from anything`} at card ${problem.at.join(",")} length ${problem.length} d ${problem.d}…${problem.opacity < 1 ? ` opacity ${problem.opacity}` : ""}`);
317 if (out) {
318 save(out, await capture(selector, 2));
319 await evaluate(`(document.querySelector("[data-line-ends]")||{remove(){}}).remove()`);
320 console.log(`shot ${out}`);
321 }
322 failed += result.problems.length;
323 }
324 }
325 console.log(problems.length ? problems.join("\n") : "console: clean");
326 socket.close();
327 finish(failed ? 1 : 0);
328 } catch (error) {
329 console.error(error);
330 finish(1);
331 }
332}