commit 8ccd3dc569453445937df267bcc0f76c0a1ba98a
parent d77b126fc1a8eb9333c998d5aedbc8fdee9a70cb
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sat, 10 Oct 2026 02:02:29 -0400
Merge r20/e2e-speed (e2e speed S1–S3: start mode by default with a build stamp, a timing record per run, the slow timers behind test knobs) into r20/integration
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
# Conflicts:
# editor/CHANGELOG.md
Diffstat:
27 files changed, 1399 insertions(+), 59 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 |
@@ -200,6 +202,8 @@ Read only by a test harness, a fake binary or a test-mode branch. Never set one
| `E2E_AUDIO_CHECK_INTERVAL_FLOOR_MS` | the real floor | Likewise, the interval floor. | common/ytdlp/audioCheckedDownload.ts |
| `E2E_AUDIO_CHECK_RECOVER_STEP_MS` | the real step | Likewise, the recovery step. | common/ytdlp/audioCheckedDownload.ts |
| `E2E_AUDIO_CHECK_RECOVER_AFTER` | the real count | Likewise, the recovery count. | common/ytdlp/audioCheckedDownload.ts |
+| `E2E_BACKOFF_BASE_MS` | `60000` (the real base) | The first rate-limit cooldown, which every doubling starts from; the cap and the hold arithmetic keep the real constants. The editor's e2e server sets 20 s, so pacing.spec watches one lapse. | common/jobs/platformBackoff.ts |
+| `E2E_CLIP_WINDOW_GAP_MS` | the real gap (30–45 s, more for Rumble) | The pause between two clip-window fetches in one batch. The editor's e2e server sets 2 s, so fetch-window.spec sees the one it owes. | common/controller/fetchWindows.ts |
| `E2E_AUDIO_CHECK_DEBUG_PAUSE_MS` | off | A debugging pause inside the audio check. | common/ytdlp/audioCheckedDownload.ts |
| `E2E_FAKE_YTDLP_AUDIO_CHECK_MODE` | — | Fake yt-dlp: which audio-check scenario to act out. | editor/e2e/fixtures/bin/fake-ytdlp.mjs |
| `E2E_FAKE_YTDLP_CHUNK_DELAY_MS` | — | Fake yt-dlp: delay between written chunks. | editor/e2e/fixtures/bin/fake-ytdlp.mjs |
diff --git a/common/controller/fetchWindows.test.ts b/common/controller/fetchWindows.test.ts
@@ -213,6 +213,27 @@ test("the default gap is the platform's, floored at the clip-window minimum and
}
});
+test("E2E_CLIP_WINDOW_GAP_MS replaces the default gap; an explicit gapMs still wins", async () => {
+ const saved = process.env.E2E_CLIP_WINDOW_GAP_MS;
+ try {
+ process.env.E2E_CLIP_WINDOW_GAP_MS = "2000";
+ const h = harness();
+ await fetchWindows({
+ paths: await corpus(),
+ items: [item("v1", "/ok"), item("v2", "/ok")],
+ provenance: { requestedBy: "test" },
+ deps: h.deps,
+ });
+ assert.deepEqual(h.rec.sleeps, [2000]);
+ const explicit = harness();
+ await go(await corpus(), [item("v1", "/ok"), item("v2", "/ok")], explicit, { gapMs: 1234 });
+ assert.deepEqual(explicit.rec.sleeps, [1234]);
+ } finally {
+ if (saved === undefined) delete process.env.E2E_CLIP_WINDOW_GAP_MS;
+ else process.env.E2E_CLIP_WINDOW_GAP_MS = saved;
+ }
+});
+
test("an unknown channel, an unreadable one and a missing URL fail their items and the run goes on", async () => {
const h = harness({
readChannelConfig: async (_p, slug) => (slug === "nope" ? null : CONFIG),
diff --git a/common/controller/fetchWindows.ts b/common/controller/fetchWindows.ts
@@ -98,6 +98,14 @@ export const CLIP_WINDOW_MIN_GAP_SECONDS = 30;
export const CLIP_WINDOW_PLATFORM_MIN_GAP_SECONDS: Readonly<Record<string, number>> =
Object.freeze({ rumble: 120 });
+// TEST-ONLY: E2E_CLIP_WINDOW_GAP_MS replaces the batch gap above, so the e2e
+// suite sees the one owed pause without waiting 30–45 s for it
+// (fetch-window.spec). Unset everywhere but the e2e test server.
+function testGapMs(): number | undefined {
+ const raw = Number(process.env.E2E_CLIP_WINDOW_GAP_MS);
+ return Number.isFinite(raw) && raw > 0 ? raw : undefined;
+}
+
export type FetchWindowsItem = {
slug: string;
id: string;
@@ -434,6 +442,7 @@ export async function fetchWindows({
const paced = configForVideoUrl(config, url);
const gap =
gapMs ??
+ testGapMs() ??
downloadGapMs(
config.sleepBetweenDownloadsSeconds ?? settings.sleepBetweenDownloadsSeconds,
channelPaceSeconds(paced),
diff --git a/common/jobs/platformBackoff.test.ts b/common/jobs/platformBackoff.test.ts
@@ -61,6 +61,25 @@ test("nextBackoff applies bounded jitter (±10%)", () => {
assert.equal(high.until, Math.round(BACKOFF_BASE_MS * 1.1));
});
+test("E2E_BACKOFF_BASE_MS shortens the base; the cap and the hold arithmetic keep the real constants", () => {
+ const saved = process.env.E2E_BACKOFF_BASE_MS;
+ try {
+ process.env.E2E_BACKOFF_BASE_MS = "20000";
+ const first = nextBackoff(undefined, 0, noJitter);
+ assert.equal(first.until, 20_000);
+ assert.equal(nextBackoff(first, 0, noJitter).until, 40_000);
+ let entry = first;
+ for (let i = 0; i < 20; i++) entry = nextBackoff(entry, 0, noJitter);
+ assert.equal(entry.until, BACKOFF_MAX_MS);
+ assert.equal(failsAtCap(FAILS_TO_REACH_CAP), 1);
+ process.env.E2E_BACKOFF_BASE_MS = "not a number";
+ assert.equal(nextBackoff(undefined, 0, noJitter).until, BACKOFF_BASE_MS);
+ } finally {
+ if (saved === undefined) delete process.env.E2E_BACKOFF_BASE_MS;
+ else process.env.E2E_BACKOFF_BASE_MS = saved;
+ }
+});
+
test("isCoolingDown reflects the until window", () => {
const state: PlatformBackoffState = { odysee: { until: 500, fails: 1 } };
assert.equal(isCoolingDown(state, "odysee", 499), true);
diff --git a/common/jobs/platformBackoff.ts b/common/jobs/platformBackoff.ts
@@ -25,6 +25,15 @@ export const BACKOFF_MAX_MS = 30 * 60_000; // 30 minutes
// ±10% jitter so multiple platforms recovering at once don't sync up.
const JITTER = 0.1;
+// TEST-ONLY: E2E_BACKOFF_BASE_MS shortens the FIRST cooldown (and so every
+// doubling from it), so the e2e suite can watch one lapse in seconds instead
+// of a minute (pacing.spec). The cap and the hold arithmetic (FAILS_TO_REACH_CAP
+// below) stay on the real constants. Unset everywhere but the e2e test server.
+function backoffBaseMs(): number {
+ const raw = Number(process.env.E2E_BACKOFF_BASE_MS);
+ return Number.isFinite(raw) && raw > 0 ? raw : BACKOFF_BASE_MS;
+}
+
// Compute the next backoff entry for a platform given its previous one (or
// undefined for the first failure). `now` and `rand` are injected for
// deterministic tests; `rand` defaults to Math.random and returns [0, 1).
@@ -34,7 +43,7 @@ export function nextBackoff(
rand: () => number = Math.random,
): PlatformBackoffEntry {
const fails = (prev?.fails ?? 0) + 1;
- const base = Math.min(BACKOFF_BASE_MS * 2 ** (fails - 1), BACKOFF_MAX_MS);
+ const base = Math.min(backoffBaseMs() * 2 ** (fails - 1), BACKOFF_MAX_MS);
const jitter = 1 + (rand() * 2 - 1) * JITTER;
return { until: now + Math.round(base * jitter), fails };
}
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." },
@@ -198,6 +200,8 @@ const DECLARED: EnvVarDecl[] = [
{ name: "E2E_AUDIO_CHECK_INTERVAL_FLOOR_MS", audience: "test", default: "the real floor", readBy: "common/ytdlp/audioCheckedDownload.ts", doc: "Likewise, the interval floor." },
{ name: "E2E_AUDIO_CHECK_RECOVER_STEP_MS", audience: "test", default: "the real step", readBy: "common/ytdlp/audioCheckedDownload.ts", doc: "Likewise, the recovery step." },
{ name: "E2E_AUDIO_CHECK_RECOVER_AFTER", audience: "test", default: "the real count", readBy: "common/ytdlp/audioCheckedDownload.ts", doc: "Likewise, the recovery count." },
+ { name: "E2E_BACKOFF_BASE_MS", audience: "test", default: "`60000` (the real base)", readBy: "common/jobs/platformBackoff.ts", doc: "The first rate-limit cooldown, which every doubling starts from; the cap and the hold arithmetic keep the real constants. The editor's e2e server sets 20 s, so pacing.spec watches one lapse." },
+ { name: "E2E_CLIP_WINDOW_GAP_MS", audience: "test", default: "the real gap (30–45 s, more for Rumble)", readBy: "common/controller/fetchWindows.ts", doc: "The pause between two clip-window fetches in one batch. The editor's e2e server sets 2 s, so fetch-window.spec sees the one it owes." },
{ name: "E2E_AUDIO_CHECK_DEBUG_PAUSE_MS", audience: "test", default: "off", readBy: "common/ytdlp/audioCheckedDownload.ts", doc: "A debugging pause inside the audio check." },
{ name: "E2E_FAKE_YTDLP_AUDIO_CHECK_MODE", audience: "test", default: "—", readBy: "editor/e2e/fixtures/bin/fake-ytdlp.mjs", doc: "Fake yt-dlp: which audio-check scenario to act out." },
{ name: "E2E_FAKE_YTDLP_CHUNK_DELAY_MS", audience: "test", default: "—", readBy: "editor/e2e/fixtures/bin/fake-ytdlp.mjs", doc: "Fake yt-dlp: delay between written chunks." },
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -2,6 +2,7 @@
## [Unreleased]
- **A clip window of a video whose source is saved is cut from it, not fetched.** `fetch_clip`, `POST /api/media/fetch-window` and `pnpm ops fetch-windows` now cut a window out of the video's saved container (a persisted source, a full-source fetch, or media attached from a local archive) when it covers the seconds asked for, and answer at once as a cached window — no request to the platform, so a deleted channel's held videos are clippable. The window's sidecar records `source: "saved-video"`, and the video page marks it "cut from the saved video". A batch runs such windows as their own job on `clips:saved-video`, outside every platform's queue, hold and cooldown. A saved video whose file cannot be read (its drive unplugged, the file gone) is refused with the media guard's sentence rather than fetched; one that ends before the window is fetched as before.
+- **`pnpm e2e` runs against a production build, and rebuilds it when the code changed.** The editor and umtool suites now run under `next start` by default (release 19's full editor suite: 24 min, against 71 under `next dev`). Before a run starts, the build's stamp — a fingerprint of the files the build reads, uncommitted edits included — is checked against the tree, and a stale build is rebuilt first through the heavy slot under a 5 GB cap, with the reason printed (`e2e build: rebuilding editor — common changed since the build …`). The test build has its own directory (`editor/.next/e2e`, `umtool/.next-e2e-start`), so it never replaces the build a running editor or umtool serves. `E2E_MODE=dev` runs `next dev` for iterating on one spec. The export and homepage suites stay on `next dev` (they are static exports). Every suite also writes `test-results/timings.json`, and `node scripts/e2e-timings.mjs` prints each spec file's time against the branch's last run. Four slow tests no longer wait on real clocks: the first rate-limit cooldown is 20 s and the clip-window gap 2 s on the test server only (`E2E_BACKOFF_BASE_MS`, `E2E_CLIP_WINDOW_GAP_MS`).
- **An X fetch with a `limit` stops at that many posts.** "Fetch posts" with `limit` (`pnpm ops fetch-posts {"limit": 400}`) on a gallery-dl X channel read the whole history instead — a new channel walked 3,803 posts under the rate limit and held the platform queue for hours — because the cap counted media files, which a metadata-only read has almost none of. It now caps the posts themselves.
- **A home seeder of last resort, behind a VPN.** `archilyzer seed` seeds the playable torrents of the sites named in the new `settings.seeder` (`sites`, `trackers`, `maxUploadKiBps`, `maxConnections`, `pollSeconds`, `standbyAfterSeconds`, `bindInterface`; SETTINGS.md) to desktop clients over TCP and to browsers over WebRTC — but each torrent only while no other seeder has it: other seeders seen on every poll for `standbyAfterSeconds` puts that torrent on standby (it stops announcing and closes its peers, keeping the data), and it comes back at once when a leecher is waiting with no other source, or after the same window with no other seeder. Every change is logged with its reason. No DHT, no local discovery, no UPnP. `archilyzer tracker` is a self-hosted HTTP + WebSocket tracker that tracks only those torrents. `docker-compose.seeder.yml` (profile `seeder`) runs both inside a WireGuard container's network namespace (gluetun, its firewall always on), so a tunnel that is down means no network, never the home connection; the WireGuard config is yours (`SEEDER_WG_CONF`, required, mounted read-only). `archilyzer doctor` compares the seeder's egress address with the host's and fails when they are the same; it says "seeder not configured" until `seeder.sites` names a site.
- **Saved videos can be made browser-playable, with a torrent each.** `pnpm ops prepare-playable` (`POST /api/ops/prepare-playable`) and `archilyzer media playable <slug>` remux each of a channel's saved containers — without re-encoding (`-c copy`) — into an mp4 with its index in front, or a webm when it already is one (VP9/AV1 with Opus), drop subtitles, metadata and chapters, and make one single-file torrent of the copy: named `<id>.<ext>`, no web seed, no comment, no "created by", 256 KiB–1 MiB pieces. They go to `playable/<slug>/<id>/` beside the saved-video store, listed in `playable/<slug>/playable.json` with each infohash. `"trackers"` is the announce list written into each torrent (none by default); it is not part of the infohash, so the same torrent can be announced elsewhere later. A video already prepared from the same source (by sha256) is skipped, so a re-run is a no-op; a codec a browser cannot play without re-encoding (HEVC, MPEG-4 Part 2) is listed and left alone.
diff --git a/editor/app/api/test/stuck-job/route.ts b/editor/app/api/test/stuck-job/route.ts
@@ -22,10 +22,42 @@ export const dynamic = "force-dynamic";
// mounted unconditionally on the reasoning that the editor is a localhost admin
// tool — but the operator's browser is inside the loopback, so an
// unauthenticated GET that writes into the registry is CSRF-able. See _guard.ts.
+// Run `fn` after `ms` FROM OUTSIDE ANY REQUEST (see `releaseAfterMs` below for
+// why); resolves whether that worked.
+async function armDetached(fn: () => void, ms: number): Promise<boolean> {
+ let detached = false;
+ try {
+ // A Next internal (typed, but not public API) — test harness only.
+ const { workAsyncStorage } =
+ await import("next/dist/server/app-render/work-async-storage.external");
+ workAsyncStorage.exit(() => {
+ detached = workAsyncStorage.getStore() === undefined;
+ setTimeout(fn, ms);
+ });
+ } catch {
+ setTimeout(fn, ms);
+ }
+ return detached;
+}
+
export async function GET(request: Request) {
const denied = testRouteDenied();
if (denied) return denied;
const url = new URL(request.url);
+
+ // `release=<id>`: finish a holder this route made NOW, the same detached way
+ // `releaseAfterMs` does — for a spec that knows when it is done holding,
+ // rather than one sizing a timer for the slowest machine it might run on
+ // (e2e speed S3: publish-lane.spec's 45 s, channel-storage.spec's 25 s).
+ const releaseId = url.searchParams.get("release");
+ if (releaseId) {
+ const registry = getRegistry();
+ const known = registry.get(releaseId) !== undefined;
+ const detached = known
+ ? await armDetached(() => registry.finalize(releaseId, "done"), 0)
+ : false;
+ return NextResponse.json({ id: releaseId, released: known, detached });
+ }
const queueKey = url.searchParams.get("queue") || "stuck-queue";
// `slug` (+ `task`): the fake job runs ON A CHANNEL, transcribing one video —
// a writer the media move must refuse over and name (release 16 slice RM's
@@ -78,18 +110,7 @@ export async function GET(request: Request) {
const releaseAfterMs = Number(url.searchParams.get("releaseAfterMs") ?? "");
let detached = false;
if (Number.isFinite(releaseAfterMs) && releaseAfterMs > 0) {
- const release = () => registry.finalize(id, "done");
- try {
- // A Next internal (typed, but not public API) — test harness only.
- const { workAsyncStorage } =
- await import("next/dist/server/app-render/work-async-storage.external");
- workAsyncStorage.exit(() => {
- detached = workAsyncStorage.getStore() === undefined;
- setTimeout(release, releaseAfterMs);
- });
- } catch {
- setTimeout(release, releaseAfterMs);
- }
+ detached = await armDetached(() => registry.finalize(id, "done"), releaseAfterMs);
}
return NextResponse.json({ id, queueKey, detached });
diff --git a/editor/e2e/channel-storage.spec.ts b/editor/e2e/channel-storage.spec.ts
@@ -867,8 +867,8 @@ test("a move that starts while a job writes into the channel refuses, naming the
// a writer "stopping", and the move waits for it; after it, the move is free.
//
// The writer is the stuck-job route's fake Transcribe all on this channel's
-// video, cancelled from /jobs; its function "returns" when the route releases
-// it, 25 s after it was made.
+// video, cancelled from /jobs; its function "returns" when the spec releases
+// it through the route, once the refusal is seen (it used to be a 25 s timer).
test("a job cancelled but still stopping holds the move until it has stopped", async ({
page,
}, testInfo) => {
@@ -880,7 +880,7 @@ test("a job cancelled but still stopping holds the move until it has stopped", a
await mkdir(root, { recursive: true });
const writer = await page.request.get(
- `${baseUrl}/api/test/stuck-job?queue=${encodeURIComponent("rm-stopping")}&slug=${SLUG}&task=${VIDEO}&releaseAfterMs=25000`,
+ `${baseUrl}/api/test/stuck-job?queue=${encodeURIComponent("rm-stopping")}&slug=${SLUG}&task=${VIDEO}`,
);
expect(writer.ok()).toBe(true);
const writerId = ((await writer.json()) as { id: string }).id;
@@ -911,6 +911,10 @@ test("a job cancelled but still stopping holds the move until it has stopped", a
{ timeout: 15_000 },
);
+ // Its function returns now.
+ const released = await page.request.get(`${baseUrl}/api/test/stuck-job?release=${writerId}`);
+ expect(((await released.json()) as { released: boolean }).released).toBe(true);
+
// Once it has stopped, nothing holds the move.
await expect(async () => {
await page.goto(channelStage(SLUG, "storage"));
diff --git a/editor/e2e/fetch-window.spec.ts b/editor/e2e/fetch-window.spec.ts
@@ -51,7 +51,8 @@ async function invocations(): Promise<string> {
async function pollJob(
request: APIRequestContext,
jobId: string,
- // A batch sleeps the clip-window gap (30–45 s) between two fetches.
+ // A batch sleeps the clip-window gap between two fetches (2 s on the test
+ // server, E2E_CLIP_WINDOW_GAP_MS; 30–45 s in production).
timeout = 30_000,
): Promise<Record<string, unknown>> {
let last: Record<string, unknown> = {};
@@ -332,8 +333,9 @@ test("a 429 fails the job and puts the platform in cooldown", async ({
// refuses with a 403, one that fetches. A single 403 is an item failure — a
// removed Rumble page answers 403 too — so the run carries on past it and the
// platform is NOT backed off; the job still ends failed, naming the window it
-// lost. Exactly one pause is owed (between the two network fetches), at the
-// clip-window floor of 30–45 s.
+// lost. Exactly one pause is owed (between the two network fetches) — the
+// clip-window floor of 30–45 s in production, 2 s on the test server
+// (E2E_CLIP_WINDOW_GAP_MS, playwright.config.ts) — and the job log names it.
test("a batch skips what is cached, survives one 403, and fetches the rest", async ({
request,
}) => {
@@ -373,6 +375,13 @@ test("a batch skips what is cached, survives one 403, and fetches the rest", asy
const finished = await pollJob(request, body.jobId, 90_000);
expect(finished.status).toBe("failed");
+ const jobLog = await readFile(
+ resolvePath(`test-transcripts/.jobs/${body.jobs[0].jobId}.log`),
+ "utf8",
+ );
+ expect(jobLog.match(/Sleeping \d+s before the next fetch/g)).toEqual([
+ "Sleeping 2s before the next fetch",
+ ]);
expect(String(finished.error)).toMatch(/1 window\(s\) failed to fetch/);
expect(String(finished.error)).toMatch(/1 fetched, 0 cached, 1 failed/);
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/e2e/pacing.spec.ts b/editor/e2e/pacing.spec.ts
@@ -195,8 +195,10 @@ test("a deferred video is skipped by auto-download, and the lane says so", async
test("a live 429 backs youtube off, defers the video, and the next pick is the next video", async ({
request,
}) => {
- // A REAL cooldown: base 60 s ±10 %. The whole point is what happens when it
- // lapses — the runner must move on to a2, not re-pick wp429vid1.
+ // A REAL cooldown, on a shorter clock: the base is 60 s ±10 % in production
+ // and 20 s on the test server (E2E_BACKOFF_BASE_MS, playwright.config.ts).
+ // The whole point is what happens when it lapses — the runner must move on to
+ // a2, not re-pick wp429vid1.
test.setTimeout(150_000);
await setup(["wp429vid1", "a2"]);
diff --git a/editor/e2e/publish-lane.spec.ts b/editor/e2e/publish-lane.spec.ts
@@ -86,13 +86,13 @@ test("enabled and started, the lane updates the index; a hold mid-stage stops th
publish: { enabled: true, checkEveryMinutes: 1, refreshEveryMinutes: 0 },
});
await openLane(page);
- // Hold the publish queue for a while, so the lane's first stage (the index
- // update: there is no stamp) queues behind the holder and is IN FLIGHT from
- // the lane's view while the hold is thrown. The holder lets go by itself —
- // its clock starts here, after the page has compiled and hydrated, and runs
- // long enough for the dispatch poll and the click below.
- const hold = await request.get(`${baseUrl}/api/test/stuck-job?queue=publish&releaseAfterMs=45000`);
+ // Hold the publish queue, so the lane's first stage (the index update: there
+ // is no stamp) queues behind the holder and is IN FLIGHT from the lane's view
+ // while the hold is thrown. The spec lets the holder go once the hold is
+ // thrown (it used to be a 45 s timer sized for a cold dev server).
+ const hold = await request.get(`${baseUrl}/api/test/stuck-job?queue=publish`);
expect(hold.ok()).toBe(true);
+ const holderId = ((await hold.json()) as { id: string }).id;
await page.getByRole("button", { name: "Start publish lane" }).click();
await expect(page.getByText("Runner running")).toBeVisible({ timeout: 15_000 });
@@ -107,6 +107,10 @@ test("enabled and started, the lane updates the index; a hold mid-stage stops th
expect((await publishStatus(request)).lane.held).toBe(true);
const settings = await readJson<{ publish?: { held?: boolean } }>("test-settings.json");
expect(settings.publish?.held).toBe(true);
+ // The stage is still queued behind the holder; let the holder go.
+ expect((await metasOf("publish-update-index"))[0]?.status).not.toBe("done");
+ const released = await request.get(`${baseUrl}/api/test/stuck-job?release=${holderId}`);
+ expect(((await released.json()) as { released: boolean }).released).toBe(true);
// The stage is not killed: it runs once the holder lets go, and ends done.
await expect
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,29 @@ 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_BACKOFF_BASE_MS the first rate-limit cooldown (60 s in
+// production; 20 s here), so pacing.spec watches one
+// lapse (common/jobs/platformBackoff.ts). The cap and
+// the hold arithmetic keep the real constants. Not
+// shorter: rumble-sweep, metadata-scan-softblock and
+// fetch-window assert a cooldown is still in force a
+// page load after it was recorded
+// E2E_CLIP_WINDOW_GAP_MS the pause between two clip-window fetches in a
+// batch (30–45 s in production; 2 s here), so
+// fetch-window.spec sees the one it owes
+// (common/controller/fetchWindows.ts)
+// 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,
@@ -72,6 +95,8 @@ const E2E_SERVER_ENV = {
E2E_AUDIO_CHECK_INTERVAL_FLOOR_MS: "50",
E2E_AUDIO_CHECK_RECOVER_STEP_MS: "100",
E2E_AUDIO_CHECK_RECOVER_AFTER: "2",
+ E2E_BACKOFF_BASE_MS: "20000",
+ E2E_CLIP_WINDOW_GAP_MS: "2000",
E2E_LIVE_CHECK: "skip",
WRANGLER_BIN: path.resolve(process.cwd(), "e2e", "fixtures", "bin", "fake-wrangler.mjs"),
ARCHILYZER_BRANCH: "main",
@@ -85,8 +110,40 @@ 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;
+// The same directory as scripts/e2e-stamp.mjs PACKAGES.editor.distDir.
+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
@@ -126,7 +183,11 @@ export default defineConfig({
// for retries, and passes an explicit --retries=0 so its failures stay
// comparable to a serial run's. See scripts/run-sharded-e2e.mjs.
retries: process.env.CI ? 2 : 0,
- reporter: process.env.CI ? "github" : "list",
+ // `json` beside `list`: the run's timing record, which
+ // scripts/e2e-timings.mjs totals per spec against the branch's last run.
+ reporter: process.env.CI
+ ? "github"
+ : [["list"], ["json", { outputFile: "test-results/timings.json" }]],
outputDir: "test-results/",
// Serial, one worker — deliberate, and not a performance oversight. Three
// pieces of shared mutable state are global to the whole run, so two workers
@@ -156,9 +217,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.2origin.config.ts b/export/playwright.2origin.config.ts
@@ -29,7 +29,11 @@ export default defineConfig({
testDir: "./e2e-2origin",
timeout: 60_000,
retries: process.env.CI ? 2 : 0,
- reporter: process.env.CI ? "github" : "list",
+ // `json` beside `list`: the run's timing record, which
+ // scripts/e2e-timings.mjs totals per spec against the branch's last run.
+ reporter: process.env.CI
+ ? "github"
+ : [["list"], ["json", { outputFile: "test-results-2origin/timings.json" }]],
outputDir: "test-results-2origin/",
fullyParallel: false,
workers: 1,
diff --git a/export/playwright.config.ts b/export/playwright.config.ts
@@ -63,11 +63,35 @@ 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,
retries: process.env.CI ? 2 : 0,
- reporter: process.env.CI ? "github" : "list",
+ // `json` beside `list`: the run's timing record, which
+ // scripts/e2e-timings.mjs totals per spec against the branch's last run.
+ reporter: process.env.CI
+ ? "github"
+ : [["list"], ["json", { outputFile: "test-results/timings.json" }]],
outputDir: "test-results/",
fullyParallel: false,
workers: 1,
diff --git a/export/playwright.hub.config.ts b/export/playwright.hub.config.ts
@@ -24,11 +24,35 @@ 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,
retries: process.env.CI ? 2 : 0,
- reporter: process.env.CI ? "github" : "list",
+ // `json` beside `list`: the run's timing record, which
+ // scripts/e2e-timings.mjs totals per spec against the branch's last run.
+ reporter: process.env.CI
+ ? "github"
+ : [["list"], ["json", { outputFile: "test-results-hub/timings.json" }]],
outputDir: "test-results-hub/",
fullyParallel: false,
workers: 1,
diff --git a/export/playwright.report.config.ts b/export/playwright.report.config.ts
@@ -29,7 +29,11 @@ export default defineConfig({
testDir: "./e2e-report",
timeout: 30_000,
retries: process.env.CI ? 2 : 0,
- reporter: process.env.CI ? "github" : "list",
+ // `json` beside `list`: the run's timing record, which
+ // scripts/e2e-timings.mjs totals per spec against the branch's last run.
+ reporter: process.env.CI
+ ? "github"
+ : [["list"], ["json", { outputFile: "test-results-report/timings.json" }]],
outputDir: "test-results-report/",
fullyParallel: false,
workers: 1,
diff --git a/homepage/playwright.config.ts b/homepage/playwright.config.ts
@@ -68,11 +68,35 @@ 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,
retries: process.env.CI ? 2 : 0,
- reporter: process.env.CI ? "github" : "list",
+ // `json` beside `list`: the run's timing record, which
+ // scripts/e2e-timings.mjs totals per spec against the branch's last run.
+ reporter: process.env.CI
+ ? "github"
+ : [["list"], ["json", { outputFile: "test-results/timings.json" }]],
outputDir: "test-results/",
fullyParallel: false,
workers: 1,
diff --git a/plans/FACTS.md b/plans/FACTS.md
@@ -999,6 +999,12 @@ seed again (`plans/tools/implementer-rules.md`); the export config re-copies `sw
- `editor/playwright.config.ts` — editor on `PORT ?? 3011`, export on `EXPORT_PORT ?? 3010`.
`E2E_MODE=start` → `pnpm start:test`, otherwise `pnpm dev:test` (`:10-11`).
`fullyParallel: false`, `workers: 1`, `timeout: 30_000`.
+ **AMENDED 2026-10-10 (e2e speed S1):** `start` is the DEFAULT (`E2E_MODE=dev` for `next dev`).
+ Before the servers start, `scripts/e2e-stamp.mjs ensure editor` checks the build's stamp
+ (`editor/.next/e2e/e2e-stamp.json`) and rebuilds through the heavy slot when the tree moved;
+ the test server serves `.next/e2e` (`E2E_NEXT_DIST_DIR`), never `.next`. `CI` keeps `.next`
+ and skips the stamp (the sharded image builds its own). umtool likewise (`.next-e2e-start`);
+ export and homepage stay `next dev`.
- `editor/e2e/helpers.ts` — `resetData` (`:32`), `writeSettings` (`:49`), `readJson` (`:90`).
- Fake binaries live in `editor/e2e/fixtures/bin/`. The **`SLOWOP` sentinel** (checked
case-insensitively against cwd or video id) makes an instant fake emit paced progress —
@@ -1080,6 +1086,8 @@ not reset `timestampMode`/`promptVariant`.
Run `pnpm e2e` in **default dev mode** — `E2E_MODE=start` serves a stale build. Kill stale
dev servers by port between runs.
+**AMENDED 2026-10-10 (e2e speed S1):** no longer true — start mode is the default and its build
+is stamped; a stale build is rebuilt before the run (see the playwright.config entry above).
### The 22 reds: what they actually were
diff --git a/plans/e2e-speed.md b/plans/e2e-speed.md
@@ -96,4 +96,91 @@ apply throughout; no slice weakens the memory floor.
## As it went
-(Each slice adds its record here.)
+### S1–S3, as shipped (2026-10-10, Track E of the overnight batch, branch `r20/e2e-speed`)
+
+**S1 — start mode by default, the build stamped.**
+- `scripts/e2e-stamp.mjs` (`ensure | check | build <editor|umtool>`). The stamp is a FINGERPRINT, not HEAD: the
+ index's blob id of every file the build reads, and git's blob id of the working-tree bytes of every dirty or
+ untracked one, over the package, `common/` and `pnpm-lock.yaml`, minus what no build reads (`<pkg>/e2e/`, the
+ package's `playwright.config.ts`, `*.test.*`, `CHANGELOG.md`). An edit and its commit fingerprint the same; a
+ plans-only commit costs no rebuild. A stale stamp names where the tree moved (`common, editor changed since
+ the build (4 uncommitted files there, e.g. common/controller/fetchWindows.ts)`). The build runs through
+ `queue-lock.mjs --heavy` and `systemd-run --user --scope -p MemoryMax=5G -p MemorySwapMax=0` (inside an e2e run
+ the slot is already held and passes through), and the stamp written is the tree the build STARTED from.
+- **The build has its own directory** — ruled: the primary checkout's `.next` is what the live editor and umtool
+ serve. Editor: `.next/e2e` through `E2E_NEXT_DIST_DIR` (a one-line `distDir` in `editor/next.config.ts`; inside
+ the ignored `.next/`, so Tailwind never scans it). umtool: `.next-e2e-start` (its config already reads
+ `NEXT_DIST_DIR`; ignored by `umtool/.next-*/`). The stamp is `editor/.next/e2e/e2e-stamp.json`, not
+ `editor/.next/e2e-stamp.json` as briefed. A `next build` with the custom dist dir leaves `tsconfig.json`
+ alone and rewrites only the ignored `next-env.d.ts`.
+- `editor/playwright.config.ts` and `umtool/playwright.config.ts` run `ensure` before any server starts
+ (`E2E_BUILD_CHECKED` keeps a worker's second load from checking again). `E2E_MODE=dev` builds nothing.
+ `CI` (the sharded image, which builds its own `.next`) keeps today's path.
+- **The export (default, hub) and homepage suites take the switch and stay `next dev`**, printing so for
+ `E2E_MODE=start` — ruled: they are static exports, several export specs (header, transcript-downloads, brand,
+ site-branding, …) rewrite the fixture site mid-run and assert the next page, and the homepage's fixture
+ summary and publish are read only outside a production build. **The editor suite's export server stays `next
+ dev` too** — ruled: its two spec files took 42 s in all in release 19's start run, under one export build's
+ cost, and a static build would bake in the `test-settings.json` export-search.spec rewrites.
+- **Flakes from release 19's start run:** `channel-work.spec:208` was a real race — `fs.cp` mkdirs each
+ directory after finding it absent, and a write still landing from the previous spec's work created
+ `test-transcripts/channels` in between. `resetData` now retries the clear-and-copy on `EEXIST` (four
+ attempts). `sites-crud.spec:392` (the first Save's status not seen in 5 s) and `whisper.spec:184` (the clear
+ job's output still "Waiting for output…" at 15 s) are recorded as load flakes: both pass alone, and nothing in
+ either names a race.
+- **Stale-safety, by hand** (the S1 tree, before its commit): first run `e2e build: rebuilding editor — no
+ build stamp here yet` → `done in 54s, stamped f534007e1d7a`, `channel-work.spec` 11 passed. Then a comment
+ appended to `common/lib/project.ts`: `check editor` → `stale — common changed since the build …`; the runner →
+ `e2e build: rebuilding editor — common changed since the build …` (stopped before the suite). Killing that
+ build left NO stamp (`next build` empties its dist dir first), so the next run rebuilt rather than trusting a
+ half-written build: `done in 84s, stamped f534007e1d7a` — the same key for the same tree.
+
+**S2 — a timing record per run.** Every suite's config writes Playwright's `json` report beside `list`
+(`test-results*/timings.json`; editor, umtool, export default/hub/report/2origin, homepage).
+`scripts/e2e-timings.mjs [<timings.json>]` (default: the editor's) totals it per spec file (every result,
+retries included), prints the files slowest first, each against the last run on the same branch that ran it,
+and the wall time against the last run of the same spec files; it records the run in the git common dir
+(`.git/e2e-timings/<suite>/<branch>.json`, the last 30), untracked and shared by every worktree.
+
+**S3 — the four timers.** Two were product timers and take a test-only duration from `E2E_SERVER_ENV`,
+declared `test` in `common/lib/envVars.ts`:
+- `E2E_BACKOFF_BASE_MS=20000` — the first rate-limit cooldown, read in `nextBackoff`
+ (`common/jobs/platformBackoff.ts`); the cap and the hold arithmetic (`FAILS_TO_REACH_CAP`) keep the real
+ constants. 20 s, not less — ruled: rumble-sweep, metadata-scan-softblock and fetch-window assert a cooldown
+ still in force a page load after it was recorded.
+- `E2E_CLIP_WINDOW_GAP_MS=2000` — the pause between two clip-window fetches (`common/controller/fetchWindows.ts`:
+ `testGapMs()` beside `CLIP_WINDOW_PLATFORM_MIN_GAP_SECONDS`, and one line, `testGapMs() ??`, in the gap
+ expression). fetch-window.spec now asserts the one pause the batch owes, from the job log.
+
+The other two were never product timers: publish-lane.spec's 45 s and channel-storage.spec's 25 s were the
+stuck-job holder's own `releaseAfterMs`, sized for a cold dev server. `GET /api/test/stuck-job?release=<id>`
+finishes a holder now, detached from the request exactly as `releaseAfterMs` does (one helper,
+`armDetached`); the two specs release it once they have seen what it holds (publish-lane also asserts the
+stage is still queued at that point).
+
+Before/after, start mode, same worktree, per test (and per spec file, from `e2e-timings.mjs`):
+
+| test | before | after | file before → after |
+|---|---|---|---|
+| `pacing.spec:195` (a live 429 backs youtube off …) | 1.1 m | 22.4 s | 66.5 s → 24.0 s |
+| `publish-lane.spec:78` (a hold mid-stage …) | 49.7 s | 5.6 s | 51.2 s → 7.0 s |
+| `fetch-window.spec:337` (a batch skips what is cached …) | 34.1 s | 3.1 s | 45.2 s → 6.4 s |
+| `channel-storage.spec:872` (a job cancelled but still stopping …) | 28.1 s | 2.8 s | 85.4 s → 35.3 s |
+
+Four files: 248 s → 73 s of test time.
+
+Commits: `d1e80e42` S1 · `5ef76aa0` S2 · `977c8e72` S3 · `0f509eac` a comment.
+
+Gates: tsc clean before each commit (3 runs, 3.6–4.7 min); `scripts/e2e-stamp.test.mjs` 9/9,
+`scripts/e2e-timings.test.mjs` 8/8; `test:scripts` 725 passed + 3 skipped (728); editor unit 218/218; common
+3635/3636 — the one, `fetchPosts.test.ts`'s drain-mid-page case, passes 3/3 alone and touches nothing here;
+`platformBackoff.test.ts` + `fetchWindows.test.ts` 33/33 with a case for each knob; `docs env --check` clean.
+e2e, single files in the foreground: `channel-work.spec` 11/11 (twice, each after a build); umtool
+`article-notes` + `usage` 15/15 in start mode (first umtool build 43 s); export `header.spec` 33/33 under
+`E2E_MODE=start` (prints the dev line); homepage `source.spec` 6/6; the four S3 files 4/4 + 24/24 before and
+after. With the knobs on: `rumble-sweep` + `metadata-scan-softblock` 2/2 and `rate-limit` + `ops-api` 18/18 (start;
+ops-api's `detached` holder still detached); `pacing` + `publish-lane` 4/4 under `E2E_MODE=dev` (1.0 m; pacing:195
+21.7 s), and the stamp still fresh after it — a `next dev` in `.next` leaves `.next/e2e` alone. A commit that
+changed only `editor/playwright.config.ts` (`0f509eac`) cost no rebuild.
+
+Not done here, by the brief: the full suites (the orchestrator runs them, start then dev). S4–S6.
diff --git a/scripts/e2e-stamp.mjs b/scripts/e2e-stamp.mjs
@@ -0,0 +1,346 @@
+#!/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",
+ // 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 <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/scripts/e2e-timings.mjs b/scripts/e2e-timings.mjs
@@ -0,0 +1,242 @@
+#!/usr/bin/env node
+// PER-SPEC TIMINGS FOR AN E2E RUN, against the last run on the same branch.
+//
+// node scripts/e2e-timings.mjs [<timings.json>] [--suite <name>] [--top <n>] [--no-record]
+//
+// Every suite's Playwright config writes a `json` report beside `list`
+// (`<package>/test-results/timings.json`; the hub's, report's and 2origin's in
+// their own test-results-* dirs). This totals it per spec FILE — every result
+// of every test, retries included, since that is what the run spent — prints
+// the files slowest first, each with its change against the last run of the
+// same suite on the same branch that ran it (the wall time against the last
+// run of the same spec files), and records the run so the next one has a
+// baseline. A spec that quietly grows a 60 s wait shows up here as a delta.
+//
+// THE HISTORY lives in the repository's git common dir
+// (`<.git>/e2e-timings/<suite>/<branch>.json`, the last 30 runs): untracked by
+// construction, shared by every worktree of the checkout, so a branch's
+// baseline is found wherever its next run happens. A report already recorded
+// (the same start time) is not recorded twice, and is compared with the runs
+// before it.
+import { execFileSync } from "node:child_process";
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const SELF = fileURLToPath(import.meta.url);
+const REPO_ROOT = path.resolve(path.dirname(SELF), "..");
+export const HISTORY_KEEP = 30;
+
+// --------------------------------------------------------------- the report
+
+/**
+ * Per-spec totals from a Playwright JSON report: `{startTime, wallMs, totals,
+ * specs: {[file]: {tests, passed, failed, flaky, skipped, ms}}}`.
+ */
+export function summarize(report) {
+ const specs = {};
+ const totals = { tests: 0, passed: 0, failed: 0, flaky: 0, skipped: 0 };
+ const walk = (suite, file) => {
+ const f = suite.file ?? file;
+ for (const spec of suite.specs ?? []) {
+ const name = spec.file ?? f;
+ const row = (specs[name] ??= { tests: 0, passed: 0, failed: 0, flaky: 0, skipped: 0, ms: 0 });
+ for (const t of spec.tests ?? []) {
+ row.tests += 1;
+ totals.tests += 1;
+ const kind =
+ t.status === "expected" ? "passed"
+ : t.status === "unexpected" ? "failed"
+ : t.status === "flaky" ? "flaky"
+ : "skipped";
+ row[kind] += 1;
+ totals[kind] += 1;
+ for (const r of t.results ?? []) row.ms += Number(r.duration) || 0;
+ }
+ }
+ for (const child of suite.suites ?? []) walk(child, f);
+ };
+ for (const s of report.suites ?? []) walk(s, s.file);
+ return {
+ startTime: report.stats?.startTime ?? null,
+ wallMs: Number(report.stats?.duration) || 0,
+ totals,
+ specs,
+ };
+}
+
+// ------------------------------------------------------------------ history
+
+function gitOut(args, cwd) {
+ try {
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
+ } catch {
+ return null;
+ }
+}
+
+export function defaultHistoryDir(cwd = REPO_ROOT) {
+ const common = gitOut(["rev-parse", "--path-format=absolute", "--git-common-dir"], cwd);
+ return common ? path.join(common, "e2e-timings") : null;
+}
+
+const safe = (s) => String(s).replace(/[^A-Za-z0-9._-]+/g, "_");
+
+export function historyFile(dir, suite, branch) {
+ return path.join(dir, safe(suite), `${safe(branch)}.json`);
+}
+
+export function readHistory(file) {
+ try {
+ const h = JSON.parse(fs.readFileSync(file, "utf8"));
+ return Array.isArray(h?.runs) ? h.runs : [];
+ } catch {
+ return [];
+ }
+}
+
+/**
+ * Where `run` goes: the runs recorded before it (`earlier`, oldest first) and
+ * the history with it appended — or unchanged, when it is already there.
+ */
+export function placeRun(runs, run) {
+ const idx = run.startTime == null ? -1 : runs.findIndex((r) => r.startTime === run.startTime);
+ if (idx >= 0) return { earlier: runs.slice(0, idx), runs, recorded: false };
+ return { earlier: runs, runs: [...runs, run].slice(-HISTORY_KEEP), recorded: true };
+}
+
+const specSet = (r) => Object.keys(r?.specs ?? {}).sort().join("\n");
+
+/**
+ * The baselines: the last earlier run of the SAME spec files (for the wall
+ * time — a one-spec run is no baseline for a full suite), and per spec the last
+ * earlier run that ran it.
+ */
+export function baselines(earlier, run) {
+ const same = [...earlier].reverse().find((r) => specSet(r) === specSet(run)) ?? null;
+ const perSpec = {};
+ for (const file of Object.keys(run.specs)) {
+ const r = [...earlier].reverse().find((e) => e.specs?.[file]);
+ if (r) perSpec[file] = r.specs[file];
+ }
+ return { same, perSpec, any: earlier.length > 0 };
+}
+
+export function writeHistory(file, runs) {
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ const tmp = `${file}.${process.pid}.tmp`;
+ fs.writeFileSync(tmp, `${JSON.stringify({ runs }, null, 1)}\n`);
+ fs.renameSync(tmp, file);
+}
+
+// ------------------------------------------------------------------- output
+
+function secs(ms) {
+ const s = ms / 1000;
+ return s >= 600 ? `${Math.floor(s / 60)}m${String(Math.round(s % 60)).padStart(2, "0")}s` : `${s.toFixed(1)}s`;
+}
+
+function delta(ms) {
+ const sign = ms > 0 ? "+" : ms < 0 ? "−" : "±";
+ return `${sign}${secs(Math.abs(ms))}`;
+}
+
+/** The printed table, as lines. */
+export function render({ suite, branch, head, run, earlier = [], top = Infinity }) {
+ const out = [];
+ const stamp = (r) => (r.startTime ? `${r.startTime.replace("T", " ").slice(0, 16)} UTC` : "?");
+ out.push(`e2e timings — ${suite} on ${branch}${head ? ` (${head.slice(0, 8)})` : ""}, ${stamp(run)}, wall ${secs(run.wallMs)}`);
+ const t = run.totals;
+ out.push(
+ ` ${t.tests} tests: ${t.passed} passed, ${t.failed} failed` +
+ `${t.flaky ? `, ${t.flaky} flaky` : ""}, ${t.skipped} skipped`,
+ );
+ const { same, perSpec, any } = baselines(earlier, run);
+ if (same) {
+ out.push(` against ${stamp(same)}${same.head ? ` (${same.head.slice(0, 8)})` : ""}, the same specs: wall ${delta(run.wallMs - same.wallMs)}`);
+ } else if (any) {
+ out.push(" no earlier run of these same specs on this branch: each spec against the last run that had it");
+ } else {
+ out.push(" no earlier run of this suite on this branch: this one is the baseline");
+ }
+ out.push("");
+ const rows = Object.entries(run.specs).sort((a, b) => b[1].ms - a[1].ms || a[0].localeCompare(b[0]));
+ const width = Math.max(4, ...rows.map(([f]) => f.length));
+ out.push(` ${"spec".padEnd(width)} tests time${any ? " Δ" : ""}`);
+ for (const [file, r] of rows.slice(0, top)) {
+ const was = perSpec[file];
+ const d = !any ? "" : was ? delta(r.ms - was.ms).padStart(10) : " new";
+ const bad = r.failed ? ` (${r.failed} failed)` : "";
+ out.push(` ${file.padEnd(width)} ${String(r.tests).padStart(5)} ${secs(r.ms).padStart(8)}${d}${bad}`);
+ }
+ if (rows.length > top) out.push(` … ${rows.length - top} more`);
+ return out;
+}
+
+// ---------------------------------------------------------------------- CLI
+
+function parseArgs(argv) {
+ const opts = { report: null, suite: null, top: Infinity, record: true, historyDir: null, branch: null };
+ for (let i = 0; i < argv.length; i++) {
+ const a = argv[i];
+ if (a === "--suite") opts.suite = argv[++i];
+ else if (a === "--top") opts.top = Number(argv[++i]);
+ else if (a === "--no-record") opts.record = false;
+ else if (a === "--history-dir") opts.historyDir = argv[++i];
+ else if (a === "--branch") opts.branch = argv[++i];
+ else if (a.startsWith("--")) throw new Error(`unknown flag '${a}'`);
+ else opts.report = a;
+ }
+ return opts;
+}
+
+// `editor/test-results/timings.json` → "editor"; `export/test-results-hub/…` → "export-hub".
+export function suiteFromPath(file) {
+ const dir = path.basename(path.dirname(file));
+ const pkg = path.basename(path.dirname(path.dirname(file)));
+ const extra = dir.startsWith("test-results-") ? `-${dir.slice("test-results-".length)}` : "";
+ return `${pkg}${extra}`;
+}
+
+function main(argv) {
+ const opts = parseArgs(argv);
+ const report = path.resolve(opts.report ?? path.join(REPO_ROOT, "editor", "test-results", "timings.json"));
+ let json;
+ try {
+ json = JSON.parse(fs.readFileSync(report, "utf8"));
+ } catch (err) {
+ process.stderr.write(`e2e-timings: cannot read ${report} (${err.code ?? err.message}) — run the suite first\n`);
+ return 1;
+ }
+ const suite = opts.suite ?? suiteFromPath(report);
+ const cwd = path.dirname(report);
+ const branch = opts.branch ?? gitOut(["rev-parse", "--abbrev-ref", "HEAD"], cwd) ?? "unknown";
+ const head = gitOut(["rev-parse", "HEAD"], cwd);
+ const run = { ...summarize(json), head };
+ const dir = opts.historyDir ?? defaultHistoryDir(cwd);
+ let earlier = [];
+ let note = "";
+ if (dir) {
+ const file = historyFile(dir, suite, branch);
+ const placed = placeRun(readHistory(file), run);
+ earlier = placed.earlier;
+ if (opts.record && placed.recorded) {
+ writeHistory(file, placed.runs);
+ note = `recorded in ${file}`;
+ } else if (!placed.recorded) {
+ note = "this run was already recorded";
+ }
+ }
+ const lines = render({ suite, branch, head, run, earlier, top: opts.top });
+ process.stdout.write(`${lines.join("\n")}\n${note ? `\n${note}\n` : ""}`);
+ return 0;
+}
+
+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(`e2e-timings: ${err?.message ?? err}\n`);
+ process.exit(2);
+ }
+}
diff --git a/scripts/e2e-timings.test.mjs b/scripts/e2e-timings.test.mjs
@@ -0,0 +1,175 @@
+// scripts/e2e-timings.mjs — per-spec totals from a Playwright JSON report, and
+// the delta against the branch's last run. The reports here are synthetic, in
+// the shape Playwright's `json` reporter writes.
+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 { fileURLToPath } from "node:url";
+import {
+ HISTORY_KEEP,
+ baselines,
+ historyFile,
+ placeRun,
+ readHistory,
+ render,
+ suiteFromPath,
+ summarize,
+ writeHistory,
+} from "./e2e-timings.mjs";
+
+const SCRIPT = path.join(path.dirname(fileURLToPath(import.meta.url)), "e2e-timings.mjs");
+
+const made = [];
+after(() => {
+ for (const dir of made) fs.rmSync(dir, { recursive: true, force: true });
+});
+function tmp() {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "e2e-timings-test-"));
+ made.push(dir);
+ return dir;
+}
+
+const result = (duration, status = "passed") => ({ duration, status, retry: 0 });
+const t = (status, ...results) => ({ status, projectName: "chromium", results });
+
+// Two files; one nests a describe; one test failed, one retried to flaky, one skipped.
+function report({ pacingMs = 60_000, startTime = "2026-10-09T20:40:00.000Z", wall = 90_000 } = {}) {
+ return {
+ config: {},
+ errors: [],
+ stats: { startTime, duration: wall, expected: 3, unexpected: 1, flaky: 1, skipped: 1 },
+ suites: [
+ {
+ title: "pacing.spec.ts",
+ file: "pacing.spec.ts",
+ specs: [
+ { title: "a live 429 backs off", file: "pacing.spec.ts", tests: [t("expected", result(pacingMs))] },
+ { title: "deferred", file: "pacing.spec.ts", tests: [t("unexpected", result(4_000, "failed"))] },
+ ],
+ },
+ {
+ title: "channel-work.spec.ts",
+ file: "channel-work.spec.ts",
+ specs: [{ title: "top", file: "channel-work.spec.ts", tests: [t("expected", result(1_500))] }],
+ suites: [
+ {
+ title: "inline",
+ file: "channel-work.spec.ts",
+ specs: [
+ {
+ title: "retried",
+ file: "channel-work.spec.ts",
+ tests: [t("flaky", result(2_000, "failed"), result(1_000))],
+ },
+ { title: "skipped", file: "channel-work.spec.ts", tests: [t("skipped", result(0, "skipped"))] },
+ ],
+ },
+ ],
+ },
+ ],
+ };
+}
+
+test("summarize totals every result per spec file, nested describes and retries included", () => {
+ const s = summarize(report());
+ assert.equal(s.startTime, "2026-10-09T20:40:00.000Z");
+ assert.equal(s.wallMs, 90_000);
+ assert.deepEqual(s.totals, { tests: 5, passed: 2, failed: 1, flaky: 1, skipped: 1 });
+ assert.deepEqual(s.specs["pacing.spec.ts"], { tests: 2, passed: 1, failed: 1, flaky: 0, skipped: 0, ms: 64_000 });
+ assert.deepEqual(s.specs["channel-work.spec.ts"], { tests: 3, passed: 1, failed: 0, flaky: 1, skipped: 1, ms: 4_500 });
+});
+
+test("placeRun: earlier is every run before this one; a run already recorded is not added twice", () => {
+ const a = { startTime: "a", specs: {} };
+ const b = { startTime: "b", specs: {} };
+ const first = placeRun([], a);
+ assert.deepEqual(first.earlier, []);
+ assert.deepEqual(first.runs, [a]);
+ const second = placeRun(first.runs, b);
+ assert.deepEqual(second.earlier, [a]);
+ assert.deepEqual(second.runs, [a, b]);
+ const again = placeRun(second.runs, { ...b });
+ assert.equal(again.recorded, false);
+ assert.deepEqual(again.earlier, [a]);
+ assert.equal(again.runs.length, 2);
+ let runs = [];
+ for (let i = 0; i < HISTORY_KEEP + 5; i++) runs = placeRun(runs, { startTime: String(i) }).runs;
+ assert.equal(runs.length, HISTORY_KEEP);
+ assert.equal(runs[0].startTime, "5");
+});
+
+const row = (ms) => ({ tests: 1, passed: 1, failed: 0, flaky: 0, skipped: 0, ms });
+
+test("baselines: the wall against the last run of the same specs; each spec against its last run", () => {
+ const full1 = { startTime: "1", wallMs: 100, specs: { "a.spec.ts": row(10), "b.spec.ts": row(20) } };
+ const one = { startTime: "2", wallMs: 5, specs: { "a.spec.ts": row(4) } };
+ const now = { startTime: "3", wallMs: 90, specs: { "a.spec.ts": row(3), "b.spec.ts": row(25) } };
+ const b = baselines([full1, one], now);
+ assert.equal(b.same, full1);
+ assert.deepEqual(b.perSpec, { "a.spec.ts": row(4), "b.spec.ts": row(20) });
+ assert.equal(baselines([one], now).same, null);
+});
+
+test("render: slowest first, a signed delta per spec, a spec never run before is new", () => {
+ const before = { ...summarize(report()), head: "aaaaaaaa11" };
+ const now = summarize(report({ pacingMs: 10_000, startTime: "2026-10-10T01:00:00.000Z", wall: 40_000 }));
+ const lines = render({ suite: "editor", branch: "r20/e2e-speed", head: "bbbbbbbb22", run: now, earlier: [before] });
+ assert.equal(lines[0], "e2e timings — editor on r20/e2e-speed (bbbbbbbb), 2026-10-10 01:00 UTC, wall 40.0s");
+ assert.equal(lines[1], " 5 tests: 2 passed, 1 failed, 1 flaky, 1 skipped");
+ assert.equal(lines[2], " against 2026-10-09 20:40 UTC (aaaaaaaa), the same specs: wall −50.0s");
+ const body = lines.slice(4);
+ assert.match(body[1], /^ {2}pacing\.spec\.ts\s+2\s+14\.0s\s+−50\.0s {2}\(1 failed\)$/);
+ assert.match(body[2], /^ {2}channel-work\.spec\.ts\s+3\s+4\.5s\s+±0\.0s$/);
+
+ now.specs["new.spec.ts"] = row(500);
+ const mixed = render({ suite: "editor", branch: "b", head: null, run: now, earlier: [before] });
+ assert.equal(mixed[2], " no earlier run of these same specs on this branch: each spec against the last run that had it");
+ assert.match(mixed.at(-1), /^ {2}new\.spec\.ts\s+1\s+0\.5s\s+new$/);
+});
+
+test("render with no earlier run says this run is the baseline", () => {
+ const lines = render({ suite: "umtool", branch: "main", head: null, run: summarize(report()), earlier: [] });
+ assert.equal(lines[2], " no earlier run of this suite on this branch: this one is the baseline");
+ assert.doesNotMatch(lines[4], /Δ/);
+});
+
+test("the suite name comes from where the report lives", () => {
+ assert.equal(suiteFromPath("/x/editor/test-results/timings.json"), "editor");
+ assert.equal(suiteFromPath("/x/export/test-results-hub/timings.json"), "export-hub");
+ assert.equal(suiteFromPath("/x/export/test-results-report/timings.json"), "export-report");
+});
+
+test("history round-trips, and a garbled file reads as no history", () => {
+ const dir = tmp();
+ const file = historyFile(dir, "editor", "r20/e2e-speed");
+ assert.equal(path.basename(file), "r20_e2e-speed.json");
+ assert.deepEqual(readHistory(file), []);
+ writeHistory(file, [{ startTime: "a" }]);
+ assert.deepEqual(readHistory(file), [{ startTime: "a" }]);
+ fs.writeFileSync(file, "{nope");
+ assert.deepEqual(readHistory(file), []);
+});
+
+test("the CLI records the first run, then prints the second against it", () => {
+ const dir = tmp();
+ const reportFile = path.join(dir, "editor", "test-results", "timings.json");
+ fs.mkdirSync(path.dirname(reportFile), { recursive: true });
+ const hist = path.join(dir, "hist");
+ const run = () =>
+ execFileSync(process.execPath, [SCRIPT, reportFile, "--history-dir", hist, "--branch", "b"], {
+ encoding: "utf8",
+ });
+ fs.writeFileSync(reportFile, JSON.stringify(report()));
+ const one = run();
+ assert.match(one, /this one is the baseline/);
+ assert.match(one, /recorded in /);
+ assert.match(run(), /this run was already recorded/);
+ fs.writeFileSync(reportFile, JSON.stringify(report({ pacingMs: 5_000, startTime: "2026-10-10T02:00:00.000Z" })));
+ const two = run();
+ assert.match(two, /against 2026-10-09 20:40 UTC, the same specs: wall ±0\.0s/);
+ assert.match(two, /pacing\.spec\.ts\s+2\s+9\.0s\s+−55\.0s/);
+ assert.equal(readHistory(historyFile(hist, "editor", "b")).length, 2);
+});
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
@@ -38,7 +75,9 @@ export default defineConfig({
testMatch: /.*\.spec\.ts/,
fullyParallel: false,
workers: 1,
- reporter: [["list"]],
+ // `json` beside `list`: the run's timing record, which
+ // scripts/e2e-timings.mjs totals per spec against the branch's last run.
+ reporter: [["list"], ["json", { outputFile: "test-results/timings.json" }]],
use: {
baseURL: `http://localhost:${PORT}`,
trace: "retain-on-failure",
@@ -68,6 +107,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 +145,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,