// Record and check the goldens in tests/goldens (see scripts/golden-files.ts) under the // pinned Node. A target is `guests/`, `http/` or `ws/`; a bare name is a // guest. No targets means every golden. // // node scripts/golden.mjs [--check] [target…] // Re-record each target and diff against its file. Exits 1 on any difference: run // it after a Node bump to see what changed. // node scripts/golden.mjs --check --node= [target…] // The same check under another Node (a candidate minor) without moving the pin. // Guests and apps run under ; the WebSocket client stays on this Node. Never // writes: goldens only ever hold the pinned Node's output. // node scripts/golden.mjs --write [target…] // Re-record and overwrite. A guest with no golden yet gets one: `--stdin=` pipes that text in then closes stdin, `--deps=a,b` lists extra files it // loads (both relative to tests/fixtures/guests). New http/ws goldens are written by // hand with their inputs (app, port, requests), then --write fills in the rest. import { spawnSync } from "node:child_process"; import { existsSync, readdirSync } from "node:fs"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { goldenPath, KINDS, listGoldens, maskTestDurations, readGolden, writeGolden } from "./golden-files.ts"; import { recordHttp } from "./golden-http.mjs"; import { recordWs } from "./golden-ws.mjs"; import { assertPinnedNode, oracleEnv } from "./pinned-node.mjs"; const ROOT = resolve(fileURLToPath(new URL("..", import.meta.url))); const GUESTS = resolve(ROOT, "tests/fixtures/guests"); const args = process.argv.slice(2); const flag = (name) => args.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3); const write = args.includes("--write"); const node = flag("node") ?? process.execPath; if (node === process.execPath) assertPinnedNode(); else if (write) throw new Error("--node only checks; goldens hold the pinned Node's output"); const version = spawnSync(node, ["-v"], { encoding: "utf8" }).stdout.trim(); /** Run a guest the way the kernel runs it: mounted at /app, with `stdin` piped then * closed. PIDs and the checkout path are masked. */ function recordGuest({ guest, deps, stdin, mask: maskKind }) { const before = new Set(readdirSync(GUESTS)); const r = spawnSync(node, [guest], { cwd: GUESTS, encoding: "utf8", input: stdin ?? "", env: oracleEnv() }); // Guests share this directory and some list it, so what one leaves changes another. const left = readdirSync(GUESTS).filter((n) => !before.has(n)); if (left.length) throw new Error(`${guest} left ${left.join(", ")} in tests/fixtures/guests; clean up at its end`); const mask = (text) => { let out = text.replaceAll(GUESTS, "/app").replace(/\(node:\d+\)/g, "(node:PID)"); if (maskKind === "test-durations") out = maskTestDurations(out); return out; }; return { guest, ...(deps ? { deps } : {}), ...(stdin !== undefined ? { stdin } : {}), ...(maskKind ? { mask: maskKind } : {}), node: version, code: r.status, ...(r.signal ? { signal: r.signal } : {}), stdout: mask(r.stdout), stderr: mask(r.stderr), }; } // Per kind: the input fields a recording starts from, how to record, and the output // fields compared as text (guest streams) or as pretty JSON (structured results). const KIND = { guests: { inputs: ["guest", "deps", "stdin", "mask"], record: async (inputs) => recordGuest(inputs), compared: { code: "value", signal: "value", stdout: "text", stderr: "text" }, }, http: { inputs: ["app", "port", "requests"], record: async (inputs) => ({ ...inputs, node: version, exchanges: await recordHttp(ROOT, inputs, node) }), compared: { exchanges: "json" }, }, ws: { inputs: ["app", "port"], record: async (inputs) => ({ ...inputs, node: version, log: await recordWs(ROOT, inputs, node) }), compared: { log: "json" }, }, }; /** Lines only in `a` as "-", only in `b` as "+", by longest common subsequence. */ function diffLines(a, b) { const x = a.split("\n"); const y = b.split("\n"); const lcs = Array.from({ length: x.length + 1 }, () => new Array(y.length + 1).fill(0)); for (let i = x.length - 1; i >= 0; i--) { for (let j = y.length - 1; j >= 0; j--) lcs[i][j] = x[i] === y[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]); } const out = []; let i = 0; let j = 0; while (i < x.length || j < y.length) { if (i < x.length && j < y.length && x[i] === y[j]) (i++, j++); else if (i < x.length && (j === y.length || lcs[i + 1][j] >= lcs[i][j + 1])) out.push(` - ${JSON.stringify(x[i++])}`); else out.push(` + ${JSON.stringify(y[j++])}`); } return out; } function differences(compared, want, got) { const out = []; for (const [key, how] of Object.entries(compared)) { if (how === "value") { if ((want[key] ?? null) !== (got[key] ?? null)) out.push(` ${key}: ${want[key] ?? null} -> ${got[key] ?? null}`); continue; } const [a, b] = how === "text" ? [want[key], got[key]] : [JSON.stringify(want[key], null, 2), JSON.stringify(got[key], null, 2)]; if (a !== b) out.push(` ${key}:`, ...diffLines(a, b)); } return out; } function parseTarget(t) { const slash = t.indexOf("/"); const kind = slash === -1 ? undefined : t.slice(0, slash); return KINDS.includes(kind) ? [kind, t.slice(slash + 1)] : ["guests", t]; } const named = args.filter((a) => !a.startsWith("--")); const targets = named.length ? named.map(parseTarget) : KINDS.flatMap((k) => listGoldens(k).map((n) => [k, n])); let changed = 0; for (const [kind, name] of targets) { const { inputs: fields, record, compared } = KIND[kind]; const exists = existsSync(goldenPath(kind, name)); if (!exists && (!write || kind !== "guests")) throw new Error(`no golden at ${goldenPath(kind, name)}`); const old = exists ? readGolden(kind, name) : undefined; const inputs = old ? Object.fromEntries(fields.filter((f) => old[f] !== undefined).map((f) => [f, old[f]])) : { guest: name, ...(flag("deps") ? { deps: flag("deps").split(",") } : {}), ...(flag("stdin") !== undefined ? { stdin: JSON.parse(flag("stdin")) } : {}), ...(flag("mask") ? { mask: flag("mask") } : {}) }; const got = await record(inputs); const diff = old ? differences(compared, old, got) : [" new"]; if (diff.length) { changed++; console.log(`${write ? "wrote" : "differs"} ${kind}/${name}`); console.log(diff.join("\n")); if (write) writeGolden(kind, name, got); } } console.log(`${targets.length} goldens, ${changed} ${write ? "written" : "differ"} (node ${version})`); if (changed && !write) process.exit(1);