commit b6a859055fd100d0925a1c9895e764d77e567e83
parent 7f91177c75088089fdf8556b05ca9af26d5a5c89
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sat, 10 Oct 2026 00:13:49 -0400
e2e: start mode by default, with a build stamp that cannot be stale (e2e speed S1)
The editor and umtool suites run against `next start` unless E2E_MODE=dev.
Before any server starts, scripts/e2e-stamp.mjs fingerprints what the build
reads (index blob ids, and the working-tree bytes of every dirty or untracked
file, over the package, common/ and the lockfile, minus specs and unit tests)
and rebuilds through the heavy slot under the 5 GB cap when the stamp differs,
saying why. The build goes to its own directory (editor .next/e2e via
E2E_NEXT_DIST_DIR, umtool .next-e2e-start), never the .next a running app
serves from. CI (the sharded image) keeps its own build.
The export (default, hub) and homepage suites accept the switch and stay on
next dev, saying so: they are static exports whose specs rewrite fixtures
mid-run. The editor suite's export server stays next dev for the same reason.
resetData retries its fixture copy on EEXIST (release 19's channel-work.spec:208).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
11 files changed, 705 insertions(+), 24 deletions(-)
diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md
@@ -183,7 +183,9 @@ Read only by a test harness, a fake binary or a test-mode branch. Never set one
| Variable | Default | What it does | Read by |
|---|---|---|---|
| `E2E_TEST_ROUTES` | off | `1` opens the editor's `/api/test/*` routes and marks a test server at boot. Set by `editor/playwright.config.ts` on its test server, and by nothing else. | editor/app/api/test/_guard.ts, editor/instrumentation.ts |
-| `E2E_MODE` | dev | `start` runs the editor suite against `next start` instead of `next dev`. | editor/playwright.config.ts |
+| `E2E_MODE` | `start` | `start`: the editor and umtool suites run against `next start` from a build `scripts/e2e-stamp.mjs` vouches for, rebuilt through the heavy slot when the tree moved. `dev`: against `next dev`, for iterating on one spec. The export and homepage suites always run `next dev` (static exports) and say so when asked for `start`. | editor/playwright.config.ts, umtool/playwright.config.ts, scripts/e2e-stamp.mjs |
+| `E2E_NEXT_DIST_DIR` | `.next` | The editor's build directory for the e2e suite's start mode (`.next/e2e`), so a test build never replaces the `.next` a running editor serves from. Set by `scripts/e2e-stamp.mjs` for its build and by `editor/playwright.config.ts` for its test server. | editor/next.config.ts |
+| `E2E_BUILD_CHECKED` | — | Set by a suite's config once it has checked the start-mode build's stamp, so a worker's second load of the config does not check again. | editor/playwright.config.ts, umtool/playwright.config.ts |
| `E2E_QUEUE` | on | `0` skips the machine-global e2e queue (the port check still runs). | scripts/queue-lock.mjs |
| `E2E_PORT_CHECK` | on | `0` skips the pre-run check that the suite's ports are free. | scripts/queue-lock.mjs |
| `E2E_QUEUE_TIMEOUT` | wait forever | Seconds to wait for the queue before giving up. | scripts/queue-lock.mjs |
diff --git a/common/lib/envVars.ts b/common/lib/envVars.ts
@@ -181,7 +181,9 @@ const DECLARED: EnvVarDecl[] = [
// ── test: harnesses, fakes and test-mode branches ──────────────────────
{ name: "E2E_TEST_ROUTES", audience: "test", default: "off", readBy: "editor/app/api/test/_guard.ts, editor/instrumentation.ts", doc: "`1` opens the editor's `/api/test/*` routes and marks a test server at boot. Set by `editor/playwright.config.ts` on its test server, and by nothing else." },
- { name: "E2E_MODE", audience: "test", default: "dev", readBy: "editor/playwright.config.ts", doc: "`start` runs the editor suite against `next start` instead of `next dev`." },
+ { name: "E2E_MODE", audience: "test", default: "`start`", readBy: "editor/playwright.config.ts, umtool/playwright.config.ts, scripts/e2e-stamp.mjs", doc: "`start`: the editor and umtool suites run against `next start` from a build `scripts/e2e-stamp.mjs` vouches for, rebuilt through the heavy slot when the tree moved. `dev`: against `next dev`, for iterating on one spec. The export and homepage suites always run `next dev` (static exports) and say so when asked for `start`." },
+ { name: "E2E_NEXT_DIST_DIR", audience: "test", default: "`.next`", readBy: "editor/next.config.ts", doc: "The editor's build directory for the e2e suite's start mode (`.next/e2e`), so a test build never replaces the `.next` a running editor serves from. Set by `scripts/e2e-stamp.mjs` for its build and by `editor/playwright.config.ts` for its test server." },
+ { name: "E2E_BUILD_CHECKED", audience: "test", default: "—", readBy: "editor/playwright.config.ts, umtool/playwright.config.ts", doc: "Set by a suite's config once it has checked the start-mode build's stamp, so a worker's second load of the config does not check again." },
{ name: "E2E_QUEUE", audience: "test", default: "on", readBy: "scripts/queue-lock.mjs", doc: "`0` skips the machine-global e2e queue (the port check still runs)." },
{ name: "E2E_PORT_CHECK", audience: "test", default: "on", readBy: "scripts/queue-lock.mjs", doc: "`0` skips the pre-run check that the suite's ports are free." },
{ name: "E2E_QUEUE_TIMEOUT", audience: "test", default: "wait forever", readBy: "scripts/queue-lock.mjs", doc: "Seconds to wait for the queue before giving up." },
diff --git a/editor/e2e/helpers.ts b/editor/e2e/helpers.ts
@@ -51,25 +51,44 @@ export async function resetData(fixtureName: string | null = null) {
// Node retries the whole operation with linear backoff on exactly that errno
// set (also EBUSY/EPERM). Observed as two unrelated-looking full-suite
// failures at channel-work.spec and pipeline.spec:106; both pass in isolation.
- await fetch(`${baseUrl}/api/test/invalidate-cache`).catch(() => {});
- await rm(testTranscriptsDir, {
- recursive: true,
- force: true,
- maxRetries: 10,
- retryDelay: 100,
- });
- await rm(testSettingsFile, { force: true });
- await cp(defaultTestSettingsFile, testSettingsFile);
- if (fixtureName) {
- const src = join(fixturesRoot, "test-transcripts", fixtureName);
- if (!(await fileExists(src))) {
- throw new Error(`Fixture not found: ${fixtureName}`);
+ const src = fixtureName
+ ? join(fixturesRoot, "test-transcripts", fixtureName)
+ : null;
+ if (src && !(await fileExists(src))) {
+ throw new Error(`Fixture not found: ${fixtureName}`);
+ }
+ // THE COPY IS RETRIED ON EEXIST. fs.cp makes each directory with a plain
+ // mkdir after finding it absent, so a write still landing from the previous
+ // spec's work (a job log, the auto-queue state — anything that mkdirs under
+ // the data root) can create the directory in between. Release 19's
+ // start-mode gate run lost channel-work.spec:208 to exactly that ("EEXIST:
+ // file already exists, mkdir '…/test-transcripts/channels'"), and an earlier
+ // run no-subs-fallback.spec:114. Quiesce again, clear again, copy again.
+ for (let attempt = 1; ; attempt++) {
+ await fetch(`${baseUrl}/api/test/invalidate-cache`).catch(() => {});
+ await rm(testTranscriptsDir, {
+ recursive: true,
+ force: true,
+ maxRetries: 10,
+ retryDelay: 100,
+ });
+ try {
+ if (src) {
+ await mkdir(testTranscriptsDir, { recursive: true });
+ await cp(src, testTranscriptsDir, { recursive: true });
+ } else {
+ await mkdir(join(testTranscriptsDir, "channels"), { recursive: true });
+ }
+ break;
+ } catch (err) {
+ if ((err as NodeJS.ErrnoException).code !== "EEXIST" || attempt >= 4) {
+ throw err;
+ }
+ await new Promise((r) => setTimeout(r, 100 * attempt));
}
- await mkdir(testTranscriptsDir, { recursive: true });
- await cp(src, testTranscriptsDir, { recursive: true });
- } else {
- await mkdir(join(testTranscriptsDir, "channels"), { recursive: true });
}
+ await rm(testSettingsFile, { force: true });
+ await cp(defaultTestSettingsFile, testSettingsFile);
await fetch(`${baseUrl}/api/test/invalidate-cache`).catch(() => {});
}
diff --git a/editor/next.config.ts b/editor/next.config.ts
@@ -11,6 +11,10 @@ const siteQuery = {
} as const;
const nextConfig: NextConfig = {
+ // The e2e suite's start mode builds into its own directory (scripts/
+ // e2e-stamp.mjs), so a rebuild for a test run never replaces the build a
+ // running editor serves from `.next`. Unset everywhere else.
+ distDir: process.env.E2E_NEXT_DIST_DIR || ".next",
turbopack: {
// Pin the workspace root. Turbopack infers it by walking up for a lockfile
// and taking the outermost one, so an unrelated pnpm-lock.yaml anywhere
diff --git a/editor/playwright.config.ts b/editor/playwright.config.ts
@@ -1,3 +1,4 @@
+import { spawnSync } from "node:child_process";
import path from "node:path";
import { defineConfig, devices } from "@playwright/test";
import { portFor } from "yt-dlp-transcript-common/lib/ports.mjs";
@@ -16,7 +17,18 @@ import { portFor } from "yt-dlp-transcript-common/lib/ports.mjs";
// shrink the mid-download audio check so a spec
// sees it fire (common/ytdlp/audioCheckedDownload.ts)
// E2E_AUDIO_CHECK_DEBUG_PAUSE_MS a debugging pause in the same check
-// E2E_MODE=start run against `next start` instead of `next dev`
+// E2E_MODE `start` (the default): the editor runs under
+// `next start` from a build the stamp vouches for
+// (scripts/e2e-stamp.mjs — rebuilt through the
+// heavy slot when the tree moved). `dev`: under
+// `next dev`, for iterating on one spec with no
+// rebuild per edit
+// E2E_NEXT_DIST_DIR set below on a start-mode server: the build
+// directory (`.next/e2e`) editor/next.config.ts
+// reads, so a test build never replaces `.next`
+// E2E_BUILD_CHECKED set below once the stamp was checked, so the
+// config's second load (a worker's) does not check
+// again
// E2E_RACK_SHOTS=1 run the /channels rack screenshot audit
// E2E_FAKE_YTDLP_AUDIO_CHECK_MODE, E2E_FAKE_YTDLP_CHUNK_DELAY_MS,
// E2E_FAKE_YTDLP_CORRUPT_AFTER_CHUNK, E2E_FAKE_YTDLP_CORRUPT_RUNS,
@@ -85,8 +97,39 @@ const baseURL = `http://localhost:${PORT}`;
// test API). Keeps offset-port worktrees working even when run directly without
// the worktree wrapper. See e2e/baseUrl.ts.
process.env.PLAYWRIGHT_BASE_URL = baseURL;
-const webServerCommand =
- process.env.E2E_MODE === "start" ? "pnpm start:test" : "pnpm dev:test";
+
+// START MODE BY DEFAULT (plans/e2e-speed.md, S1): the same tests took 0.32× the
+// time under `next start` — a polled route recompiles under `next dev`. Before
+// any server starts, scripts/e2e-stamp.mjs compares the build's stamp with the
+// tree and rebuilds when they differ (it says which, and why), so a start-mode
+// run cannot serve a stale build. CI — the sharded image, which runs its own
+// `next build` into `.next` — keeps that build and skips the stamp.
+const E2E_MODE = (() => {
+ const raw = (process.env.E2E_MODE ?? "").trim().toLowerCase();
+ if (raw === "" || raw === "start") return "start";
+ if (raw === "dev") return "dev";
+ throw new Error(`E2E_MODE=${process.env.E2E_MODE} is not a mode: use start (the default) or dev`);
+})();
+const STAMPED_BUILD = E2E_MODE === "start" && !process.env.CI;
+const E2E_DIST_DIR = ".next/e2e";
+if (STAMPED_BUILD && !process.env.E2E_BUILD_CHECKED) {
+ const ensured = spawnSync(
+ process.execPath,
+ [path.resolve(process.cwd(), "..", "scripts", "e2e-stamp.mjs"), "ensure", "editor"],
+ { stdio: "inherit" },
+ );
+ if (ensured.status !== 0) {
+ throw new Error(
+ "e2e: the editor's start-mode build failed (scripts/e2e-stamp.mjs ensure editor). " +
+ "Fix the build, or run this spec under E2E_MODE=dev.",
+ );
+ }
+ process.env.E2E_BUILD_CHECKED = "1";
+}
+const webServerCommand = E2E_MODE === "start" ? "pnpm start:test" : "pnpm dev:test";
+const webServerEnv: Record<string, string> = STAMPED_BUILD
+ ? { ...E2E_SERVER_ENV, E2E_NEXT_DIST_DIR: E2E_DIST_DIR }
+ : E2E_SERVER_ENV;
// The digest lane's local engine is reached over HTTP, not spawned, so it gets a
// webServer entry instead of a fake binary in e2e/fixtures/bin/. Its port is
@@ -156,9 +199,15 @@ export default defineConfig({
url: baseURL,
timeout: 120_000,
reuseExistingServer: !process.env.CI,
- env: E2E_SERVER_ENV,
+ env: webServerEnv,
},
{
+ // The export server stays `next dev` in both modes. The export is a
+ // STATIC export (output: "export"): a build bakes test-settings.json and
+ // the fixture site in at build time, so export-search.spec's settings
+ // rewrite would be asserted against a page that cannot see it; and the
+ // two spec files that use this server ran 42 s in all in release 19's
+ // start-mode run — less than one export build costs.
command: `pnpm --filter export run dev --port ${EXPORT_PORT}`,
url: exportBaseURL,
timeout: 120_000,
diff --git a/export/playwright.config.ts b/export/playwright.config.ts
@@ -63,6 +63,26 @@ if (fs.existsSync(SW_SRC)) {
fs.copyFileSync(SW_SRC, SW_DEST);
}
+// E2E_MODE (start | dev), the editor's and umtool's switch: those suites
+// default to `start`, a production build the stamp vouches for
+// (scripts/e2e-stamp.mjs). This suite always runs under `next dev`: the export is a
+// STATIC export (output: "export"), and several of its spec files rewrite the
+// fixture site or settings mid-run and assert the page that follows — a build
+// would have baked the old ones in.
+// `E2E_MODE=start` is accepted and says so; anything else is refused.
+{
+ const mode = (process.env.E2E_MODE ?? "").trim().toLowerCase();
+ if (mode !== "" && mode !== "start" && mode !== "dev") {
+ throw new Error(`E2E_MODE=${process.env.E2E_MODE} is not a mode: use start or dev`);
+ }
+ if (mode === "start" && !process.env.E2E_BUILD_CHECKED) {
+ process.stderr.write(
+ "export e2e: E2E_MODE=start has no build to run here (a static export bakes its fixtures in at build time) — running next dev\n",
+ );
+ process.env.E2E_BUILD_CHECKED = "1";
+ }
+}
+
export default defineConfig({
testDir: "./e2e",
timeout: 30_000,
diff --git a/export/playwright.hub.config.ts b/export/playwright.hub.config.ts
@@ -24,6 +24,26 @@ buildFixtureSettings(TEST_SETTINGS_FILE);
// from a killed run before any spec reads the hub's config.
fs.rmSync(path.join(TEST_SITES_DIR, "_homepage"), { recursive: true, force: true });
+// E2E_MODE (start | dev), the editor's and umtool's switch: those suites
+// default to `start`, a production build the stamp vouches for
+// (scripts/e2e-stamp.mjs). This suite always runs under `next dev`: the export is a
+// STATIC export (output: "export"), and the hub specs rewrite fixture sites
+// mid-run and assert the page that follows — a build would have baked the
+// old ones in.
+// `E2E_MODE=start` is accepted and says so; anything else is refused.
+{
+ const mode = (process.env.E2E_MODE ?? "").trim().toLowerCase();
+ if (mode !== "" && mode !== "start" && mode !== "dev") {
+ throw new Error(`E2E_MODE=${process.env.E2E_MODE} is not a mode: use start or dev`);
+ }
+ if (mode === "start" && !process.env.E2E_BUILD_CHECKED) {
+ process.stderr.write(
+ "export hub e2e: E2E_MODE=start has no build to run here (a static export bakes its fixtures in at build time) — running next dev\n",
+ );
+ process.env.E2E_BUILD_CHECKED = "1";
+ }
+}
+
export default defineConfig({
testDir: "./e2e-hub",
timeout: 30_000,
diff --git a/homepage/playwright.config.ts b/homepage/playwright.config.ts
@@ -68,6 +68,26 @@ fs.mkdirSync(FIXTURE_SITES_DIR, { recursive: true });
const FIXTURE_SOURCE_DIR = path.resolve(process.cwd(), "e2e", FIXTURE_SOURCE_NAME);
clearFixtureSource(FIXTURE_SOURCE_DIR);
+// E2E_MODE (start | dev), the editor's and umtool's switch: those suites
+// default to `start`, a production build the stamp vouches for
+// (scripts/e2e-stamp.mjs). This suite always runs under `next dev`: the homepage is a
+// STATIC export, and its fixture summary and fixture publish
+// (E2E_HOMEPAGE_SUMMARY_FILE, E2E_SOURCE_PUBLIC_DIR) are read only outside a
+// production build, by design.
+// `E2E_MODE=start` is accepted and says so; anything else is refused.
+{
+ const mode = (process.env.E2E_MODE ?? "").trim().toLowerCase();
+ if (mode !== "" && mode !== "start" && mode !== "dev") {
+ throw new Error(`E2E_MODE=${process.env.E2E_MODE} is not a mode: use start or dev`);
+ }
+ if (mode === "start" && !process.env.E2E_BUILD_CHECKED) {
+ process.stderr.write(
+ "homepage e2e: E2E_MODE=start has no build to run here (a static export bakes its fixtures in at build time) — running next dev\n",
+ );
+ process.env.E2E_BUILD_CHECKED = "1";
+ }
+}
+
export default defineConfig({
testDir: "./e2e",
timeout: 30_000,
diff --git a/scripts/e2e-stamp.mjs b/scripts/e2e-stamp.mjs
@@ -0,0 +1,342 @@
+#!/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 <editor|umtool> rebuild if stale, then stamp
+// node scripts/e2e-stamp.mjs check <editor|umtool> say fresh or why stale (exit 0 / 1)
+// node scripts/e2e-stamp.mjs build <editor|umtool> 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 `<distDir>/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",
+];
+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 <ensure|check|build> <${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);
+ }
+}
diff --git a/scripts/e2e-stamp.test.mjs b/scripts/e2e-stamp.test.mjs
@@ -0,0 +1,165 @@
+// scripts/e2e-stamp.mjs — the start-mode build stamp. Every case runs over a
+// throwaway git checkout, so nothing here reads or builds the real tree.
+import assert from "node:assert/strict";
+import { execFileSync } from "node:child_process";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { after, test } from "node:test";
+import {
+ STAMP_VERSION,
+ computeFingerprint,
+ decide,
+ ensurePackage,
+ readStamp,
+ resolveE2EMode,
+ writeStamp,
+} from "./e2e-stamp.mjs";
+
+const SPEC = {
+ dir: "app",
+ distDir: ".next/e2e",
+ sources: ["app", "common", "pnpm-lock.yaml"],
+ exclude: [":(exclude)app/e2e", ":(exclude,glob)**/*.test.ts"],
+};
+
+const made = [];
+after(() => {
+ for (const dir of made) fs.rmSync(dir, { recursive: true, force: true });
+});
+
+function repo() {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "e2e-stamp-test-"));
+ made.push(root);
+ const git = (...args) => execFileSync("git", args, { cwd: root, stdio: "pipe" });
+ git("init", "-q");
+ git("config", "user.email", "test@example.invalid");
+ git("config", "user.name", "test");
+ git("config", "commit.gpgsign", "false");
+ const write = (rel, text) => {
+ fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
+ fs.writeFileSync(path.join(root, rel), text);
+ };
+ write(".gitignore", "**/.next/\n");
+ write("app/page.tsx", "export default 1;\n");
+ write("app/lib/a.ts", "export const a = 1;\n");
+ write("app/e2e/x.spec.ts", "// a spec\n");
+ write("app/lib/a.test.ts", "// a unit test\n");
+ write("common/lib/b.ts", "export const b = 2;\n");
+ write("pnpm-lock.yaml", "lockfileVersion: '9.0'\n");
+ write("plans/notes.md", "a plan\n");
+ git("add", "-A");
+ git("commit", "-q", "-m", "one");
+ return { root, git, write };
+}
+
+const fp = (root) => computeFingerprint({ root, spec: SPEC });
+
+test("the same tree gives the same stamp, and the stamp is fresh", () => {
+ const { root } = repo();
+ const a = fp(root);
+ const b = fp(root);
+ assert.equal(a.key, b.key);
+ assert.equal(a.version, STAMP_VERSION);
+ assert.deepEqual(a.dirty, []);
+ assert.deepEqual(decide({ recorded: a, current: b }), { fresh: true });
+});
+
+test("a dirty edit under common/ changes the stamp and names where", () => {
+ const { root, write } = repo();
+ const built = fp(root);
+ write("common/lib/b.ts", "export const b = 3;\n");
+ const now = fp(root);
+ assert.notEqual(now.key, built.key);
+ assert.deepEqual(now.dirty, ["common/lib/b.ts"]);
+ const d = decide({ recorded: built, current: now });
+ assert.equal(d.fresh, false);
+ assert.match(d.reason, /^common changed since the build \(1 uncommitted file there, e\.g\. common\/lib\/b\.ts\)$/);
+});
+
+test("committing the edit keeps the new stamp (content, not HEAD)", () => {
+ const { root, git, write } = repo();
+ write("app/lib/a.ts", "export const a = 9;\n");
+ const dirty = fp(root);
+ git("commit", "-qam", "two");
+ const committed = fp(root);
+ assert.equal(committed.key, dirty.key);
+ assert.deepEqual(committed.dirty, []);
+});
+
+test("an untracked file and a deletion count; a staged edit counts", () => {
+ const { root, git, write } = repo();
+ const built = fp(root);
+ write("app/lib/new.ts", "export {};\n");
+ assert.notEqual(fp(root).key, built.key);
+ fs.rmSync(path.join(root, "app/lib/new.ts"));
+ assert.equal(fp(root).key, built.key);
+ fs.rmSync(path.join(root, "app/lib/a.ts"));
+ const gone = fp(root);
+ assert.notEqual(gone.key, built.key);
+ assert.deepEqual(gone.dirty, ["app/lib/a.ts"]);
+ git("checkout", "--", "app/lib/a.ts");
+ write("app/page.tsx", "export default 2;\n");
+ git("add", "app/page.tsx");
+ assert.notEqual(fp(root).key, built.key);
+});
+
+test("what no build reads moves nothing: specs, unit tests, plans, ignored output", () => {
+ const { root, git, write } = repo();
+ const built = fp(root);
+ write("app/e2e/x.spec.ts", "// edited\n");
+ write("app/lib/a.test.ts", "// edited\n");
+ write("plans/notes.md", "edited\n");
+ write("app/.next/e2e/BUILD_ID", "abc\n");
+ assert.equal(fp(root).key, built.key);
+ git("commit", "-qam", "specs only");
+ assert.equal(fp(root).key, built.key);
+});
+
+test("a missing, garbled or foreign-version stamp means rebuild", () => {
+ const { root } = repo();
+ const current = fp(root);
+ const file = path.join(root, "stamp.json");
+ assert.equal(readStamp(file), null);
+ assert.deepEqual(decide({ recorded: readStamp(file), current }), {
+ fresh: false,
+ reason: "no build stamp here yet",
+ });
+ fs.writeFileSync(file, "{not json");
+ assert.deepEqual(decide({ recorded: readStamp(file), current }), {
+ fresh: false,
+ reason: "the build stamp is unreadable",
+ });
+ fs.writeFileSync(file, JSON.stringify({ hello: 1 }));
+ assert.equal(decide({ recorded: readStamp(file), current }).fresh, false);
+ writeStamp(file, { ...current, version: STAMP_VERSION + 1 });
+ assert.match(decide({ recorded: readStamp(file), current }).reason, /^the build stamp is version/);
+});
+
+test("a stamp written for this tree reads back fresh; a build dir without BUILD_ID does not", () => {
+ const { root } = repo();
+ const current = fp(root);
+ const file = path.join(root, "stamp.json");
+ writeStamp(file, current, { buildSeconds: 1 });
+ const recorded = readStamp(file);
+ assert.equal(recorded.buildSeconds, 1);
+ assert.deepEqual(decide({ recorded, current }), { fresh: true });
+ assert.deepEqual(decide({ recorded, current, buildIdPresent: false }), {
+ fresh: false,
+ reason: "the stamped build directory has no BUILD_ID",
+ });
+});
+
+test("E2E_MODE: start by default, dev on request, anything else refused", () => {
+ assert.equal(resolveE2EMode({}), "start");
+ assert.equal(resolveE2EMode({ E2E_MODE: "" }), "start");
+ assert.equal(resolveE2EMode({ E2E_MODE: "start" }), "start");
+ assert.equal(resolveE2EMode({ E2E_MODE: "DEV" }), "dev");
+ assert.throws(() => resolveE2EMode({ E2E_MODE: "prod" }), /not a mode/);
+});
+
+test("ensure under E2E_MODE=dev builds nothing and says so", () => {
+ const lines = [];
+ assert.equal(ensurePackage("editor", { env: { E2E_MODE: "dev" }, log: (l) => lines.push(l) }), true);
+ assert.deepEqual(lines, ["e2e build: E2E_MODE=dev — editor runs under next dev, no build"]);
+});
diff --git a/umtool/playwright.config.ts b/umtool/playwright.config.ts
@@ -1,4 +1,5 @@
import { defineConfig, devices } from "@playwright/test";
+import { spawnSync } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { portFor } from "yt-dlp-transcript-common/lib/ports.mjs";
@@ -12,9 +13,45 @@ import { portFor } from "yt-dlp-transcript-common/lib/ports.mjs";
// it was asked (e2e/fixtures/editor-stub.mjs)
// E2E_UMTOOL_EXTRA_KINDS set by projects.spec.ts on the CLI it runs: a kind
// injected into the registry (lib/projects/kinds.mjs)
+// E2E_MODE `start` (the default): the app runs under
+// `next start` from a build in .next-e2e-start that
+// scripts/e2e-stamp.mjs vouches for (rebuilt through
+// the heavy slot when the tree moved). `dev`: under
+// `next dev` in .next-e2e, no rebuild per edit
+// E2E_BUILD_CHECKED set below once the stamp was checked, so a
+// worker's load of this config does not check again
// The ports are common/lib/ports.mjs's (offset per worktree by
// scripts/worktree.mjs), not test-only, and keep their names.
const PORT = portFor("UMTOOL_E2E_PORT");
+
+// START MODE BY DEFAULT, as the editor suite (plans/e2e-speed.md, S1): `next
+// dev` compiles each route on first use and again after a change, and the
+// suite was 26.5 min under it in release 19's gate. The build is its OWN
+// directory — never .next, which the live umtool serves from.
+const E2E_MODE = (() => {
+ const raw = (process.env.E2E_MODE ?? "").trim().toLowerCase();
+ if (raw === "" || raw === "start") return "start";
+ if (raw === "dev") return "dev";
+ throw new Error(`E2E_MODE=${process.env.E2E_MODE} is not a mode: use start (the default) or dev`);
+})();
+if (E2E_MODE === "start" && !process.env.E2E_BUILD_CHECKED) {
+ const ensured = spawnSync(
+ process.execPath,
+ [path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "scripts", "e2e-stamp.mjs"), "ensure", "umtool"],
+ { stdio: "inherit" },
+ );
+ if (ensured.status !== 0) {
+ throw new Error(
+ "e2e: umtool's start-mode build failed (scripts/e2e-stamp.mjs ensure umtool). " +
+ "Fix the build, or run this spec under E2E_MODE=dev.",
+ );
+ }
+ process.env.E2E_BUILD_CHECKED = "1";
+}
+const APP_SERVER =
+ E2E_MODE === "start"
+ ? `NEXT_DIST_DIR=.next-e2e-start pnpm exec next start --port ${PORT}`
+ : `NEXT_DIST_DIR=.next-e2e pnpm exec next dev --port ${PORT}`;
// The editor stub's port. Named, because the queue lock's port PREFLIGHT only
// checks the ports it is given: a bare PORT+1 was outside it, so a second
// checkout's stub could already hold the port and this run would drive it. It
@@ -68,6 +105,7 @@ export default defineConfig({
// NEXT_DIST_DIR keeps this server's build directory -- and so its dev lock
// -- separate from a dev server someone is judging clips in. Without it
// Next refuses to start and every spec fails with ERR_CONNECTION_REFUSED.
+ // In start mode it is the stamped build's directory (APP_SERVER above).
command:
`node e2e/fixtures/make-fixture.mjs ${FIXTURE} && ` +
`SONG_CODE_DIR=${FIXTURE}/code SONG_DIR=${FIXTURE}/data SONG_REPORTS_DIR=${FIXTURE}/reports ` +
@@ -105,7 +143,7 @@ export default defineConfig({
// the middle of a job and prove that cancelling abandons the rest while
// the next run resumes. Read only by bin/cut-from-cache.mjs.
`E2E_UMTOOL_CUT_DELAY_MS=2000 ` +
- `NEXT_DIST_DIR=.next-e2e pnpm exec next dev --port ${PORT}`,
+ APP_SERVER,
port: PORT,
reuseExistingServer: false,
timeout: 120_000,