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"; // 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) // 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 // 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 = 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"); // The suite NEVER runs against the real song dir. The tool writes to the same // files the CLI scripts do, and those hold thousands of real human verdicts -- // a spec that judged a clip would be indistinguishable from a person doing it. // make-fixture.mjs builds an empty-state copy and symlinks the heavy audio. // // SONG_REPORTS_DIR matters just as much now that the mix bench RENDERS: its // default output root is the directory holding every finished deliverable, and // a spec that rendered there would overwrite work. Pointing it at the fixture // also confines MEDIA_ROOTS, so a render cannot resolve a path outside it. export default defineConfig({ testDir: "./e2e", testMatch: /.*\.spec\.ts/, fullyParallel: false, workers: 1, // `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", }, projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], webServer: [ { // THE EDITOR, STUBBED. // // A clip window is now fetched by ASKING the editor, and the suite must // not run a real one: booting it arms the runners against whatever // corpus they find, and the endpoint's whole job is to spend somebody's // bandwidth. The stub answers the contract and writes a real mp4 into // the fixture's own corpus. command: `node ${path.join(path.dirname(fileURLToPath(import.meta.url)), "e2e", "fixtures", "editor-stub.mjs")}`, url: `http://127.0.0.1:${STUB_PORT}/health`, timeout: 30_000, reuseExistingServer: false, env: { EDITOR_STUB_PORT: String(STUB_PORT), CHANNELS_DIR: `${FIXTURE}/channels`, WORKER_TOKEN: "umtool-e2e-token", E2E_EDITOR_STUB_LOG: `${FIXTURE}/editor-stub.requests.json`, }, }, { // 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 ` + // The project walk reads REPORTS_ROOT, which defaults to // dirname(SONG_REPORTS_DIR) -- so the fixture is confined with no new env // var. CHANNELS_DIR has to be said explicitly: it is where a report // video's cue files live, and its default is the real 3 GB corpus. `CHANNELS_DIR=${FIXTURE}/channels ` + // The sites (/sites, article notes): the fixture's own, never the real // transcripts/sites -- the one corpus file umtool writes is a report's // notes.json, and the suite writes them. `SITES_DIR=${FIXTURE}/sites ` + // The cache (the project index, posters, analyses) is no longer under // SONG_DIR (release 17): its default is the user's ~/.cache, which a // suite must never write. The fixture's own, rebuilt with it every run. `UMTOOL_CACHE_DIR=${FIXTURE}/cache ` + // And never the media root: Playwright hands the app this shell's whole // environment, so a shell that exports UMTOOL_MEDIA_DIR would put every // fixture build's out/ on the real media drive. Empty is unset // (lib/paths.mjs reads it with ||); storage.spec.ts gives its CLI its own. `UMTOOL_MEDIA_DIR= ` + // Stub binaries, so a build spec is offline and deterministic. The // pipeline already reads both as overrides; the fixture writes them. `YTDLP_BIN=${FIXTURE}/bin/yt-dlp QRENCODE_BIN=${FIXTURE}/bin/qrencode ` + // The on-screen deck's renderer, stubbed the same way: it writes the // frame sequence the build checks for, so a deck build is offline and // takes a second (onscreen.spec.ts). Its review stills use the system // chromium (CHROME, default /usr/bin/chromium), which is not stubbed. `HYPERFRAMES_BIN=${FIXTURE}/bin/hyperframes ` + // Where a clip window is fetched FROM, and the shared secret it is asked // with. Set here rather than in a step's env: jobView() echoes a step's // env back to the browser, and this is a token. `ARCHILYZER_EDITOR_URL=http://127.0.0.1:${STUB_PORT} WORKER_TOKEN=umtool-e2e-token ` + // 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. `E2E_UMTOOL_CUT_DELAY_MS=2000 ` + APP_SERVER, port: PORT, reuseExistingServer: false, timeout: 120_000, }, ], });