commit 2046a23e0bc5a92320e13dc52f39a4dd5e847792 parent 4d1a0493c8f257a30254950948e208179803fe25 Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st> Date: Mon, 28 Sep 2026 04:36:52 -0400 Merge r11/phase-4-s3 (checkpoint B) — release 11 slice O6: every test-only env var carries the E2E_ prefix and is declared in its playwright config (the queue lock and ARCHILYZER_* untouched); the harnesses read common/lib/ports.mjs; the build-mode options say the mode is a label; the homepage's Docker page matches PUBLISH.md Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Diffstat:
52 files changed, 427 insertions(+), 164 deletions(-)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md @@ -83,7 +83,7 @@ The npm script rebuilds the image first (layer cache covers unchanged deps, so repeat builds are seconds), then runs `scripts/run-sharded-e2e.mjs`. Shard count defaults to `min(max(2, cpus/2), 8)`. Override with -`SHARDS=N pnpm e2e:sharded` or `node scripts/run-sharded-e2e.mjs --shards N`. +`E2E_SHARDS=N pnpm e2e:sharded` or `node scripts/run-sharded-e2e.mjs --shards N`. **Retries default to 0**, so a sharded run reports the same failures a sequential one does. (The containers set `CI=true` because `playwright.config`'s diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md @@ -69,6 +69,8 @@ Tokens, credentials and knobs a running process reads. Most configuration is not | `TRANSCRIPT_HUB_URL` | — | MCP server: a hub, federating every archive it lists. | mcp/src/sources.ts | | `TRANSCRIPT_LOCAL_DIR` | — | MCP server: a composed public dir on disk. | mcp/src/sources.ts | | `TRANSCRIPT_PLATFORM_LINKS` | off | `1` cites platform watch pages instead of the archive's own pages. | common/lib/archive/reader-fs.ts | +| `AUDIO_CHECK_RESUME_DURING_PROBE` | the channel's `audioCheck.resumeDuringProbe` | `1` or `true` resumes yt-dlp during the audio check's probe, anything else holds it, for a one-off comparison run; unset = the channel's setting. | common/ytdlp/audioCheckedDownload.ts | +| `AUDIO_CHECK_BACKOFF_FACTOR` | the built-in factor | The audio check's interval backoff factor, in (0, 1], for a one-off run. | common/ytdlp/audioCheckedDownload.ts | | `MCP_IO_STATS` | off | `1` turns on per-call I/O accounting, for `mcp/bench`. | common/lib/archive/io-stats.ts | | `ARCHILYZER_EDITOR_URL` | `http://localhost:3001` | Which editor `pnpm ops` and the MCP's `fetch_clip` talk to. | scripts/archilyzer-ops.mjs, mcp/src/fetchClip.ts, umtool | | `ARCHILYZER_AGENT` | `cli` | Who is asking, recorded as the provenance of a curated-tag write through `pnpm ops`. | scripts/archilyzer-ops.mjs | @@ -122,7 +124,6 @@ The publish pipeline sets these for a process it spawns. Listed so a reader know | `BUILD_ARCHIVES` | on | `0` skips archive-zip generation for one build (`--skip-archives`). | common/bin/compose-site.ts, common/bin/build-archives.ts | | `ARCHIVES_READONLY` | off | `1` inside a docker-mode build container: materialize archives, never write the shared cache. | common/bin/compose-site.ts | | `HOMEPAGE_PUBLIC_DIR` | `<repo>/homepage/public` | Where `compose homepage` writes. | common/bin/compose-homepage.ts | -| `HOMEPAGE_SUMMARY_FILE` | `homepage/public/homepage-summary.json` | A dev-only summary file for the homepage; ignored by a production build. The homepage e2e sets it. | homepage/app/lib/summary.ts | ## Docker @@ -150,7 +151,7 @@ Read only by a test harness, a fake binary or a test-mode branch. Never set one | Variable | Default | What it does | Read by | |---|---|---|---| -| `EDITOR_TEST_ROUTES` | off | `1` opens the editor's `/api/test/*` routes. The e2e server sets it. | editor/app/api/test/_guard.ts, editor/instrumentation.ts | +| `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_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 | @@ -159,23 +160,26 @@ Read only by a test harness, a fake binary or a test-mode branch. Never set one | `E2E_QUEUE_LOCK_FILE` | one per machine | The queue's lock file; the queue's own tests point it elsewhere. | scripts/queue-lock.mjs | | `QUEUE_LOCK_HELD` | — | Set by the queue for the command it runs, so a nested wrapper passes through. | scripts/queue-lock.mjs | | `PLAYWRIGHT_BASE_URL` | `http://localhost:<PORT>` | The editor test server's URL; the worktree injector sets it. | editor/playwright.config.ts, editor/e2e/baseUrl.ts | -| `AUDIO_CHECK_INTERVAL_MS_OVERRIDE` | the real cadence | Shrinks the mid-download audio check so the e2e suite sees it fire. | common/ytdlp/audioCheckedDownload.ts | -| `AUDIO_CHECK_SIZE_GATE_OVERRIDE` | the real gate | Likewise, the size gate. | common/ytdlp/audioCheckedDownload.ts | -| `AUDIO_CHECK_INTERVAL_FLOOR_MS_OVERRIDE` | the real floor | Likewise, the interval floor. | common/ytdlp/audioCheckedDownload.ts | -| `AUDIO_CHECK_RECOVER_STEP_MS_OVERRIDE` | the real step | Likewise, the recovery step. | common/ytdlp/audioCheckedDownload.ts | -| `AUDIO_CHECK_RECOVER_AFTER_OVERRIDE` | the real count | Likewise, the recovery count. | common/ytdlp/audioCheckedDownload.ts | -| `AUDIO_CHECK_DEBUG_PAUSE_MS` | off | A debugging pause inside the audio check. | common/ytdlp/audioCheckedDownload.ts | -| `FAKE_YTDLP_AUDIO_CHECK_MODE` | — | Fake yt-dlp: which audio-check scenario to act out. | editor/e2e/fixtures/bin/fake-ytdlp.mjs | -| `FAKE_YTDLP_CHUNK_DELAY_MS` | — | Fake yt-dlp: delay between written chunks. | editor/e2e/fixtures/bin/fake-ytdlp.mjs | -| `FAKE_YTDLP_CORRUPT_AFTER_CHUNK` | — | Fake yt-dlp: start corrupting after this chunk. | editor/e2e/fixtures/bin/fake-ytdlp.mjs | -| `FAKE_YTDLP_CORRUPT_RUNS` | — | Fake yt-dlp: how many runs corrupt. | editor/e2e/fixtures/bin/fake-ytdlp.mjs | -| `FAKE_YTDLP_DETERMINISTIC_CORRUPT` | — | Fake yt-dlp: corrupt deterministically. | editor/e2e/fixtures/bin/fake-ytdlp.mjs | -| `FAKE_YTDLP_RECOVER_ON_RESUME` | — | Fake yt-dlp: a resumed run recovers. | editor/e2e/fixtures/bin/fake-ytdlp.mjs | -| `FAKE_YTDLP_TOTAL_CHUNKS` | — | Fake yt-dlp: how many chunks a download has. | editor/e2e/fixtures/bin/fake-ytdlp.mjs | -| `FAKE_GALLERY_DL_AUTH_FAIL` | — | Fake gallery-dl: fail as an auth error. | editor/e2e/fixtures/bin/fake-gallery-dl.mjs | -| `FIXTURE_MAX_LIFETIME_MS` | the watchdog's | How long a fake binary may live before its watchdog kills it. | editor/e2e/fixtures/bin/_watchdog.mjs | -| `OLLAMA_STUB_MODEL` | `qwen2.5:7b` | The model the ollama stub claims to serve. | editor/e2e/fixtures/ollama-stub.mjs | -| `RACK_SHOTS` | off (spec skipped) | Runs the `/channels` rack screenshot audit. | editor/e2e/channels-rack-audit.spec.ts | -| `TWO_ORIGIN_REBUILD` | off | `1` rebuilds the two-origin suite's cached hub bundle. | export/e2e-2origin/globalSetup.ts | -| `IMAGE` | `yt-dlp-transcript-browser-e2e` | The sharded e2e run's image tag. | scripts/run-sharded-e2e.mjs | -| `SKIP_BUILD` | off | `1` reuses the sharded e2e image instead of rebuilding it (`--no-build`). | scripts/run-sharded-e2e.mjs | +| `E2E_AUDIO_CHECK_INTERVAL_MS` | the real cadence | Shrinks the mid-download audio check so the e2e suite sees it fire. | common/ytdlp/audioCheckedDownload.ts | +| `E2E_AUDIO_CHECK_SIZE_GATE` | the real gate | Likewise, the size gate. | common/ytdlp/audioCheckedDownload.ts | +| `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_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 | +| `E2E_FAKE_YTDLP_CORRUPT_AFTER_CHUNK` | — | Fake yt-dlp: start corrupting after this chunk. | editor/e2e/fixtures/bin/fake-ytdlp.mjs | +| `E2E_FAKE_YTDLP_CORRUPT_RUNS` | — | Fake yt-dlp: how many runs corrupt. | editor/e2e/fixtures/bin/fake-ytdlp.mjs | +| `E2E_FAKE_YTDLP_DETERMINISTIC_CORRUPT` | — | Fake yt-dlp: corrupt deterministically. | editor/e2e/fixtures/bin/fake-ytdlp.mjs | +| `E2E_FAKE_YTDLP_RECOVER_ON_RESUME` | — | Fake yt-dlp: a resumed run recovers. | editor/e2e/fixtures/bin/fake-ytdlp.mjs | +| `E2E_FAKE_YTDLP_TOTAL_CHUNKS` | — | Fake yt-dlp: how many chunks a download has. | editor/e2e/fixtures/bin/fake-ytdlp.mjs | +| `E2E_FAKE_GALLERY_DL_AUTH_FAIL` | — | Fake gallery-dl: fail as an auth error. | editor/e2e/fixtures/bin/fake-gallery-dl.mjs | +| `E2E_FIXTURE_MAX_LIFETIME_MS` | the watchdog's | How long a fake binary may live before its watchdog kills it. | editor/e2e/fixtures/bin/_watchdog.mjs | +| `E2E_OLLAMA_STUB_MODEL` | `qwen2.5:7b` | The model the ollama stub claims to serve. | editor/e2e/fixtures/ollama-stub.mjs | +| `E2E_RACK_SHOTS` | off (spec skipped) | Runs the `/channels` rack screenshot audit. | editor/e2e/channels-rack-audit.spec.ts | +| `E2E_TWO_ORIGIN_REBUILD` | off | `1` rebuilds the two-origin suite's cached hub bundle. | export/e2e-2origin/globalSetup.ts | +| `E2E_SHARDS` | min(max(2, cpus/2), 8) | How many containers `pnpm e2e:sharded` splits the editor suite across (`--shards N` wins). | scripts/run-sharded-e2e.mjs | +| `E2E_RETRIES` | `0` | Retries per shard (`--retries N` wins); 0 keeps a sharded run comparable to a serial one. | scripts/run-sharded-e2e.mjs | +| `E2E_IMAGE` | `yt-dlp-transcript-browser-e2e` | The sharded e2e run's image tag. | scripts/run-sharded-e2e.mjs | +| `E2E_SKIP_BUILD` | off | `1` reuses the sharded e2e image instead of rebuilding it (`--no-build`). | scripts/run-sharded-e2e.mjs | +| `E2E_HOMEPAGE_SUMMARY_FILE` | `homepage/public/homepage-summary.json` | The synthetic summary the homepage's e2e dev server reads; set by `homepage/playwright.config.ts`, ignored by a production build. | homepage/app/lib/summary.ts | diff --git a/SETUP.md b/SETUP.md @@ -298,7 +298,7 @@ pnpm e2e # sequential run on the host (next dev, port 3 For a faster parallel run, `pnpm e2e:sharded` splits the suite across N Docker containers (needs **Docker**; the container image ships the browser, so no `playwright install` there). Shard count defaults to `min(max(2, cpus/2), 8)`; -override with `SHARDS=N pnpm e2e:sharded`. +override with `E2E_SHARDS=N pnpm e2e:sharded`. To run multiple checkouts / dev servers / e2e suites at once without port collisions, see [WORKTREES.md](WORKTREES.md). diff --git a/WORKTREES.md b/WORKTREES.md @@ -129,14 +129,20 @@ lock at that moment, no *queued* run can own those ports: what you are looking a leftover from a killed run, or a server someone started by hand. The trade-off: a hand-started `pnpm dev:test` on 3011 is no longer silently reused, so the -~30s server boot is no longer skippable. `E2E_PORT_CHECK=0` restores the old behavior. +~30s server boot is no longer skippable. `E2E_PORT_CHECK=0` restores the old behavior — but a +reused server must then be started with the test-only env that `editor/playwright.config.ts`'s +`E2E_SERVER_ENV` gives the servers Playwright starts (`E2E_TEST_ROUTES=1 +E2E_AUDIO_CHECK_INTERVAL_MS=300 E2E_AUDIO_CHECK_SIZE_GATE=4096 +E2E_AUDIO_CHECK_INTERVAL_FLOOR_MS=50 E2E_AUDIO_CHECK_RECOVER_STEP_MS=100 +E2E_AUDIO_CHECK_RECOVER_AFTER=2 pnpm dev:test`). Without it `/api/test/*` answers 404 and the +audio checks run at production pace, which reads as real failures. ### Escape hatches | Variable | Effect | |---|---| | `E2E_QUEUE=0` | Skip the queue entirely (the port preflight still runs) | -| `E2E_PORT_CHECK=0` | Skip the port preflight, reusing whatever servers are up | +| `E2E_PORT_CHECK=0` | Skip the port preflight, reusing whatever servers are up (a hand-started editor test server needs the config's `E2E_SERVER_ENV`, above) | | `E2E_QUEUE_TIMEOUT=<seconds>` | Give up waiting after N seconds (default: wait forever) | Verify the queue with `pnpm test:scripts`. diff --git a/common/lib/envVars.test.ts b/common/lib/envVars.test.ts @@ -51,8 +51,10 @@ const READ_PATTERNS = [ // `env.X` on an env object handed in (a spawn's env, a testable `env = // process.env` parameter). /\b[eE]nv\??\.([A-Z][A-Z0-9_]{2,})\b/g, - // audioCheckedDownload.ts reads its test overrides through a helper. - /envIntOverride\(["']([A-Z][A-Z0-9_]+)["']\)/g, + // audioCheckedDownload.ts reads its overrides through helpers. + /env(?:Int|Float|Bool)Override\(\s*["']([A-Z][A-Z0-9_]+)["']\s*\)/g, + // run-sharded-e2e.mjs reads a flag's env twin by name. + /parseIntArg\(\s*["'][^"']+["'],\s*["']([A-Z][A-Z0-9_]+)["']/g, ]; function reads(): Map<string, Set<string>> { @@ -144,3 +146,37 @@ test("the docker audience is the ARCHILYZER_ set", () => { assert.match(v.name, /^ARCHILYZER_/); } }); + +// THE TEST AUDIENCE IS E2E_-PREFIXED AND DECLARED IN A PLAYWRIGHT CONFIG +// (one-core Phase 4 slice 3). The two exceptions keep names others depend on: +// Playwright's own convention, and the machine-global queue's nesting marker +// (its protocol is shared with checkouts on older code). +const UNPREFIXED_TEST_VARS = new Set(["PLAYWRIGHT_BASE_URL", "QUEUE_LOCK_HELD"]); + +test("every test-only variable carries the E2E_ prefix", () => { + const bad = ENV_VARS.filter( + (v) => v.audience === "test" && !v.name.startsWith("E2E_") && !UNPREFIXED_TEST_VARS.has(v.name), + ).map((v) => v.name); + assert.deepEqual(bad, []); + // And nothing outside the test audience wears the prefix. + assert.deepEqual( + ENV_VARS.filter((v) => v.audience !== "test" && v.name.startsWith("E2E_")).map((v) => v.name), + [], + ); +}); + +test("every test-only variable is declared in a playwright config (the queue's own in queue-lock.mjs)", () => { + const configs = ["editor", "export", "homepage", "umtool"].flatMap((pkg) => + readdirSync(path.join(REPO, pkg)) + .filter((n) => /^playwright.*\.config\.ts$/.test(n)) + .map((n) => readFileSync(path.join(REPO, pkg, n), "utf8")), + ).join("\n"); + const queue = readFileSync(path.join(REPO, "scripts", "queue-lock.mjs"), "utf8"); + const missing = ENV_VARS.filter((v) => v.audience === "test") + .filter((v) => { + const home = v.readBy.includes("scripts/queue-lock.mjs") ? queue : configs; + return !new RegExp(`\\b${v.name}\\b`).test(home); + }) + .map((v) => v.name); + assert.deepEqual(missing, []); +}); diff --git a/common/lib/envVars.ts b/common/lib/envVars.ts @@ -105,6 +105,8 @@ const DECLARED: EnvVarDecl[] = [ { name: "TRANSCRIPT_HUB_URL", audience: "runtime", default: "—", readBy: "mcp/src/sources.ts", doc: "MCP server: a hub, federating every archive it lists." }, { name: "TRANSCRIPT_LOCAL_DIR", audience: "runtime", default: "—", readBy: "mcp/src/sources.ts", doc: "MCP server: a composed public dir on disk." }, { name: "TRANSCRIPT_PLATFORM_LINKS", audience: "runtime", default: "off", readBy: "common/lib/archive/reader-fs.ts", doc: "`1` cites platform watch pages instead of the archive's own pages." }, + { name: "AUDIO_CHECK_RESUME_DURING_PROBE", audience: "runtime", default: "the channel's `audioCheck.resumeDuringProbe`", readBy: "common/ytdlp/audioCheckedDownload.ts", doc: "`1` or `true` resumes yt-dlp during the audio check's probe, anything else holds it, for a one-off comparison run; unset = the channel's setting." }, + { name: "AUDIO_CHECK_BACKOFF_FACTOR", audience: "runtime", default: "the built-in factor", readBy: "common/ytdlp/audioCheckedDownload.ts", doc: "The audio check's interval backoff factor, in (0, 1], for a one-off run." }, { name: "MCP_IO_STATS", audience: "runtime", default: "off", readBy: "common/lib/archive/io-stats.ts", doc: "`1` turns on per-call I/O accounting, for `mcp/bench`." }, { name: "ARCHILYZER_EDITOR_URL", audience: "runtime", default: "`http://localhost:3001`", readBy: "scripts/archilyzer-ops.mjs, mcp/src/fetchClip.ts, umtool", doc: "Which editor `pnpm ops` and the MCP's `fetch_clip` talk to." }, { name: "ARCHILYZER_AGENT", audience: "runtime", default: "`cli`", readBy: "scripts/archilyzer-ops.mjs", doc: "Who is asking, recorded as the provenance of a curated-tag write through `pnpm ops`." }, @@ -131,7 +133,6 @@ const DECLARED: EnvVarDecl[] = [ { name: "BUILD_ARCHIVES", audience: "internal", default: "on", readBy: "common/bin/compose-site.ts, common/bin/build-archives.ts", doc: "`0` skips archive-zip generation for one build (`--skip-archives`)." }, { name: "ARCHIVES_READONLY", audience: "internal", default: "off", readBy: "common/bin/compose-site.ts", doc: "`1` inside a docker-mode build container: materialize archives, never write the shared cache." }, { name: "HOMEPAGE_PUBLIC_DIR", audience: "internal", default: "`<repo>/homepage/public`", readBy: "common/bin/compose-homepage.ts", doc: "Where `compose homepage` writes." }, - { name: "HOMEPAGE_SUMMARY_FILE", audience: "internal", default: "`homepage/public/homepage-summary.json`", readBy: "homepage/app/lib/summary.ts", doc: "A dev-only summary file for the homepage; ignored by a production build. The homepage e2e sets it." }, // ── docker: the container's set ──────────────────────────────────────── { name: "ARCHILYZER_TRANSCRIBER", audience: "docker", default: "baked per image target (`whisper-cpp` in `runtime`)", readBy: "docker/entrypoint.sh", doc: "`whisper-cpp` or `parakeet`: which worker the first boot seeds and which model it fetches." }, @@ -149,7 +150,7 @@ const DECLARED: EnvVarDecl[] = [ { name: "ARCHILYZER_TAG", audience: "docker", default: "`local`", readBy: "docker-compose*.yml", doc: "The image tag the compose files build and run." }, // ── test: harnesses, fakes and test-mode branches ────────────────────── - { name: "EDITOR_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. The e2e server sets it." }, + { 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_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." }, @@ -158,26 +159,29 @@ const DECLARED: EnvVarDecl[] = [ { name: "E2E_QUEUE_LOCK_FILE", audience: "test", default: "one per machine", readBy: "scripts/queue-lock.mjs", doc: "The queue's lock file; the queue's own tests point it elsewhere." }, { name: "QUEUE_LOCK_HELD", audience: "test", default: "—", readBy: "scripts/queue-lock.mjs", doc: "Set by the queue for the command it runs, so a nested wrapper passes through." }, { name: "PLAYWRIGHT_BASE_URL", audience: "test", default: "`http://localhost:<PORT>`", readBy: "editor/playwright.config.ts, editor/e2e/baseUrl.ts", doc: "The editor test server's URL; the worktree injector sets it." }, - { name: "AUDIO_CHECK_INTERVAL_MS_OVERRIDE", audience: "test", default: "the real cadence", readBy: "common/ytdlp/audioCheckedDownload.ts", doc: "Shrinks the mid-download audio check so the e2e suite sees it fire." }, - { name: "AUDIO_CHECK_SIZE_GATE_OVERRIDE", audience: "test", default: "the real gate", readBy: "common/ytdlp/audioCheckedDownload.ts", doc: "Likewise, the size gate." }, - { name: "AUDIO_CHECK_INTERVAL_FLOOR_MS_OVERRIDE", audience: "test", default: "the real floor", readBy: "common/ytdlp/audioCheckedDownload.ts", doc: "Likewise, the interval floor." }, - { name: "AUDIO_CHECK_RECOVER_STEP_MS_OVERRIDE", audience: "test", default: "the real step", readBy: "common/ytdlp/audioCheckedDownload.ts", doc: "Likewise, the recovery step." }, - { name: "AUDIO_CHECK_RECOVER_AFTER_OVERRIDE", audience: "test", default: "the real count", readBy: "common/ytdlp/audioCheckedDownload.ts", doc: "Likewise, the recovery count." }, - { name: "AUDIO_CHECK_DEBUG_PAUSE_MS", audience: "test", default: "off", readBy: "common/ytdlp/audioCheckedDownload.ts", doc: "A debugging pause inside the audio check." }, - { name: "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: "FAKE_YTDLP_CHUNK_DELAY_MS", audience: "test", default: "—", readBy: "editor/e2e/fixtures/bin/fake-ytdlp.mjs", doc: "Fake yt-dlp: delay between written chunks." }, - { name: "FAKE_YTDLP_CORRUPT_AFTER_CHUNK", audience: "test", default: "—", readBy: "editor/e2e/fixtures/bin/fake-ytdlp.mjs", doc: "Fake yt-dlp: start corrupting after this chunk." }, - { name: "FAKE_YTDLP_CORRUPT_RUNS", audience: "test", default: "—", readBy: "editor/e2e/fixtures/bin/fake-ytdlp.mjs", doc: "Fake yt-dlp: how many runs corrupt." }, - { name: "FAKE_YTDLP_DETERMINISTIC_CORRUPT", audience: "test", default: "—", readBy: "editor/e2e/fixtures/bin/fake-ytdlp.mjs", doc: "Fake yt-dlp: corrupt deterministically." }, - { name: "FAKE_YTDLP_RECOVER_ON_RESUME", audience: "test", default: "—", readBy: "editor/e2e/fixtures/bin/fake-ytdlp.mjs", doc: "Fake yt-dlp: a resumed run recovers." }, - { name: "FAKE_YTDLP_TOTAL_CHUNKS", audience: "test", default: "—", readBy: "editor/e2e/fixtures/bin/fake-ytdlp.mjs", doc: "Fake yt-dlp: how many chunks a download has." }, - { name: "FAKE_GALLERY_DL_AUTH_FAIL", audience: "test", default: "—", readBy: "editor/e2e/fixtures/bin/fake-gallery-dl.mjs", doc: "Fake gallery-dl: fail as an auth error." }, - { name: "FIXTURE_MAX_LIFETIME_MS", audience: "test", default: "the watchdog's", readBy: "editor/e2e/fixtures/bin/_watchdog.mjs", doc: "How long a fake binary may live before its watchdog kills it." }, - { name: "OLLAMA_STUB_MODEL", audience: "test", default: "`qwen2.5:7b`", readBy: "editor/e2e/fixtures/ollama-stub.mjs", doc: "The model the ollama stub claims to serve." }, - { name: "RACK_SHOTS", audience: "test", default: "off (spec skipped)", readBy: "editor/e2e/channels-rack-audit.spec.ts", doc: "Runs the `/channels` rack screenshot audit." }, - { name: "TWO_ORIGIN_REBUILD", audience: "test", default: "off", readBy: "export/e2e-2origin/globalSetup.ts", doc: "`1` rebuilds the two-origin suite's cached hub bundle." }, - { name: "IMAGE", audience: "test", default: "`yt-dlp-transcript-browser-e2e`", readBy: "scripts/run-sharded-e2e.mjs", doc: "The sharded e2e run's image tag." }, - { name: "SKIP_BUILD", audience: "test", default: "off", readBy: "scripts/run-sharded-e2e.mjs", doc: "`1` reuses the sharded e2e image instead of rebuilding it (`--no-build`)." }, + { name: "E2E_AUDIO_CHECK_INTERVAL_MS", audience: "test", default: "the real cadence", readBy: "common/ytdlp/audioCheckedDownload.ts", doc: "Shrinks the mid-download audio check so the e2e suite sees it fire." }, + { name: "E2E_AUDIO_CHECK_SIZE_GATE", audience: "test", default: "the real gate", readBy: "common/ytdlp/audioCheckedDownload.ts", doc: "Likewise, the size gate." }, + { 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_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." }, + { name: "E2E_FAKE_YTDLP_CORRUPT_AFTER_CHUNK", audience: "test", default: "—", readBy: "editor/e2e/fixtures/bin/fake-ytdlp.mjs", doc: "Fake yt-dlp: start corrupting after this chunk." }, + { name: "E2E_FAKE_YTDLP_CORRUPT_RUNS", audience: "test", default: "—", readBy: "editor/e2e/fixtures/bin/fake-ytdlp.mjs", doc: "Fake yt-dlp: how many runs corrupt." }, + { name: "E2E_FAKE_YTDLP_DETERMINISTIC_CORRUPT", audience: "test", default: "—", readBy: "editor/e2e/fixtures/bin/fake-ytdlp.mjs", doc: "Fake yt-dlp: corrupt deterministically." }, + { name: "E2E_FAKE_YTDLP_RECOVER_ON_RESUME", audience: "test", default: "—", readBy: "editor/e2e/fixtures/bin/fake-ytdlp.mjs", doc: "Fake yt-dlp: a resumed run recovers." }, + { name: "E2E_FAKE_YTDLP_TOTAL_CHUNKS", audience: "test", default: "—", readBy: "editor/e2e/fixtures/bin/fake-ytdlp.mjs", doc: "Fake yt-dlp: how many chunks a download has." }, + { name: "E2E_FAKE_GALLERY_DL_AUTH_FAIL", audience: "test", default: "—", readBy: "editor/e2e/fixtures/bin/fake-gallery-dl.mjs", doc: "Fake gallery-dl: fail as an auth error." }, + { name: "E2E_FIXTURE_MAX_LIFETIME_MS", audience: "test", default: "the watchdog's", readBy: "editor/e2e/fixtures/bin/_watchdog.mjs", doc: "How long a fake binary may live before its watchdog kills it." }, + { name: "E2E_OLLAMA_STUB_MODEL", audience: "test", default: "`qwen2.5:7b`", readBy: "editor/e2e/fixtures/ollama-stub.mjs", doc: "The model the ollama stub claims to serve." }, + { name: "E2E_RACK_SHOTS", audience: "test", default: "off (spec skipped)", readBy: "editor/e2e/channels-rack-audit.spec.ts", doc: "Runs the `/channels` rack screenshot audit." }, + { name: "E2E_TWO_ORIGIN_REBUILD", audience: "test", default: "off", readBy: "export/e2e-2origin/globalSetup.ts", doc: "`1` rebuilds the two-origin suite's cached hub bundle." }, + { name: "E2E_SHARDS", audience: "test", default: "min(max(2, cpus/2), 8)", readBy: "scripts/run-sharded-e2e.mjs", doc: "How many containers `pnpm e2e:sharded` splits the editor suite across (`--shards N` wins)." }, + { name: "E2E_RETRIES", audience: "test", default: "`0`", readBy: "scripts/run-sharded-e2e.mjs", doc: "Retries per shard (`--retries N` wins); 0 keeps a sharded run comparable to a serial one." }, + { name: "E2E_IMAGE", audience: "test", default: "`yt-dlp-transcript-browser-e2e`", readBy: "scripts/run-sharded-e2e.mjs", doc: "The sharded e2e run's image tag." }, + { name: "E2E_SKIP_BUILD", audience: "test", default: "off", readBy: "scripts/run-sharded-e2e.mjs", doc: "`1` reuses the sharded e2e image instead of rebuilding it (`--no-build`)." }, + { name: "E2E_HOMEPAGE_SUMMARY_FILE", audience: "test", default: "`homepage/public/homepage-summary.json`", readBy: "homepage/app/lib/summary.ts", doc: "The synthetic summary the homepage's e2e dev server reads; set by `homepage/playwright.config.ts`, ignored by a production build." }, ]; // The port rows come from the port table, so a port is declared once. diff --git a/common/views/names.ts b/common/views/names.ts @@ -14,7 +14,7 @@ // takes a credential, and — stated because the temptation is obvious — NO // `/api/test/*` NAME MAY EVER JOIN THIS TUPLE. Those routes fabricate jobs, // restart lanes and set the worker token; they are gated on -// `EDITOR_TEST_ROUTES` precisely so they do not exist on a real editor, and +// `E2E_TEST_ROUTES` precisely so they do not exist on a real editor, and // putting one behind this dispatcher would hand it the one surface here that // has no guard at all. export const VIEW_NAMES = [ diff --git a/common/ytdlp/audioCheckedDownload.ts b/common/ytdlp/audioCheckedDownload.ts @@ -57,7 +57,8 @@ const DEFAULT_SIZE_GATE_BYTES = 1024 * 1024; // 1 MiB minimum growth between che const PART_DISCOVERY_INTERVAL_MS = 250; const PART_DISCOVERY_TIMEOUT_MS = 10_000; -// Test-only overrides. Tests need fast intervals and small file sizes to +// Test-only overrides, E2E_-prefixed and set by editor/playwright.config.ts on +// its test server. Tests need fast intervals and small file sizes to // exercise the loop without burning real seconds and megabytes. The // AUDIO_CHECK_INTERVAL_MIN_SECONDS clamp in the form parser protects against // users accidentally setting this in production; the env knobs bypass the @@ -186,16 +187,16 @@ type Knobs = { function resolveKnobs(opts: AudioCheckedOpts): Knobs { const cfg = opts.channelConfig.audioCheck; - const intervalMsOverride = envIntOverride("AUDIO_CHECK_INTERVAL_MS_OVERRIDE"); - const sizeGateOverride = envIntOverride("AUDIO_CHECK_SIZE_GATE_OVERRIDE"); - const debugPauseOverride = envIntOverride("AUDIO_CHECK_DEBUG_PAUSE_MS"); + const intervalMsOverride = envIntOverride("E2E_AUDIO_CHECK_INTERVAL_MS"); + const sizeGateOverride = envIntOverride("E2E_AUDIO_CHECK_SIZE_GATE"); + const debugPauseOverride = envIntOverride("E2E_AUDIO_CHECK_DEBUG_PAUSE_MS"); const resumeDuringProbeOverride = envBoolOverride( "AUDIO_CHECK_RESUME_DURING_PROBE", ); const backoffFactorOverride = envFloatOverride("AUDIO_CHECK_BACKOFF_FACTOR"); - const floorMsOverride = envIntOverride("AUDIO_CHECK_INTERVAL_FLOOR_MS_OVERRIDE"); - const recoverStepMsOverride = envIntOverride("AUDIO_CHECK_RECOVER_STEP_MS_OVERRIDE"); - const recoverAfterOverride = envIntOverride("AUDIO_CHECK_RECOVER_AFTER_OVERRIDE"); + const floorMsOverride = envIntOverride("E2E_AUDIO_CHECK_INTERVAL_FLOOR_MS"); + const recoverStepMsOverride = envIntOverride("E2E_AUDIO_CHECK_RECOVER_STEP_MS"); + const recoverAfterOverride = envIntOverride("E2E_AUDIO_CHECK_RECOVER_AFTER"); const intervalMs = intervalMsOverride ?? (cfg?.intervalSeconds ?? AUDIO_CHECK_INTERVAL_DEFAULT_SECONDS) * 1000; diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -7,6 +7,7 @@ - **A bucket's retry keeps its log when it empties the bucket.** On a channel's Download stage, "Download with cookies", the partial-download resume and the missing-transcript retry could lose their run log part-way: the video they fetched left the bucket, the page refreshed, and the card disappeared with the log in it. The card now stays, with its log and its button disabled, until the page is reloaded. The Transcribe stage's "Fetch audio" button does the same. (A Diagnostics card still disappears, log and all, when its retry empties it.) - **A release cut whose commit fails still refreshes the pages.** When the changelog's new heading was written but the commit after it failed, the Cut release form and `pnpm ops cut-release` answered as if nothing had happened and no page showed the new heading until a reload. Both now refresh the changelog pages, and `pnpm ops cut-release` says the file was written. Every refused cut's answer says whether anything was written (`untouched`), and a cut of both changelogs that stopped half-way names the one already cut as well as the failure. - **`archilyzer` checks the machine, runs one operation offline, starts the MCP server, and is one command from the repo root.** `pnpm archilyzer <command>` is the short form (`pnpm archilyzer --help` lists them all). `pnpm archilyzer doctor` is a read-only report: Node, the checkout, the corpus and whether each channel's media is reachable, `settings.json`, every tool the paths name plus each enabled worker's engine and model, umtool's report-pipeline tools, and this checkout's ports; it exits 1 only for something the machine is set up to do and cannot. `pnpm archilyzer run <operation> <channel> [ids…]` runs diarization, either attribution pass or digest over one channel as the editor's job does (a job record and log under `.jobs/`, the same summary line, the same refusal for an unmounted drive); sync, the metadata scan, downloads and transcription are refused with the reason, because they run on the editor's paced download queue and worker pool. `pnpm archilyzer mcp` starts the MCP server, so it can be registered as `-- pnpm -C "$PWD" archilyzer mcp`. Every other script in `common/bin/` is a subcommand too (`duplicates`, `posts fetch`, `digest plan`, `verify transcripts`, …), and export's `detect:duplicates` script is now `archilyzer duplicates`. Every environment variable is listed, by audience, in the new `ENVIRONMENT.md`, and `DEPLOY_CLOUDFLARE.md` and `DEPLOY_DOCKER.md` are now one `PUBLISH.md`. Settings and `/sites` no longer call the Docker build mode a follow-up, and say what it is: a label. **Build all sites** builds in containers whenever a container engine answers, whichever mode is set. +- **Settings' build mode says it is a label, and the test-only environment variables start with `E2E_`.** The Build mode choices now read *Basic (a label for now)* and *Docker (a label for now)*, and the **Build all sites** section and the per-site panel on `/sites` say the same: Build all builds every site in parallel in containers whenever a container engine answers, whatever the mode, and single-site builds run one at a time. For whoever runs the test suites: every variable only a test harness reads is renamed with an `E2E_` prefix and listed in its package's `playwright.config.ts` (and in `ENVIRONMENT.md`); `SHARDS=N pnpm e2e:sharded` is now `E2E_SHARDS=N`. - **In high-contrast mode the sidebar's Archilyzer mark keeps its edge.** In Windows' high-contrast mode (forced colours) the reader's own background replaces the page on every ground and can be as dark as the mark's slate tile, whose thin ring is only drawn on Dark. In that mode the tile now gets a 1-pixel outline in the reader's text colour, on every ground, following its rounded corners. Nothing changes outside that mode. ## [0.9.4] - 2026-09-28 diff --git a/editor/app/api/test/_guard.ts b/editor/app/api/test/_guard.ts @@ -12,15 +12,16 @@ import { NextResponse } from "next/server"; // which is why binding to loopback is not an answer — the browser is inside the // loopback. The Caddyfile has no path rule for `/api/test` either. // -// So the surface is opt-in, the way the worker endpoint is: `EDITOR_TEST_ROUTES=1` -// is set by `dev:test` and `start:test` (editor/package.json) — the two scripts -// Playwright's webServer, the sharded runner and Dockerfile.test all boot -// through — and by nothing else. A real editor never sets it. +// So the surface is opt-in, the way the worker endpoint is: `E2E_TEST_ROUTES=1` +// is set by editor/playwright.config.ts's E2E_SERVER_ENV, on the webServer that +// runs `dev:test` / `start:test` — the one launch the host suite, the sharded +// runner and Dockerfile.test all go through — and by nothing else. A real +// editor never sets it, and neither does a hand-started `pnpm dev:test`. // // 404 AND NOT 403, deliberately: the answer must be indistinguishable from a // route that was never built. A 403 advertises that the harness exists and that // there is an env var worth guessing. export function testRouteDenied(): NextResponse | null { - if (process.env.EDITOR_TEST_ROUTES === "1") return null; + if (process.env.E2E_TEST_ROUTES === "1") return null; return NextResponse.json({ error: "Not Found" }, { status: 404 }); } diff --git a/editor/app/api/test/invalidate-cache/route.ts b/editor/app/api/test/invalidate-cache/route.ts @@ -41,8 +41,8 @@ function cancelLiveJobs() { } } -// E2E test harness only, and GUARDED BY `EDITOR_TEST_ROUTES` — which dev:test -// and start:test set, so it is still reachable from exactly the servers that +// E2E test harness only, and GUARDED BY `E2E_TEST_ROUTES` — which the +// playwright config sets for its test server, so it is reachable from exactly the servers that // need it. It cancels every live job and drops four singletons; an // unauthenticated GET doing that is CSRF-able from any page the operator has // open, loopback or not. See _guard.ts. diff --git a/editor/app/api/test/resume-lane/route.ts b/editor/app/api/test/resume-lane/route.ts @@ -20,7 +20,7 @@ export const dynamic = "force-dynamic"; // It replaced /api/test/resume-backfill-sweep, which did this for the sweep; // that route retired with the sweep in slice 1.3. // -// GUARDED BY `EDITOR_TEST_ROUTES`, like every other /api/test route. It was +// GUARDED BY `E2E_TEST_ROUTES`, like every other /api/test route. It was // 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 stops and restarts a lane runner is CSRF-able. diff --git a/editor/app/api/test/stuck-job/route.ts b/editor/app/api/test/stuck-job/route.ts @@ -18,7 +18,7 @@ export const dynamic = "force-dynamic"; // NOT auto-healed, so it persists across polls and the test can prove // FORCE-RELEASE (not auto-heal) clears it. We reproduce it directly // (backdating startedAt) since a genuinely wedged child would be racy. -// GUARDED BY `EDITOR_TEST_ROUTES`, like every other /api/test route. It was +// GUARDED BY `E2E_TEST_ROUTES`, like every other /api/test route. It was // 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. diff --git a/editor/app/api/test/worker-token/route.ts b/editor/app/api/test/worker-token/route.ts @@ -25,7 +25,7 @@ export const dynamic = "force-dynamic"; // later /api/ops and /api/worker spec answering 503 — a whole suite red from // one failure. `?set=` with no value is the restore, and it is idempotent. // -// GUARDED BY `EDITOR_TEST_ROUTES`, and it is the route that made the guard +// GUARDED BY `E2E_TEST_ROUTES`, and it is the route that made the guard // necessary: an unauthenticated GET that SETS a credential is a CSRF-able // token grant, and "the editor is a localhost admin tool" does not help — the // operator's browser is inside the loopback. See _guard.ts. diff --git a/editor/app/api/view/[name]/route.ts b/editor/app/api/view/[name]/route.ts @@ -16,7 +16,7 @@ export const dynamic = "force-dynamic"; // than a gap: all eight of these were unauthenticated polls, they are read-only, // and the editor is a loopback-only admin surface (docker/guard-exposure.sh // refuses to start it otherwise). Contrast `api/worker/health`, which takes the -// worker token, and `api/test/*`, which is gated on EDITOR_TEST_ROUTES because +// worker token, and `api/test/*`, which is gated on E2E_TEST_ROUTES because // it MUTATES. A `/api/test/*` name may never join `VIEW_NAMES`: this dispatcher // would serve it with no guard at all. // diff --git a/editor/app/settings/components/SettingsForm.tsx b/editor/app/settings/components/SettingsForm.tsx @@ -316,8 +316,8 @@ export function SettingsForm({ initial }: Props) { defaultValue={initial.buildPipeline.mode} className="rounded border border-border bg-card px-2 py-1 text-sm" > - <option value="basic">Basic — serial build queue</option> - <option value="docker">Docker — isolated parallel builds</option> + <option value="basic">Basic (a label for now)</option> + <option value="docker">Docker (a label for now)</option> </select> </label> <Field diff --git a/editor/app/sites/components/BuildSitesPanel.tsx b/editor/app/sites/components/BuildSitesPanel.tsx @@ -16,13 +16,7 @@ type Lane = { siteId: string; title: string; deploy: boolean; key: string }; // launches its own managed job, rendered as its own live JobLane. The jobs share // the build/deploy queue and run one at a time (the export/ tree is shared), // whichever build mode is set; "Build all sites" is the parallel path. -export function BuildSitesPanel({ - sites, - serial, -}: { - sites: SiteOption[]; - serial: boolean; -}) { +export function BuildSitesPanel({ sites }: { sites: SiteOption[] }) { const [selected, setSelected] = useState<Set<string>>(new Set()); const [deployAfter, setDeployAfter] = useState(false); const [lanes, setLanes] = useState<Lane[] | null>(null); @@ -125,13 +119,11 @@ export function BuildSitesPanel({ only (deploy is skipped for them). </p> )} - {serial && ( - <p className="text-xs text-muted-foreground"> - These run one at a time (the build output tree is shared). “Build - all sites” above builds every site in parallel, in containers, whenever - a container engine answers. - </p> - )} + <p className="text-xs text-muted-foreground"> + These run one at a time (the build output tree is shared), whichever + build mode is set. “Build all sites” above builds every site in + parallel, in containers, whenever a container engine answers. + </p> {lanes && lanes.length > 0 && ( <div className="flex flex-col gap-2"> diff --git a/editor/app/sites/page.tsx b/editor/app/sites/page.tsx @@ -160,14 +160,16 @@ export default async function SitesPage() { /> </section> - {/* Batch: build (and optionally deploy) several sites at once. The mode - decides whether the batch fans out, so it is set here beside it. */} + {/* Batch: build (and optionally deploy) several sites at once. The + build mode toggle sits here, and says it is a label: Build all fans + out in containers whenever an engine answers, whatever it says. */} <section className="flex flex-col gap-3 border-t border-border pt-6"> <div> <h2 className="text-lg font-semibold">Build all sites</h2> <p className="text-sm text-muted-foreground"> One job over every site: the shared data phase runs once, per-site - builds run in parallel (Docker mode), then deploys run serially. + builds run in parallel in containers (when a container engine + answers), then deploys run serially. </p> </div> <BuildModeToggle mode={buildMode} /> @@ -186,7 +188,6 @@ export default async function SitesPage() { siteTitle: s.siteTitle, cloudflareProject: s.cloudflareProject ?? null, }))} - serial={buildMode === "basic"} /> </section> diff --git a/editor/e2e/baseUrl.ts b/editor/e2e/baseUrl.ts @@ -1,7 +1,9 @@ // The editor test server's base URL. Worktrees run on offset ports (see // scripts/worktree.mjs / WORKTREES.md), so node-side fetches in specs must read -// the assigned port from the environment rather than hardcoding 3011. The -// Playwright config sets PLAYWRIGHT_BASE_URL from its PORT; the fallback matches -// the default editor test port when running without the worktree wrapper. +// the assigned port from the environment rather than hardcoding one. The +// Playwright config sets PLAYWRIGHT_BASE_URL from its PORT; the fallback is the +// same PORT (common/lib/ports.mjs) when running without the worktree wrapper. +import { portFor } from "yt-dlp-transcript-common/lib/ports.mjs"; + export const baseUrl = - process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:3011"; + process.env.PLAYWRIGHT_BASE_URL ?? `http://localhost:${portFor("PORT")}`; diff --git a/editor/e2e/channels-rack-audit.spec.ts b/editor/e2e/channels-rack-audit.spec.ts @@ -9,15 +9,15 @@ import { // THE /channels RACK AUDIT — screenshots, not assertions. A person (and the // reviewer) LOOKS at every shot for layering, overlap, clipping and // misalignment; channels-rack-layers.spec.ts is where a finding becomes a -// hit-test that fails. Skipped unless RACK_SHOTS is set, so the ordinary suite +// hit-test that fails. Skipped unless E2E_RACK_SHOTS is set, so the ordinary suite // never spends time here: // -// RACK_SHOTS=1 pnpm e2e channels-rack-audit.spec.ts (from the repo root) +// E2E_RACK_SHOTS=1 pnpm e2e channels-rack-audit.spec.ts (from the repo root) // // Shots land in editor/test-results/rack-shots/ (gitignored; Playwright clears // test-results/ at the start of every run, so copy a set out before the next). -test.skip(!process.env.RACK_SHOTS, "audit only"); +test.skip(!process.env.E2E_RACK_SHOTS, "audit only"); const EXTRA = Array.from( { length: 16 }, diff --git a/editor/e2e/deploy-page.spec.ts b/editor/e2e/deploy-page.spec.ts @@ -149,6 +149,6 @@ test("batch panel: selecting sites enables the launch button and reflects deploy page.getByRole("button", { name: "Build & deploy selected" }), ).toBeVisible(); - // Basic mode (the default) notes that the batch runs serially. + // The panel notes that the batch runs serially, whatever the mode. await expect(page.getByText(/These run one at a time/i)).toBeVisible(); }); diff --git a/editor/e2e/export-player-platform-cache.spec.ts b/editor/e2e/export-player-platform-cache.spec.ts @@ -1,4 +1,5 @@ import { test, expect, type Page } from "@playwright/test"; +import { portFor } from "yt-dlp-transcript-common/lib/ports.mjs"; // Regression: an Odysee video must not open in the YouTube player because of a // stale local transcript cache. Before multi-platform support, the export @@ -8,7 +9,7 @@ import { test, expect, type Page } from "@playwright/test"; // rejects platform-less / unknown-platform entries on read (transcriptStore // idbGet), so the authoritative page data (platform: "odysee") wins. -const EXPORT_BASE = `http://localhost:${process.env.EXPORT_PORT ?? 3010}`; +const EXPORT_BASE = `http://localhost:${portFor("EXPORT_PORT")}`; const CHANNEL = "Test Channel"; const CHANNEL_SLUG = "test-channel"; diff --git a/editor/e2e/export-search.spec.ts b/editor/e2e/export-search.spec.ts @@ -1,8 +1,9 @@ import { test, expect, type Page } from "@playwright/test"; +import { portFor } from "yt-dlp-transcript-common/lib/ports.mjs"; import { PROJECT_URL } from "../../common/lib/project"; import { readJson, writeSettings } from "./helpers"; -const EXPORT_BASE = `http://localhost:${process.env.EXPORT_PORT ?? 3010}`; +const EXPORT_BASE = `http://localhost:${portFor("EXPORT_PORT")}`; // NOTE: the search UI is the layered query builder (common/components/ // TranscriptSearch.tsx + QueryLeafView.tsx). A search commit writes the query diff --git a/editor/e2e/fixtures/bin/_watchdog.mjs b/editor/e2e/fixtures/bin/_watchdog.mjs @@ -18,7 +18,7 @@ // wedged. The longest legitimate fixture life is 30s // (fake-parakeet-stitch's completion backstop, and fake-ytdlp --test-slow, // which jobs-retry/jobs-reorder/queues specs rely on), so the default 120s -// leaves 4x margin. Override with FIXTURE_MAX_LIFETIME_MS if a future +// leaves 4x margin. Override with E2E_FIXTURE_MAX_LIFETIME_MS if a future // fixture legitimately needs longer. // // BOTH timers are unref'd. That is the load-bearing detail: a ref'd timer would @@ -45,7 +45,7 @@ export function installFixtureWatchdog() { poll.unref(); const maxLifetimeMs = - Number(process.env.FIXTURE_MAX_LIFETIME_MS) || DEFAULT_MAX_LIFETIME_MS; + Number(process.env.E2E_FIXTURE_MAX_LIFETIME_MS) || DEFAULT_MAX_LIFETIME_MS; const backstop = setTimeout(() => { process.stderr.write( `[watchdog] ${label}: exceeded ${maxLifetimeMs}ms lifetime — exiting\n`, diff --git a/editor/e2e/fixtures/bin/fake-gallery-dl.mjs b/editor/e2e/fixtures/bin/fake-gallery-dl.mjs @@ -33,7 +33,7 @@ if (rangeIdx >= 0 && args[rangeIdx + 1]) { // Mirror the real failure the Needs-cookies bucket models: when the fixture is // asked to simulate an expired session, exit non-zero with an auth-shaped // message on stderr. -if (process.env.FAKE_GALLERY_DL_AUTH_FAIL === "1") { +if (process.env.E2E_FAKE_GALLERY_DL_AUTH_FAIL === "1") { process.stderr.write( "[twitter][error] HTTP redirect to login page (401 Unauthorized)\n", ); diff --git a/editor/e2e/fixtures/bin/fake-ytdlp.mjs b/editor/e2e/fixtures/bin/fake-ytdlp.mjs @@ -28,7 +28,7 @@ const CORRUPT_MARKER = "__CORRUPT__"; // below, the pause test can prove the download is frozen while the probe runs. const SLOW_PROBE_MARKER = "__SLOWPROBE__"; // Tests use small chunks so the size gate (defaults to 1 MiB; tests override -// via AUDIO_CHECK_SIZE_GATE_OVERRIDE) can be exceeded each chunk. ~16 KiB. +// via E2E_AUDIO_CHECK_SIZE_GATE) can be exceeded each chunk. ~16 KiB. const CHUNK_BYTES = 16 * 1024; const argv = process.argv.slice(2); @@ -293,10 +293,10 @@ async function readAudioCheckSidecar() { async function modeAudioCheckOneUrl(url) { const sidecar = await readAudioCheckSidecar(); const mode = - sidecar.mode ?? process.env.FAKE_YTDLP_AUDIO_CHECK_MODE ?? "happy"; + sidecar.mode ?? process.env.E2E_FAKE_YTDLP_AUDIO_CHECK_MODE ?? "happy"; const recoverOnResume = sidecar.recoverOnResume === true || - process.env.FAKE_YTDLP_RECOVER_ON_RESUME === "1"; + process.env.E2E_FAKE_YTDLP_RECOVER_ON_RESUME === "1"; // Generalizes recoverOnResume (= corruptRuns:1): serve corruption on the // first N audio-check invocations, then behave. Lets a test drive several // successive mid-stream rollbacks (e.g. to observe the adaptive probe @@ -304,21 +304,21 @@ async function modeAudioCheckOneUrl(url) { const corruptRuns = sidecar.corruptRuns != null ? Number.parseInt(String(sidecar.corruptRuns), 10) - : process.env.FAKE_YTDLP_CORRUPT_RUNS != null - ? Number.parseInt(process.env.FAKE_YTDLP_CORRUPT_RUNS, 10) + : process.env.E2E_FAKE_YTDLP_CORRUPT_RUNS != null + ? Number.parseInt(process.env.E2E_FAKE_YTDLP_CORRUPT_RUNS, 10) : null; const deterministicCorrupt = sidecar.deterministicCorrupt === true || - process.env.FAKE_YTDLP_DETERMINISTIC_CORRUPT === "1"; + process.env.E2E_FAKE_YTDLP_DETERMINISTIC_CORRUPT === "1"; const totalChunks = Number.parseInt( String( - sidecar.totalChunks ?? process.env.FAKE_YTDLP_TOTAL_CHUNKS ?? "6", + sidecar.totalChunks ?? process.env.E2E_FAKE_YTDLP_TOTAL_CHUNKS ?? "6", ), 10, ); const chunkDelayMs = Number.parseInt( String( - sidecar.chunkDelayMs ?? process.env.FAKE_YTDLP_CHUNK_DELAY_MS ?? "200", + sidecar.chunkDelayMs ?? process.env.E2E_FAKE_YTDLP_CHUNK_DELAY_MS ?? "200", ), 10, ); @@ -385,7 +385,7 @@ async function modeAudioCheckOneUrl(url) { ? Number.parseInt( String( sidecar.corruptAfterChunk ?? - process.env.FAKE_YTDLP_CORRUPT_AFTER_CHUNK ?? + process.env.E2E_FAKE_YTDLP_CORRUPT_AFTER_CHUNK ?? "2", ), 10, diff --git a/editor/e2e/fixtures/ollama-stub.mjs b/editor/e2e/fixtures/ollama-stub.mjs @@ -25,9 +25,10 @@ // write no file and there would be no warnings[] on disk to assert against. import { createServer } from "node:http"; +import { portFor } from "yt-dlp-transcript-common/lib/ports.mjs"; -const PORT = Number(process.env.OLLAMA_STUB_PORT ?? 11435); -const MODEL = process.env.OLLAMA_STUB_MODEL ?? "qwen2.5:7b"; +const PORT = portFor("OLLAMA_STUB_PORT"); +const MODEL = process.env.E2E_OLLAMA_STUB_MODEL ?? "qwen2.5:7b"; const SENTINEL = /badout/i; function hms(total) { diff --git a/editor/instrumentation.ts b/editor/instrumentation.ts @@ -133,7 +133,7 @@ export async function register() { const { getRegistry } = await import( "yt-dlp-transcript-common/jobs/registry" ); - const testServer = process.env.EDITOR_TEST_ROUTES === "1"; + const testServer = process.env.E2E_TEST_ROUTES === "1"; const cancelOnly = idle || testServer; void settleAfterStoragePass(storagePass, { paths: getPaths(), diff --git a/editor/package.json b/editor/package.json @@ -5,8 +5,8 @@ "type": "module", "scripts": { "dev": "next dev --port ${EDITOR_PORT:-3001}", - "dev:test": "EDITOR_TEST_ROUTES=1 WORKER_TOKEN=test-worker-token TRANSCRIPTS_DIR=$(pwd)/test-transcripts EXPORT_PUBLIC_DIR=$(pwd)/test-transcripts/.export-public SETTINGS_FILE=$(pwd)/test-settings.json EDITOR_CHANGELOG_FILE=$(pwd)/test-changelog.md EXPORT_CHANGELOG_FILE=$(pwd)/test-export-changelog.md YTDLP_BIN=$(pwd)/e2e/fixtures/bin/fake-ytdlp.mjs GALLERY_DL_BIN=$(pwd)/e2e/fixtures/bin/fake-gallery-dl.mjs WHISPER_BIN=$(pwd)/e2e/fixtures/bin/fake-whisper.mjs WHISPER_MODEL=/dev/null CHOUGH_BIN=$(pwd)/e2e/fixtures/bin/fake-chough.mjs CHOUGH_MODEL=/dev/null PARAKEET_STITCH_BIN=$(pwd)/e2e/fixtures/bin/fake-parakeet-stitch.mjs PARAKEET_CLI=/dev/null PARAKEET_MODEL=/dev/null DIARIZE_BIN=$(pwd)/e2e/fixtures/bin/fake-diarize.mjs FFMPEG_BIN=$(pwd)/e2e/fixtures/bin/fake-ffmpeg.mjs FFPROBE_BIN=$(pwd)/e2e/fixtures/bin/fake-ffprobe.mjs OLLAMA_URL=http://127.0.0.1:${OLLAMA_STUB_PORT:-11435} CLAUDE_BIN=$(pwd)/e2e/fixtures/bin/fake-claude.mjs FINDMNT_BIN=$(pwd)/e2e/fixtures/bin/fake-findmnt.mjs UDISKSCTL_BIN=$(pwd)/e2e/fixtures/bin/fake-udisksctl.mjs AUDIO_CHECK_INTERVAL_MS_OVERRIDE=300 AUDIO_CHECK_SIZE_GATE_OVERRIDE=4096 AUDIO_CHECK_INTERVAL_FLOOR_MS_OVERRIDE=50 AUDIO_CHECK_RECOVER_STEP_MS_OVERRIDE=100 AUDIO_CHECK_RECOVER_AFTER_OVERRIDE=2 next dev --port ${PORT:-3011}", - "start:test": "EDITOR_TEST_ROUTES=1 WORKER_TOKEN=test-worker-token TRANSCRIPTS_DIR=$(pwd)/test-transcripts EXPORT_PUBLIC_DIR=$(pwd)/test-transcripts/.export-public SETTINGS_FILE=$(pwd)/test-settings.json EDITOR_CHANGELOG_FILE=$(pwd)/test-changelog.md EXPORT_CHANGELOG_FILE=$(pwd)/test-export-changelog.md YTDLP_BIN=$(pwd)/e2e/fixtures/bin/fake-ytdlp.mjs GALLERY_DL_BIN=$(pwd)/e2e/fixtures/bin/fake-gallery-dl.mjs WHISPER_BIN=$(pwd)/e2e/fixtures/bin/fake-whisper.mjs WHISPER_MODEL=/dev/null CHOUGH_BIN=$(pwd)/e2e/fixtures/bin/fake-chough.mjs CHOUGH_MODEL=/dev/null PARAKEET_STITCH_BIN=$(pwd)/e2e/fixtures/bin/fake-parakeet-stitch.mjs PARAKEET_CLI=/dev/null PARAKEET_MODEL=/dev/null DIARIZE_BIN=$(pwd)/e2e/fixtures/bin/fake-diarize.mjs FFMPEG_BIN=$(pwd)/e2e/fixtures/bin/fake-ffmpeg.mjs FFPROBE_BIN=$(pwd)/e2e/fixtures/bin/fake-ffprobe.mjs OLLAMA_URL=http://127.0.0.1:${OLLAMA_STUB_PORT:-11435} CLAUDE_BIN=$(pwd)/e2e/fixtures/bin/fake-claude.mjs FINDMNT_BIN=$(pwd)/e2e/fixtures/bin/fake-findmnt.mjs UDISKSCTL_BIN=$(pwd)/e2e/fixtures/bin/fake-udisksctl.mjs AUDIO_CHECK_INTERVAL_MS_OVERRIDE=300 AUDIO_CHECK_SIZE_GATE_OVERRIDE=4096 AUDIO_CHECK_INTERVAL_FLOOR_MS_OVERRIDE=50 AUDIO_CHECK_RECOVER_STEP_MS_OVERRIDE=100 AUDIO_CHECK_RECOVER_AFTER_OVERRIDE=2 next start --port ${PORT:-3011}", + "dev:test": "WORKER_TOKEN=test-worker-token TRANSCRIPTS_DIR=$(pwd)/test-transcripts EXPORT_PUBLIC_DIR=$(pwd)/test-transcripts/.export-public SETTINGS_FILE=$(pwd)/test-settings.json EDITOR_CHANGELOG_FILE=$(pwd)/test-changelog.md EXPORT_CHANGELOG_FILE=$(pwd)/test-export-changelog.md YTDLP_BIN=$(pwd)/e2e/fixtures/bin/fake-ytdlp.mjs GALLERY_DL_BIN=$(pwd)/e2e/fixtures/bin/fake-gallery-dl.mjs WHISPER_BIN=$(pwd)/e2e/fixtures/bin/fake-whisper.mjs WHISPER_MODEL=/dev/null CHOUGH_BIN=$(pwd)/e2e/fixtures/bin/fake-chough.mjs CHOUGH_MODEL=/dev/null PARAKEET_STITCH_BIN=$(pwd)/e2e/fixtures/bin/fake-parakeet-stitch.mjs PARAKEET_CLI=/dev/null PARAKEET_MODEL=/dev/null DIARIZE_BIN=$(pwd)/e2e/fixtures/bin/fake-diarize.mjs FFMPEG_BIN=$(pwd)/e2e/fixtures/bin/fake-ffmpeg.mjs FFPROBE_BIN=$(pwd)/e2e/fixtures/bin/fake-ffprobe.mjs OLLAMA_URL=http://127.0.0.1:${OLLAMA_STUB_PORT:-11435} CLAUDE_BIN=$(pwd)/e2e/fixtures/bin/fake-claude.mjs FINDMNT_BIN=$(pwd)/e2e/fixtures/bin/fake-findmnt.mjs UDISKSCTL_BIN=$(pwd)/e2e/fixtures/bin/fake-udisksctl.mjs next dev --port ${PORT:-3011}", + "start:test": "WORKER_TOKEN=test-worker-token TRANSCRIPTS_DIR=$(pwd)/test-transcripts EXPORT_PUBLIC_DIR=$(pwd)/test-transcripts/.export-public SETTINGS_FILE=$(pwd)/test-settings.json EDITOR_CHANGELOG_FILE=$(pwd)/test-changelog.md EXPORT_CHANGELOG_FILE=$(pwd)/test-export-changelog.md YTDLP_BIN=$(pwd)/e2e/fixtures/bin/fake-ytdlp.mjs GALLERY_DL_BIN=$(pwd)/e2e/fixtures/bin/fake-gallery-dl.mjs WHISPER_BIN=$(pwd)/e2e/fixtures/bin/fake-whisper.mjs WHISPER_MODEL=/dev/null CHOUGH_BIN=$(pwd)/e2e/fixtures/bin/fake-chough.mjs CHOUGH_MODEL=/dev/null PARAKEET_STITCH_BIN=$(pwd)/e2e/fixtures/bin/fake-parakeet-stitch.mjs PARAKEET_CLI=/dev/null PARAKEET_MODEL=/dev/null DIARIZE_BIN=$(pwd)/e2e/fixtures/bin/fake-diarize.mjs FFMPEG_BIN=$(pwd)/e2e/fixtures/bin/fake-ffmpeg.mjs FFPROBE_BIN=$(pwd)/e2e/fixtures/bin/fake-ffprobe.mjs OLLAMA_URL=http://127.0.0.1:${OLLAMA_STUB_PORT:-11435} CLAUDE_BIN=$(pwd)/e2e/fixtures/bin/fake-claude.mjs FINDMNT_BIN=$(pwd)/e2e/fixtures/bin/fake-findmnt.mjs UDISKSCTL_BIN=$(pwd)/e2e/fixtures/bin/fake-udisksctl.mjs next start --port ${PORT:-3011}", "build": "next build", "start": "next start --port ${EDITOR_PORT:-3001}", "lint": "eslint", diff --git a/editor/playwright.config.ts b/editor/playwright.config.ts @@ -1,7 +1,49 @@ import path from "node:path"; import { defineConfig, devices } from "@playwright/test"; +import { portFor } from "yt-dlp-transcript-common/lib/ports.mjs"; -const PORT = Number(process.env.PORT ?? 3011); +// THE TEST-ONLY ENVIRONMENT, declared here (one-core Phase 4 slice 3). Every +// variable only a harness reads carries the E2E_ prefix; the ones this config +// sets for the editor's test server are E2E_SERVER_ENV (passed as the +// webServer's env below), and the rest are knobs a spec or a person sets: +// +// E2E_TEST_ROUTES=1 opens /api/test/* (api/test/_guard.ts) and makes +// the boot settle a test server's leftover jobs +// (instrumentation.ts) — the test server's identity +// E2E_AUDIO_CHECK_INTERVAL_MS, E2E_AUDIO_CHECK_SIZE_GATE, +// E2E_AUDIO_CHECK_INTERVAL_FLOOR_MS, E2E_AUDIO_CHECK_RECOVER_STEP_MS, +// E2E_AUDIO_CHECK_RECOVER_AFTER +// 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_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, +// E2E_FAKE_YTDLP_DETERMINISTIC_CORRUPT, E2E_FAKE_YTDLP_RECOVER_ON_RESUME, +// E2E_FAKE_YTDLP_TOTAL_CHUNKS +// fake-ytdlp.mjs's env fallbacks behind its +// .fake-ytdlp-audio-check.json sidecar +// E2E_FAKE_GALLERY_DL_AUTH_FAIL fake-gallery-dl.mjs fails as an auth error +// E2E_FIXTURE_MAX_LIFETIME_MS a fake binary's watchdog budget +// E2E_OLLAMA_STUB_MODEL the model the ollama stub claims to serve +// E2E_SHARDS, E2E_IMAGE, E2E_SKIP_BUILD, E2E_RETRIES +// the sharded runner (scripts/run-sharded-e2e.mjs) +// +// The ports are NOT prefixed: they are common/lib/ports.mjs's, injected per +// worktree by scripts/worktree.mjs and named in queue-lock's --ports list. Nor +// are the queue's own E2E_QUEUE / E2E_PORT_CHECK / E2E_QUEUE_TIMEOUT (read by +// scripts/queue-lock.mjs, shared machine-wide). ENVIRONMENT.md lists them all. +const E2E_SERVER_ENV = { + E2E_TEST_ROUTES: "1", + E2E_AUDIO_CHECK_INTERVAL_MS: "300", + E2E_AUDIO_CHECK_SIZE_GATE: "4096", + E2E_AUDIO_CHECK_INTERVAL_FLOOR_MS: "50", + E2E_AUDIO_CHECK_RECOVER_STEP_MS: "100", + E2E_AUDIO_CHECK_RECOVER_AFTER: "2", +}; + +const PORT = portFor("PORT"); const baseURL = `http://localhost:${PORT}`; // Expose the assigned base URL to node-side spec code (fetches to the editor's // test API). Keeps offset-port worktrees working even when run directly without @@ -14,10 +56,10 @@ const webServerCommand = // webServer entry instead of a fake binary in e2e/fixtures/bin/. Its port is // exported so the editor's dev:test / start:test scripts point OLLAMA_URL at the // same place, and so an offset-port worktree does not collide. -const OLLAMA_STUB_PORT = Number(process.env.OLLAMA_STUB_PORT ?? 11435); +const OLLAMA_STUB_PORT = portFor("OLLAMA_STUB_PORT"); process.env.OLLAMA_STUB_PORT = String(OLLAMA_STUB_PORT); -const EXPORT_PORT = Number(process.env.EXPORT_PORT ?? 3010); +const EXPORT_PORT = portFor("EXPORT_PORT"); const exportBaseURL = `http://localhost:${EXPORT_PORT}`; // Share the editor's test-settings.json with the export server so the // footer (and any other server-rendered settings consumers) can be @@ -78,6 +120,7 @@ export default defineConfig({ url: baseURL, timeout: 120_000, reuseExistingServer: !process.env.CI, + env: E2E_SERVER_ENV, }, { command: `pnpm --filter export run dev --port ${EXPORT_PORT}`, diff --git a/export/e2e-2origin/federation.spec.ts b/export/e2e-2origin/federation.spec.ts @@ -1,11 +1,12 @@ import { expect, test, type Page } from "@playwright/test"; +import { portFor } from "yt-dlp-transcript-common/lib/ports.mjs"; // Real two-origin federation. Origin B is a genuine static server sending // Access-Control-Allow-Origin (see globalSetup); the hub (baseURL) reads it over // the network, so these tests exercise the browser's REAL CORS enforcement and // the production service worker — the things route mocks can't. -const B_PORT = Number(process.env.ORIGIN_B_PORT ?? 4610); +const B_PORT = portFor("ORIGIN_B_PORT"); const ORIGIN_B = `http://localhost:${B_PORT}`; async function addArchive(page: Page, url: string) { diff --git a/export/e2e-2origin/globalSetup.ts b/export/e2e-2origin/globalSetup.ts @@ -126,9 +126,9 @@ function buildOriginB() { function buildHub() { // A real hub production build (SW registers only in prod). Skip the rebuild - // when a stage already exists unless TWO_ORIGIN_REBUILD=1 — the build is the + // when a stage already exists unless E2E_TWO_ORIGIN_REBUILD=1 — the build is the // slow part and the hub bundle rarely changes between local runs. - if (process.env.TWO_ORIGIN_REBUILD === "1") { + if (process.env.E2E_TWO_ORIGIN_REBUILD === "1") { rmSync(HUB_A, { recursive: true, force: true }); } try { diff --git a/export/playwright.2origin.config.ts b/export/playwright.2origin.config.ts @@ -1,7 +1,14 @@ import path from "node:path"; import { defineConfig, devices } from "@playwright/test"; +import { portFor } from "yt-dlp-transcript-common/lib/ports.mjs"; import { stageTwoOrigins } from "./e2e-2origin/globalSetup"; +// Test-only environment (declared here, one-core Phase 4 slice 3): +// E2E_TWO_ORIGIN_REBUILD=1 rebuild the cached hub bundle under .2origin +// (e2e-2origin/globalSetup.ts); otherwise a staged +// hubA/sw.js is reused. +// The two ports are common/lib/ports.mjs's, offset per worktree. + // Real two-origin test: proves what the route-mocked e2e-hub suite can't — // genuine cross-origin CORS enforcement and the prod service worker offline // path. We stage Origin B (CORS JSON) and Origin A (a real hub build) HERE at @@ -9,8 +16,8 @@ import { stageTwoOrigins } from "./e2e-2origin/globalSetup"; // webServers before globalSetup. The two webServer entries then serve the staged // dirs, and the hub reads Origin B over the network like a deployed hub would. const STAGE = path.resolve(process.cwd(), ".2origin"); -const B_PORT = Number(process.env.ORIGIN_B_PORT ?? 4610); -const A_PORT = Number(process.env.HUB_A_PORT ?? 4611); +const B_PORT = portFor("ORIGIN_B_PORT"); +const A_PORT = portFor("HUB_A_PORT"); const baseURL = `http://localhost:${A_PORT}`; stageTwoOrigins(); diff --git a/export/playwright.config.ts b/export/playwright.config.ts @@ -1,13 +1,16 @@ import fs from "node:fs"; import path from "node:path"; import { defineConfig, devices } from "@playwright/test"; +import { PORT_BASES } from "yt-dlp-transcript-common/lib/ports.mjs"; import { buildFixtureSettings } from "./e2e/fixtures/data"; // EXPORT_E2E_PORT, not PORT: under `wt run` (scripts/worktree.mjs) PORT is the // *editor's* test-server port (3011), so reading it here aimed this suite at the // editor's server. PORT is kept as a fallback for a bare `playwright test` with -// a hand-set port. -const PORT = Number(process.env.EXPORT_E2E_PORT ?? process.env.PORT ?? 3020); +// a hand-set port. The base is common/lib/ports.mjs's. +const PORT = Number( + process.env.EXPORT_E2E_PORT ?? process.env.PORT ?? PORT_BASES.EXPORT_E2E_PORT, +); const baseURL = `http://localhost:${PORT}`; const TEST_SETTINGS_FILE = path.resolve(process.cwd(), "test-settings.json"); diff --git a/export/playwright.hub.config.ts b/export/playwright.hub.config.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { defineConfig, devices } from "@playwright/test"; +import { portFor } from "yt-dlp-transcript-common/lib/ports.mjs"; import { buildFixtureSettings } from "./e2e/fixtures/data"; // Hub-mode e2e: the export app built/run with INSTANCE_MODE=hub renders the @@ -10,7 +11,7 @@ import { buildFixtureSettings } from "./e2e/fixtures/data"; // read only because it carries Access-Control-Allow-Origin) without standing up // a second real static server. Real _headers/serve.json CORS + the offline SW // are covered separately once the hub SW lands. -const PORT = Number(process.env.HUB_PORT ?? 3041); +const PORT = portFor("HUB_PORT"); const baseURL = `http://localhost:${PORT}`; const TEST_SETTINGS_FILE = path.resolve(process.cwd(), "test-settings.hub.json"); diff --git a/homepage/CHANGELOG.md b/homepage/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] +- **The docs' *Building several sites at once* page says what Build all does.** It called the container pipeline opt-in, turned on in the settings. Build all sites builds every site in parallel in containers whenever a container engine is available, whichever build mode is set (that choice is only a label for now), and one after another when none is. - **A single-colour social icon shows on every ground.** The footer's social icons are the operator's (`homepage.json`'s, else `settings.socialLinks`), normalized when they are saved (`normalizeSocialSvg`, release 11 slice O1). An icon drawn in one colour now takes the footer's colour throughout; before, a part that carried its own colour kept it, so X's official logo, which is white, was invisible on the Light ground. An icon of two or more colours, such as YouTube's red mark with its white triangle, keeps its colours as pasted. "No fill", gradients, masks, clip paths and animation timing are never changed, and a clip path's own colour does not count, so a one-colour icon exported from Figma follows the footer too. It applies when the settings are next saved, then needs a rebuild and deploy of the homepage. - **In high-contrast mode the header mark's tile keeps its edge.** In Windows' high-contrast mode (forced colours) the reader's own background replaces the page on every ground and can be as dark as the slate tile, whose ring is only drawn on Dark. In that mode the tile gets a 1-pixel outline in the reader's text colour, on every ground, following its rounded corners. Nothing changes outside that mode. - **A sixth official instance has a chart colour of its own.** The growth chart, its legend and `/stats` had five validated colours, so a sixth site fell to a pink within a degree of the fifth's magenta. There is now a sixth, a rust (`--chart-6`: `#823c10` on Light and Sepia, `#a54a08` on Dark), which is Vermilion's hue family, so Jasolyzer's card and its layer will share a hue once it is published. It clears every pair with the other five on all three grounds for colour-blind readers (the dataviz validator, all pairs; worst CVD ΔE 9.1, normal 16.3). Any six sites now wear the six validated colours; `/stats`' sixth channel gets the rust too. @@ -33,7 +34,7 @@ Violet → violet, Green → green, Sakura → magenta), since the accents themselves fail as a chart palette. No two sites share a colour. The `/stats` By-site leaderboard keeps each site's colour instead of colouring by rank. The e2e reads a copy of the summary with fixture - accents (`HOMEPAGE_SUMMARY_FILE`, `e2e/fixture-accents.ts`). + accents (`E2E_HOMEPAGE_SUMMARY_FILE`, `e2e/fixture-accents.ts`). ## 2026-08-12 diff --git a/homepage/app/lib/summary.test.ts b/homepage/app/lib/summary.test.ts @@ -7,18 +7,18 @@ import { summaryFile } from "./summary"; const PUBLIC = "/repo/homepage/public/homepage-summary.json"; -test("a production build never reads HOMEPAGE_SUMMARY_FILE", () => { +test("a production build never reads E2E_HOMEPAGE_SUMMARY_FILE", () => { assert.equal( - summaryFile({ NODE_ENV: "production", HOMEPAGE_SUMMARY_FILE: "/tmp/fixture.json" }, PUBLIC), + summaryFile({ NODE_ENV: "production", E2E_HOMEPAGE_SUMMARY_FILE: "/tmp/fixture.json" }, PUBLIC), PUBLIC, ); }); test("outside production (the e2e's next dev) it names the file to read", () => { assert.equal( - summaryFile({ NODE_ENV: "development", HOMEPAGE_SUMMARY_FILE: "/x/.e2e-summary.json" }, PUBLIC), + summaryFile({ NODE_ENV: "development", E2E_HOMEPAGE_SUMMARY_FILE: "/x/.e2e-summary.json" }, PUBLIC), "/x/.e2e-summary.json", ); assert.equal(summaryFile({ NODE_ENV: "development" }, PUBLIC), PUBLIC); - assert.equal(summaryFile({ NODE_ENV: "development", HOMEPAGE_SUMMARY_FILE: "" }, PUBLIC), PUBLIC); + assert.equal(summaryFile({ NODE_ENV: "development", E2E_HOMEPAGE_SUMMARY_FILE: "" }, PUBLIC), PUBLIC); }); diff --git a/homepage/app/lib/summary.ts b/homepage/app/lib/summary.ts @@ -35,7 +35,7 @@ export function loadSummary(): HomepageSummary | null { } // The file loadSummary reads: `public/homepage-summary.json`, or the file -// HOMEPAGE_SUMMARY_FILE names — but ONLY outside a production build. The e2e's +// E2E_HOMEPAGE_SUMMARY_FILE names — but ONLY outside a production build. The e2e's // `next dev` points it at a synthetic summary (e2e/fixture-summary.ts, written // by playwright.config.ts); // `next build` runs with NODE_ENV=production and ignores it, so a variable left @@ -44,6 +44,6 @@ export function summaryFile( env: Readonly<Record<string, string | undefined>>, publicFile: string, ): string { - const override = env.NODE_ENV !== "production" ? env.HOMEPAGE_SUMMARY_FILE : undefined; + const override = env.NODE_ENV !== "production" ? env.E2E_HOMEPAGE_SUMMARY_FILE : undefined; return override || publicFile; } diff --git a/homepage/content/docs.ts b/homepage/content/docs.ts @@ -67,7 +67,7 @@ export const DOCS: DocEntry[] = [ file: "deploy-docker.md", title: "Building several sites at once", blurb: - "The opt-in Docker pipeline: parallel per-site builds in isolated containers.", + "Build all sites: parallel per-site builds in isolated containers, whenever a container engine is available.", group: "publish", }, { diff --git a/homepage/content/docs/deploy-docker.md b/homepage/content/docs/deploy-docker.md @@ -1,11 +1,13 @@ # Building several sites at once -If one corpus publishes several sites, the default build handles them one after -another. The container pipeline builds them **in parallel**, in isolated -containers, and then deploys them serially. It is opt-in, and the ordinary build -is unchanged and remains the default. - -Turn it on in the editor's settings, or from the toggle on the deploy page. +If one corpus publishes several sites, **Build all sites** builds them **in +parallel**, in isolated containers, and then deploys them serially, whenever a +container engine is available on the machine. With none, it builds them one after +another instead. A single site's own build always runs one at a time. + +The editor's settings and its Sites page also show a build mode, Basic or Docker. +Today that choice is only a label: Build all sites uses containers whenever it +can, whichever mode is set. ## What you need diff --git a/homepage/e2e/fixture-summary.ts b/homepage/e2e/fixture-summary.ts @@ -8,7 +8,7 @@ import type { VideoStat } from "../../common/lib/stats"; // The pages' numbers come from homepage-summary.json, which compose writes into // public/ from a real corpus — gitignored, so a fresh clone or worktree has // none, and the data specs used to skip there (release 10: 8 of 29). So the -// e2e dev server reads THIS instead (HOMEPAGE_SUMMARY_FILE, app/lib/summary.ts, +// e2e dev server reads THIS instead (E2E_HOMEPAGE_SUMMARY_FILE, app/lib/summary.ts, // honoured only outside a production build): the real builder // (buildHomepageSummary) over made-up recordings, deterministic, written by // playwright.config.ts to e2e/.e2e-summary.json (gitignored, not linked) before diff --git a/homepage/playwright.config.ts b/homepage/playwright.config.ts @@ -1,5 +1,6 @@ import path from "node:path"; import { defineConfig, devices } from "@playwright/test"; +import { portFor } from "yt-dlp-transcript-common/lib/ports.mjs"; import { FIXTURE_SUMMARY_NAME, writeFixtureSummary, @@ -9,10 +10,17 @@ import { // source. Kill any stale dev server on the port between runs. // // The port comes from HOMEPAGE_E2E_PORT, the block scripts/worktree.mjs already -// allocates per worktree (3040 on main). It used to read `PORT`, which is the -// EDITOR's port variable — so a worktree run pointed this suite at whatever the -// editor had been given, and the per-worktree offset never applied here. -const PORT = Number(process.env.HOMEPAGE_E2E_PORT ?? 3040); +// allocates per worktree (common/lib/ports.mjs; 3040 on main). It used to read +// `PORT`, which is the EDITOR's port variable — so a worktree run pointed this +// suite at whatever the editor had been given, and the per-worktree offset never +// applied here. +// +// Test-only environment, declared here (one-core Phase 4 slice 3): +// E2E_HOMEPAGE_SUMMARY_FILE set below on the dev server: the synthetic +// summary it reads instead of +// public/homepage-summary.json (app/lib/summary.ts, +// ignored by a production build) +const PORT = portFor("HOMEPAGE_E2E_PORT"); const baseURL = `http://localhost:${PORT}`; // The dev server reads a SYNTHETIC summary (e2e/fixture-summary.ts), never @@ -36,7 +44,7 @@ export default defineConfig({ url: baseURL, timeout: 120_000, reuseExistingServer: !process.env.CI, - env: { HOMEPAGE_SUMMARY_FILE: FIXTURE_SUMMARY }, + env: { E2E_HOMEPAGE_SUMMARY_FILE: FIXTURE_SUMMARY }, }, use: { baseURL, diff --git a/plans/release-11.md b/plans/release-11.md @@ -1375,6 +1375,139 @@ small round on the same branch and worktree. `main` merged first (a fast-forward attribute` during `stats.spec`'s Customize test (ranked / small multiples). It is in O2's runs too, so it predates O2b. +### Slice O6, as shipped — checkpoint B: the `E2E_` prefix cleanup (2026-09-28) + +After checkpoint A merged (`cb9d02b2`), the branch fast-forwarded to `main` `15d5d646` and then +`59d2f877` (O2b in). No merge commit was needed. The plan was written first +(`$T/o6-b-plan.md`): every variable, every reader, old → new, and which stay unprefixed and why. + +**The rule.** A variable only a harness reads gets the `E2E_` prefix and is declared in its +package's playwright config. "A harness" means a playwright config, an e2e helper, a fixture, a +fake binary, a stub server, the sharded runner, or a test-mode branch of app code. The config +either sets the variable (webServer `env`) or lists it with where it is read. Two new +`envVars.test.ts` tests hold the rule: +- the `test` audience is `E2E_`-prefixed (`PLAYWRIGHT_BASE_URL` and `QUEUE_LOCK_HELD` are + excepted, with the reason); +- each test-audience variable is named in a playwright config (the queue's own variables in + `queue-lock.mjs`). + +**Renamed**, every reader and writer: + +| old | new | +|---|---| +| `EDITOR_TEST_ROUTES` | `E2E_TEST_ROUTES` | +| the five `AUDIO_CHECK_*_OVERRIDE` | `E2E_AUDIO_CHECK_*` (suffix dropped) | +| `AUDIO_CHECK_DEBUG_PAUSE_MS` | `E2E_AUDIO_CHECK_DEBUG_PAUSE_MS` | +| `FAKE_YTDLP_*` ×7, `FAKE_GALLERY_DL_AUTH_FAIL` | `E2E_FAKE_*` | +| `FIXTURE_MAX_LIFETIME_MS`, `OLLAMA_STUB_MODEL`, `RACK_SHOTS`, `TWO_ORIGIN_REBUILD`, `HOMEPAGE_SUMMARY_FILE` | `E2E_…` | +| `SHARDS`, `IMAGE`, `SKIP_BUILD` (sharded runner) | `E2E_SHARDS`, `E2E_IMAGE`, `E2E_SKIP_BUILD` | +| umtool: `EDITOR_STUB_LOG`, `UMTOOL_CUT_DELAY_MS`, `UMTOOL_EXTRA_KINDS` | `E2E_…` | + +- **The editor's test-server values moved.** `E2E_TEST_ROUTES` and the audio-check knobs moved + from `dev:test` / `start:test` into `editor/playwright.config.ts`'s `E2E_SERVER_ENV` (the + webServer's env). A hand-started `dev:test` could already not be reused under the queue's port + check, so nothing depended on it carrying them. +- **Newly declared:** `E2E_RETRIES` (read dynamically, so the scan had missed it), and two runtime + knobs the scan had also missed because they are read through helpers: + `AUDIO_CHECK_RESUME_DURING_PROBE` and `AUDIO_CHECK_BACKOFF_FACTOR`. The scan now knows + `env(Int|Float|Bool)Override` and `parseIntArg`. +- **umtool's three** are declared in `umtool/playwright.config.ts`, not in `envVars.ts` (umtool is + outside the list until Phase 5). + +**Unchanged, by design:** +- the queue lock and port check: `E2E_QUEUE`, `E2E_PORT_CHECK`, `E2E_QUEUE_TIMEOUT`, + `E2E_PORT_GRACE_MS`, `E2E_QUEUE_LOCK_FILE`, `QUEUE_LOCK_HELD`, and the lock file; +- the port names (`ports.mjs`, the worktree injector, queue-lock's `--ports` lists); +- `PLAYWRIGHT_BASE_URL`, `CI`, and the real overrides a harness points at fixtures; +- the docker `ARCHILYZER_*` set; +- two unit-test knobs, `KEEP_TAG_PREVIEW_FIXTURE` and `CUES_TEST_DIR`, which are read by node unit + tests, not an e2e harness. + +**Ports.** Everything that used to spell a fallback now reads `common/lib/ports.mjs` +(`portFor` / `PORT_BASES`), so `ports.test.ts` finds only the package.json shell defaults and +the `--ports` lists, which cannot import a module: +- the configs: editor, export, hub, 2origin, homepage, umtool; +- the editor's `baseUrl.ts`, the two export specs, and the ollama stub; +- the umtool editor stub and the 2origin spec. + +The umtool stub's port is now `EDITOR_STUB_PORT`'s base whatever `UMTOOL_E2E_PORT` is, which is +what queue-lock's preflight already checked. + +**Folded in (the parent's asks):** +- The Settings Build mode options read "Basic (a label for now)" / "Docker (a label for now)". +- The per-site panel's note shows whatever the mode is (its `serial` prop is gone). +- `/sites`' Build all sentence no longer says "(Docker mode)". +- The public homepage's "Building several sites at once" page and its `/docs` blurb say Build all + uses containers whenever an engine is available, and that the mode is a label; the homepage + changelog has a bullet. +- `E2E_SHARDS` is in CONTRIBUTING.md and SETUP.md. +- One editor `[Unreleased]` bullet. + +| sha | what | +|---|---| +| `f4367aca` | `e2e:` the renames, the declarations, `E2E_SERVER_ENV`, the two guard tests, the harnesses on ports.mjs, ENVIRONMENT.md regenerated | +| `43b2572b` | `editor:` the build-mode options, the per-site panel note, /sites' Build all sentence | +| `b3f2ee86` | `docs:` `E2E_SHARDS`; the homepage's Docker page and blurb | +| _this_ | `plans:` this section; the editor and homepage `[Unreleased]` bullets | + +**Gate B**, on `59d2f877` + these commits, from the worktree root. The logs are `o6-gateB*.log` +and `o6-gB-*.log`. +- tsc clean (66 s, then 31 s). +- **common 2,112/2,112**, **editor unit 85/85**, **`test:scripts` 185 + 1 skip**, **mcp + 269/269**, homepage unit 2/2. +- `docs env --check`, `settings example --check` and `docs files --check`: all exit 0. +- Every touched playwright config loads (`playwright test --list`): editor 665, export 206, hub + 33, homepage 31, umtool 222. +- **Editor subset** (`o6-specsB.txt`: ops-api, worker-unit, audio-check-classifier, + audio-check-scenarios, social-channel, digest, export-search, export-player-platform-cache, + deploy-page, settings, channels-rack-audit), with `E2E_RACK_SHOTS=1` so the audit actually runs + (its shots landed in `editor/test-results/rack-shots/`): **115 passed, 0 failed, 5.3 min**. +- **Export full:** **206 passed, 0 failed, 6.7 min**. +- **`e2e:hub`:** **33 passed, 1.2 min**. +- **Homepage full**, with the primary's `homepage/public` data copied: **31 passed, 0 skipped, 45 s**. +- **umtool full** (`SONG_DIR=~/reports/quartering-uh-song/data`): **175 passed, 2 failed, 45 + skipped, 4.1 min**. That is exactly O5's recorded baseline. The two failures are the known + order-dependent pair, `mix.spec.ts:166` and `:201`; the skips are the song-data capabilities. + **`mix.spec.ts` alone: 12 passed** (22 s). The renamed umtool knobs are exercised in that run: + deliver's Stop test passed (18.3 s, `E2E_UMTOOL_CUT_DELAY_MS`), and so did the + unregistered-kind test in projects.spec (`E2E_UMTOOL_EXTRA_KINDS`). +- **They bite** (`o6-bite.log`, "checkpoint B"): + - a test-only var without the prefix fails 4, including the prefix test; + - removing a name from the editor config's declaration fails the declared-in-a-config test (1); + - an old name read again in a fixture fails "every variable the code reads is declared" (1); + - a drifting port fallback in `baseUrl.ts` fails `ports.test` (1). + +**Found and left (checkpoint B):** +- **`e2e:2origin` was run by the reviewer, not by me** *(corrected after review S1; the first + version of this bullet was wrong)*. Its config's ports and declaration changed, and its setup + reads the renamed `E2E_TWO_ORIGIN_REBUILD`. + - The reviewer ran `E2E_TWO_ORIGIN_REBUILD=1 node scripts/worktree.mjs run -- pnpm --filter + export run e2e:2origin` in this worktree: **3 passed** (34.3 s), the hub rebuilt. + - The primary's `export/public` was byte-identical before and after: all 380 files matched on + path, type, size, mtime and md5 (`$T/o6b-rv-{before,after}.{meta,md5}`). + - Why: compose-hub writes through `writePublicFile`, which removes the path first and so never + writes through a link, and `sw.js` / `hub-summary.json` are `rm`'d, which removes the link + only. I had claimed the opposite. + - The run leaves this worktree's `export/public` with real files where links were; they were + re-linked afterwards (review L2). + - Never run it from the primary checkout: there the entries are real files, and `build:hub` + would overwrite the live hub files with an empty pool (the release-7 accident). +- Old names survive only in `plans/` and released changelog text. + +**Review fixes (checkpoint B)** — the review (`o6b-review.md`) was SHIP AFTER FIXES, with no +blocker. Docs and record only: +- **S1:** the `e2e:2origin` bullet above is corrected with the reviewer's run and evidence. The + "run it from the primary" advice is deleted here and in the report; it would have repeated the + release-7 accident. +- **L1** (the commit just before this record's): WORKTREES.md now says a server reused under + `E2E_PORT_CHECK=0` must be started with the config's `E2E_SERVER_ENV` values. +- **L2:** the reviewer's 2origin run left real files where this worktree's `export/public` links + were: `corpus.json`, `_headers`, `hub-sites.json`, `llms.txt`, `robots.txt` and `sw.js`, and + `hub-summary.json` was removed. All were re-linked from the primary with the implementer-rules + loop, in the worktree only. `find export/public -xtype l` is empty. +- **N1** (pre-existing, recorded only): with `E2E_TWO_ORIGIN_REBUILD=1`, playwright loads + `playwright.2origin.config.ts` twice, so `stageTwoOrigins()` builds the hub twice per run. + ## Rollout Nothing is rolled out tonight. The morning runbook lists what is owed: the :3001 editor restart, diff --git a/scripts/run-sharded-e2e.mjs b/scripts/run-sharded-e2e.mjs @@ -10,7 +10,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.resolve(__dirname, ".."); const EDITOR_DIR = path.join(REPO_ROOT, "editor"); const REPORT_DIR = path.join(EDITOR_DIR, "blob-report"); -const IMAGE = process.env.IMAGE ?? "yt-dlp-transcript-browser-e2e"; +const IMAGE = process.env.E2E_IMAGE ?? "yt-dlp-transcript-browser-e2e"; // Flags this script consumes itself; everything else on the command line is // forwarded verbatim to each shard's `playwright test` (see parseArgs). @@ -35,7 +35,7 @@ function parseIntArg(flag, envVar, fallback, min) { function parseShards() { return parseIntArg( "--shards", - "SHARDS", + "E2E_SHARDS", Math.min(Math.max(2, Math.floor(os.cpus().length / 2)), 8), 1, ); @@ -75,7 +75,7 @@ const SHARDS = parseShards(); const RETRIES = parseRetries(); const PASSTHROUGH = parsePassthrough(); const SKIP_BUILD = - process.argv.includes("--no-build") || process.env.SKIP_BUILD === "1"; + process.argv.includes("--no-build") || process.env.E2E_SKIP_BUILD === "1"; function run(cmd, args, opts = {}) { return new Promise((resolve, reject) => { diff --git a/umtool/bin/cut-from-cache.mjs b/umtool/bin/cut-from-cache.mjs @@ -45,7 +45,7 @@ const reencode = argv.includes("--reencode") // middle of -- and "Stop abandons the REST, and the next run resumes rather // than re-cutting" is the whole argument for one step per clip. The e2e // fixture's server sets this; nothing else ever should. -const delay = Number(process.env.UMTOOL_CUT_DELAY_MS ?? 0); +const delay = Number(process.env.E2E_UMTOOL_CUT_DELAY_MS ?? 0); if (delay > 0) await new Promise((r) => setTimeout(r, delay)); const res = await cutClipFromCache(r.project, clipId, { reencode }); diff --git a/umtool/docs/projects.md b/umtool/docs/projects.md @@ -79,7 +79,7 @@ fail when somebody special-cases a kind in a page — which no page test can see `components/projects/`** — a grep for lines carrying both a kind id and the word `kind`; - **`RESERVED_BROWSE` equals the real static pages**; -- **a kind injected through `UMTOOL_EXTRA_KINDS` reaches the index, the chips and +- **a kind injected through `E2E_UMTOOL_EXTRA_KINDS` reaches the index, the chips and the CLI with no code edit at all**. ## Where the code is diff --git a/umtool/e2e/deliver.spec.ts b/umtool/e2e/deliver.spec.ts @@ -237,7 +237,7 @@ test("rebuild runs build.py once per content variant present", async ({ page }) // mtimes of the files the first job wrote, which must not have moved. // // Deterministic rather than racy: the fixture's server sets -// UMTOOL_CUT_DELAY_MS=2000 (playwright.config.ts), the job is six clips long, +// E2E_UMTOOL_CUT_DELAY_MS=2000 (playwright.config.ts), the job is six clips long, // and the click waits for the first CUT-OK. Three clips of work -- six // seconds -- therefore cannot have happened by the time Stop lands. // --------------------------------------------------------------------------- diff --git a/umtool/e2e/fixtures/editor-stub.mjs b/umtool/e2e/fixtures/editor-stub.mjs @@ -19,12 +19,13 @@ import http from "node:http"; import { spawnSync } from "node:child_process"; import { mkdirSync, writeFileSync, statSync } from "node:fs"; import path from "node:path"; +import { portFor } from "yt-dlp-transcript-common/lib/ports.mjs"; -const PORT = Number(process.env.EDITOR_STUB_PORT ?? 3052); +const PORT = portFor("EDITOR_STUB_PORT"); const CHANNELS = process.env.CHANNELS_DIR ?? ""; const TOKEN = process.env.WORKER_TOKEN ?? ""; // Where the spec reads what was asked for. -const LOG = process.env.EDITOR_STUB_LOG ?? path.join(process.cwd(), "editor-stub.requests.json"); +const LOG = process.env.E2E_EDITOR_STUB_LOG ?? path.join(process.cwd(), "editor-stub.requests.json"); const requests = []; const jobs = new Map(); diff --git a/umtool/e2e/fixtures/make-fixture.mjs b/umtool/e2e/fixtures/make-fixture.mjs @@ -1224,7 +1224,7 @@ ff([ // "stopped after k of n" a measurement rather than a race. // // Six confirmed clips, none of them cut, all inside the same cached window. -// With UMTOOL_CUT_DELAY_MS set (playwright.config.ts) each step takes about +// With E2E_UMTOOL_CUT_DELAY_MS set (playwright.config.ts) each step takes about // two seconds, so the spec can see the first CUT-OK, press Stop, and know that // at least three clips could not possibly have been reached. const STOP = writeProject( diff --git a/umtool/e2e/projects.spec.ts b/umtool/e2e/projects.spec.ts @@ -280,7 +280,7 @@ test("the reserved names are exactly the static pages under app/browse", () => { }); test("a kind the registry has never seen appears everywhere, with no code edit", () => { - // The extensibility claim, tested rather than asserted. UMTOOL_EXTRA_KINDS is + // The extensibility claim, tested rather than asserted. E2E_UMTOOL_EXTRA_KINDS is // read only by kinds.mjs; if a new kind needs an edit anywhere else, this // fails. const extra = JSON.stringify([ @@ -295,7 +295,7 @@ test("a kind the registry has never seen appears everywhere, with no code edit", "meta:m.KIND_META().map(k=>k.badge)," + "detected:m.detectKind(new Set(['FIXTURE.marker']))})))", ], - { cwd: UMTOOL, encoding: "utf8", env: { ...process.env, UMTOOL_EXTRA_KINDS: extra } }, + { cwd: UMTOOL, encoding: "utf8", env: { ...process.env, E2E_UMTOOL_EXTRA_KINDS: extra } }, ); const j = JSON.parse(out.trim()); diff --git a/umtool/lib/projects/kinds.mjs b/umtool/lib/projects/kinds.mjs @@ -137,9 +137,9 @@ export const PROJECT_KINDS = [ // asserts the index, the chips, the CLI and the project page all pick it up // with no code edit anywhere. If adding a kind needs an edit outside this file, // that spec fails loudly. -if (process.env.UMTOOL_EXTRA_KINDS) { +if (process.env.E2E_UMTOOL_EXTRA_KINDS) { try { - for (const k of JSON.parse(process.env.UMTOOL_EXTRA_KINDS)) { + for (const k of JSON.parse(process.env.E2E_UMTOOL_EXTRA_KINDS)) { PROJECT_KINDS.push({ stages: [{ id: "draft", label: "draft" }], decisionKinds: [], diff --git a/umtool/playwright.config.ts b/umtool/playwright.config.ts @@ -1,14 +1,26 @@ import { defineConfig, devices } from "@playwright/test"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { portFor } from "yt-dlp-transcript-common/lib/ports.mjs"; -const PORT = Number(process.env.UMTOOL_E2E_PORT ?? 3051); +// Test-only environment, declared here (one-core Phase 4 slice 3); every one +// carries the E2E_ prefix: +// E2E_UMTOOL_CUT_DELAY_MS set below on the app server: two seconds of pacing +// per cut (bin/cut-from-cache.mjs), so the deliver +// spec can press Stop mid-job +// E2E_EDITOR_STUB_LOG set below on the editor stub: where it records what +// 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) +// 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"); // 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 // is in common/lib/ports.mjs (so scripts/worktree.mjs gives a worktree its own) // and in package.json's --ports spec (so the preflight sees it). -const STUB_PORT = Number(process.env.EDITOR_STUB_PORT ?? PORT + 1); +const STUB_PORT = portFor("EDITOR_STUB_PORT"); // The package is "type": "module", so there is no __dirname here. const FIXTURE = path.join(path.dirname(fileURLToPath(import.meta.url)), ".e2e-song"); @@ -49,7 +61,7 @@ export default defineConfig({ EDITOR_STUB_PORT: String(STUB_PORT), CHANNELS_DIR: `${FIXTURE}/channels`, WORKER_TOKEN: "umtool-e2e-token", - EDITOR_STUB_LOG: `${FIXTURE}/editor-stub.requests.json`, + E2E_EDITOR_STUB_LOG: `${FIXTURE}/editor-stub.requests.json`, }, }, { @@ -74,7 +86,7 @@ export default defineConfig({ // Two seconds of pacing per CUT, so the deliver spec can press Stop in // 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. - `UMTOOL_CUT_DELAY_MS=2000 ` + + `E2E_UMTOOL_CUT_DELAY_MS=2000 ` + `NEXT_DIST_DIR=.next-e2e pnpm exec next dev --port ${PORT}`, port: PORT, reuseExistingServer: false,