#!/usr/bin/env node // THE E2E BUILD STAMP: the editor's and umtool's suites run against a // production build (`next start`), and this decides whether that build is the // tree under test. // // node scripts/e2e-stamp.mjs ensure rebuild if stale, then stamp // node scripts/e2e-stamp.mjs check say fresh or why stale (exit 0 / 1) // node scripts/e2e-stamp.mjs build rebuild and stamp, whatever the stamp says // // A start-mode run used to serve whatever `.next` held — a build of last week's // tree passed or failed for the wrong reason (plans/FACTS.md: "E2E_MODE=start // serves a stale build"). Now a build writes `/e2e-stamp.json`: a // FINGERPRINT of every file the build reads — the index's blob id for a clean // file, git's blob id of the working-tree bytes for a dirty or untracked one — over // the package, common/ and the lockfile, minus what no build reads (the e2e // specs and fixtures, unit tests). `ensure` recomputes it and rebuilds when it // differs, so a stale build is impossible rather than a rule to remember. It is // content, not HEAD: a commit that touches no built file (a plan, a record) // costs no rebuild, and an edit that is never committed still does. // // THE BUILD GOES THROUGH THE HEAVY SLOT under a 5 GB cap (AGENTS.md, "Heavy // work takes the heavy slot"): `queue-lock.mjs --heavy -- systemd-run --user // --scope -p MemoryMax=5G -p MemorySwapMax=0 …`. Inside an e2e run the slot is // already this run's (HEAVY_HELD), so it passes through; run by hand it waits // for the slot and the memory floor like any build. // // IT BUILDS INTO ITS OWN DIRECTORY, never `.next`: the primary checkout's // `.next` is what the live editor and umtool serve from, and replacing it under // a running `next start` breaks every route not yet loaded. The editor's // next.config.ts reads E2E_NEXT_DIST_DIR (`.next/e2e`, inside the ignored // `.next/`); umtool's already reads NEXT_DIST_DIR (`.next-e2e-start`, ignored by // `umtool/.next-*/`). import { execFileSync, spawnSync } from "node:child_process"; import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const SELF = fileURLToPath(import.meta.url); export const REPO_ROOT = path.resolve(path.dirname(SELF), ".."); export const STAMP_NAME = "e2e-stamp.json"; export const STAMP_VERSION = 1; // What each start-mode suite builds, where, and from what. `sources` are git // pathspecs relative to the repo root; `exclude` names what no build reads. const COMMON_EXCLUDES = [ ":(exclude,glob)**/*.test.ts", ":(exclude,glob)**/*.test.mjs", ":(exclude,glob)**/*.test.tsx", // Read at run time from EDITOR_CHANGELOG_FILE / EXPORT_CHANGELOG_FILE (the // test server's are gitignored copies), never imported — and every record // commit touches one. ":(exclude,glob)**/CHANGELOG.md", ]; export const PACKAGES = Object.freeze({ editor: Object.freeze({ dir: "editor", distDir: ".next/e2e", distEnv: "E2E_NEXT_DIST_DIR", sources: ["editor", "common", "pnpm-lock.yaml"], // The specs, the fake binaries and the fixture trees are read from the // checkout at run time, never from the build; the suite's config is // Playwright's. exclude: [":(exclude)editor/e2e", ":(exclude)editor/playwright.config.ts", ...COMMON_EXCLUDES], }), umtool: Object.freeze({ dir: "umtool", distDir: ".next-e2e-start", distEnv: "NEXT_DIST_DIR", sources: ["umtool", "common", "pnpm-lock.yaml"], exclude: [":(exclude)umtool/e2e", ":(exclude)umtool/playwright.config.ts", ...COMMON_EXCLUDES], }), }); // ------------------------------------------------------------------ the mode /** * The suite's server mode: "start" (the default — a production build, stamped) * or "dev" (`next dev`, for iterating on one spec with no rebuild per edit). * Anything else is refused rather than guessed at. */ export function resolveE2EMode(env = process.env) { const raw = String(env.E2E_MODE ?? "").trim().toLowerCase(); if (raw === "" || raw === "start") return "start"; if (raw === "dev") return "dev"; throw new Error(`E2E_MODE=${env.E2E_MODE} is not a mode: use start (the default) or dev`); } // --------------------------------------------------------------- fingerprint function gitRunner(cwd) { return (args) => execFileSync("git", args, { cwd, encoding: "buffer", maxBuffer: 256 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"], }); } function splitZ(buf) { return buf .toString("utf8") .split("\0") .filter((s) => s.length > 0); } // A dirty path's id in the index's own terms — git's blob id of its bytes (a // link's of its target) — so an edit and the commit of that edit fingerprint // the same, and only a path that is gone reads "deleted". (A repository on // sha256 object ids would never match its index ids here: one extra rebuild, // never a stale one.) function gitBlobId(bytes) { return crypto .createHash("sha1") .update(`blob ${bytes.length}\0`) .update(bytes) .digest("hex"); } function worktreeId(root, rel) { const abs = path.join(root, rel); let st; try { st = fs.lstatSync(abs); } catch { return "deleted"; } if (st.isSymbolicLink()) return `blob:${gitBlobId(Buffer.from(fs.readlinkSync(abs)))}`; if (st.isDirectory()) return "dir"; return `blob:${gitBlobId(fs.readFileSync(abs))}`; } // The top-level part a path belongs to ("editor", "common", "pnpm-lock.yaml"), // so a stale stamp can say WHERE the tree moved. function partOf(rel, sources) { return sources.find((s) => rel === s || rel.startsWith(`${s}/`)) ?? rel.split("/")[0]; } /** * Fingerprint the files a build of `pkg` reads. Every input is a seam the unit * tests use: `root` (any git checkout), `spec` (a PACKAGES-shaped entry). */ export function computeFingerprint({ root = REPO_ROOT, spec, git = gitRunner(root) }) { const pathspec = ["--", ...spec.sources, ...spec.exclude]; const ids = new Map(); // The index: blob ids, one per staged path (stage 0; a conflicted path's // stages all land under one name, which is fine — it is dirty anyway). for (const line of splitZ(git(["ls-files", "-s", "-z", ...pathspec]))) { const tab = line.indexOf("\t"); const [, blob] = line.slice(0, tab).split(" "); ids.set(line.slice(tab + 1), `blob:${blob}`); } // Dirty against the index, and untracked-not-ignored: their bytes. const dirty = new Set([ ...splitZ(git(["diff", "--name-only", "-z", ...pathspec])), ...splitZ(git(["ls-files", "--others", "--exclude-standard", "-z", ...pathspec])), ]); for (const rel of dirty) ids.set(rel, worktreeId(root, rel)); const parts = {}; const sorted = [...ids.keys()].sort(); const whole = crypto.createHash("sha256"); for (const rel of sorted) { const line = `${rel}\0${ids.get(rel)}\n`; whole.update(line); const part = partOf(rel, spec.sources); (parts[part] ??= crypto.createHash("sha256")).update(line); } let head = null; try { head = git(["rev-parse", "HEAD"]).toString("utf8").trim(); } catch { /* an unborn branch: informational only */ } return { version: STAMP_VERSION, key: whole.digest("hex"), parts: Object.fromEntries(Object.entries(parts).map(([k, h]) => [k, h.digest("hex")])), head, files: sorted.length, dirty: [...dirty].sort(), }; } // ------------------------------------------------------------ read + decide export function stampPath(spec, root = REPO_ROOT) { return path.join(root, spec.dir, spec.distDir, STAMP_NAME); } /** The recorded stamp, or null when it is missing or not a stamp. */ export function readStamp(file) { let raw; try { raw = fs.readFileSync(file, "utf8"); } catch { return null; } try { const s = JSON.parse(raw); return s && typeof s === "object" && typeof s.key === "string" ? s : { garbled: true }; } catch { return { garbled: true }; } } export function writeStamp(file, fingerprint, extra = {}) { const tmp = `${file}.${process.pid}.tmp`; fs.writeFileSync( tmp, `${JSON.stringify({ ...fingerprint, builtAt: new Date().toISOString(), ...extra }, null, 2)}\n`, ); fs.renameSync(tmp, file); } /** * Whether the build under `distAbs` is the tree `current` describes. * Returns `{fresh: true}` or `{fresh: false, reason}` — the reason is printed. */ export function decide({ recorded, current, buildIdPresent = true }) { if (recorded == null) return { fresh: false, reason: "no build stamp here yet" }; if (recorded.garbled) return { fresh: false, reason: "the build stamp is unreadable" }; if (recorded.version !== current.version) { return { fresh: false, reason: `the build stamp is version ${recorded.version}, not ${current.version}` }; } if (!buildIdPresent) return { fresh: false, reason: "the stamped build directory has no BUILD_ID" }; if (recorded.key === current.key) return { fresh: true }; const moved = Object.keys({ ...recorded.parts, ...current.parts }) .filter((p) => recorded.parts?.[p] !== current.parts[p]) .sort(); const where = moved.length ? moved.join(", ") : "the tree"; // An uncommitted file in a part that moved, when there is one: the likeliest // reason, named. const inMoved = current.dirty.filter((f) => moved.some((p) => f === p || f.startsWith(`${p}/`))); const dirty = inMoved.length ? ` (${inMoved.length} uncommitted file${inMoved.length === 1 ? "" : "s"} there, e.g. ${inMoved[0]})` : ""; return { fresh: false, reason: `${where} changed since the build${dirty}` }; } export function checkPackage(name, { root = REPO_ROOT } = {}) { const spec = PACKAGES[name]; if (!spec) throw new Error(`no start-mode build for '${name}' (one of: ${Object.keys(PACKAGES).join(", ")})`); const current = computeFingerprint({ root, spec }); const recorded = readStamp(stampPath(spec, root)); const buildIdPresent = fs.existsSync(path.join(root, spec.dir, spec.distDir, "BUILD_ID")); return { spec, current, recorded, ...decide({ recorded, current, buildIdPresent }) }; } // -------------------------------------------------------------------- build // The 5 GB cap, when this host can give one. A container or a host with no // user systemd builds uncapped, and says so. function memoryCapPrefix(log) { const probe = spawnSync("systemd-run", ["--user", "--scope", "-q", "--", "true"], { stdio: "ignore" }); if (probe.status === 0) { return ["systemd-run", "--user", "--scope", "-q", "-p", "MemoryMax=5G", "-p", "MemorySwapMax=0"]; } log("e2e build: no user systemd here — building without the 5 GB cap"); return []; } export function buildPackage(name, { root = REPO_ROOT, log = (l) => process.stderr.write(`${l}\n`) } = {}) { const spec = PACKAGES[name]; const before = computeFingerprint({ root, spec }); const distAbs = path.join(root, spec.dir, spec.distDir); const cmd = [ process.execPath, path.join(root, "scripts", "queue-lock.mjs"), "--heavy", "--", ...memoryCapPrefix(log), "pnpm", "--filter", name, "exec", "next", "build", ]; log(`e2e build: next build → ${spec.dir}/${spec.distDir} (heavy slot, 5 GB cap)`); const t0 = Date.now(); const r = spawnSync(cmd[0], cmd.slice(1), { cwd: root, stdio: "inherit", env: { ...process.env, [spec.distEnv]: spec.distDir }, }); const secs = Math.round((Date.now() - t0) / 1000); if (r.status !== 0) { log(`e2e build: FAILED after ${secs}s (exit ${r.status ?? r.signal}) — no stamp written`); return { ok: false, secs }; } // The stamp names the tree the build STARTED from: an edit made while it ran // makes the next run rebuild instead of trusting a build that missed it. fs.mkdirSync(distAbs, { recursive: true }); writeStamp(path.join(distAbs, STAMP_NAME), before, { buildSeconds: secs }); const after = computeFingerprint({ root, spec }); if (after.key !== before.key) log("e2e build: the tree moved during the build — the next run rebuilds"); log(`e2e build: done in ${secs}s, stamped ${before.key.slice(0, 12)} (HEAD ${String(before.head).slice(0, 8)})`); return { ok: true, secs }; } /** Rebuild when stale; returns true when the build is the tree under test. */ export function ensurePackage(name, { root = REPO_ROOT, log = (l) => process.stderr.write(`${l}\n`), env = process.env } = {}) { if (resolveE2EMode(env) === "dev") { log(`e2e build: E2E_MODE=dev — ${name} runs under next dev, no build`); return true; } const c = checkPackage(name, { root }); if (c.fresh) { log( `e2e build: ${c.spec.dir}/${c.spec.distDir} is the tree under test ` + `(stamp ${c.current.key.slice(0, 12)}, built ${c.recorded.builtAt ?? "?"}) — no rebuild`, ); return true; } log(`e2e build: rebuilding ${name} — ${c.reason}`); return buildPackage(name, { root, log }).ok; } // ---------------------------------------------------------------------- CLI function main(argv) { const [verb, name] = argv; const log = (l) => process.stderr.write(`${l}\n`); if (!["ensure", "check", "build"].includes(verb) || !PACKAGES[name]) { log(`usage: e2e-stamp.mjs <${Object.keys(PACKAGES).join("|")}>`); return 2; } if (verb === "check") { const c = checkPackage(name); log(c.fresh ? `${name}: fresh (stamp ${c.current.key.slice(0, 12)})` : `${name}: stale — ${c.reason}`); return c.fresh ? 0 : 1; } if (verb === "build") return buildPackage(name, { log }).ok ? 0 : 1; return ensurePackage(name, { log }) ? 0 : 1; } if (process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(SELF)) { try { process.exit(main(process.argv.slice(2))); } catch (err) { process.stderr.write(`${err?.message ?? err}\n`); process.exit(1); } }