Archilyzer · Source

archilyzer

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

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:
MENVIRONMENT.md | 6+++++-
Mcommon/controller/fetchWindows.test.ts | 21+++++++++++++++++++++
Mcommon/controller/fetchWindows.ts | 9+++++++++
Mcommon/jobs/platformBackoff.test.ts | 19+++++++++++++++++++
Mcommon/jobs/platformBackoff.ts | 11++++++++++-
Mcommon/lib/envVars.ts | 6+++++-
Meditor/CHANGELOG.md | 1+
Meditor/app/api/test/stuck-job/route.ts | 45+++++++++++++++++++++++++++++++++------------
Meditor/e2e/channel-storage.spec.ts | 10+++++++---
Meditor/e2e/fetch-window.spec.ts | 15++++++++++++---
Meditor/e2e/helpers.ts | 53++++++++++++++++++++++++++++++++++++-----------------
Meditor/e2e/pacing.spec.ts | 6++++--
Meditor/e2e/publish-lane.spec.ts | 16++++++++++------
Meditor/next.config.ts | 4++++
Meditor/playwright.config.ts | 77++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mexport/playwright.2origin.config.ts | 6+++++-
Mexport/playwright.config.ts | 26+++++++++++++++++++++++++-
Mexport/playwright.hub.config.ts | 26+++++++++++++++++++++++++-
Mexport/playwright.report.config.ts | 6+++++-
Mhomepage/playwright.config.ts | 26+++++++++++++++++++++++++-
Mplans/FACTS.md | 8++++++++
Mplans/e2e-speed.md | 89++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Ascripts/e2e-stamp.mjs | 346+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascripts/e2e-stamp.test.mjs | 165+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascripts/e2e-timings.mjs | 242+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascripts/e2e-timings.test.mjs | 175+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/playwright.config.ts | 44++++++++++++++++++++++++++++++++++++++++++--
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,