Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
MENVIRONMENT.md | 4+++-
Mcommon/lib/envVars.ts | 4+++-
Meditor/e2e/helpers.ts | 53++++++++++++++++++++++++++++++++++++-----------------
Meditor/next.config.ts | 4++++
Meditor/playwright.config.ts | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mexport/playwright.config.ts | 20++++++++++++++++++++
Mexport/playwright.hub.config.ts | 20++++++++++++++++++++
Mhomepage/playwright.config.ts | 20++++++++++++++++++++
Ascripts/e2e-stamp.mjs | 342+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascripts/e2e-stamp.test.mjs | 165+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/playwright.config.ts | 40+++++++++++++++++++++++++++++++++++++++-
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,