import { spawnSync } from "node:child_process"; import path from "node:path"; import { defineConfig, devices } from "@playwright/test"; import { portFor } from "yt-dlp-transcript-common/lib/ports.mjs"; // 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_BACKOFF_BASE_MS the first rate-limit cooldown (60 s in // production; 20 s here), so pacing.spec watches one // lapse (common/jobs/platformBackoff.ts). The cap and // the hold arithmetic keep the real constants. Not // shorter: rumble-sweep, metadata-scan-softblock and // fetch-window assert a cooldown is still in force a // page load after it was recorded // E2E_CLIP_WINDOW_GAP_MS the pause between two clip-window fetches in a // batch (30–45 s in production; 2 s here), so // fetch-window.spec sees the one it owes // (common/controller/fetchWindows.ts) // E2E_MODE `start` (the default): the editor runs under // `next start` from a build the stamp vouches for // (scripts/e2e-stamp.mjs — rebuilt through the // heavy slot when the tree moved). `dev`: under // `next dev`, for iterating on one spec with no // rebuild per edit // E2E_NEXT_DIST_DIR set below on a start-mode server: the build // directory (`.next/e2e`) editor/next.config.ts // reads, so a test build never replaces `.next` // E2E_BUILD_CHECKED set below once the stamp was checked, so the // config's second load (a worker's) does not check // again // E2E_RACK_SHOTS=1 run the /channels rack screenshot audit // E2E_FAKE_YTDLP_AUDIO_CHECK_MODE, E2E_FAKE_YTDLP_CHUNK_DELAY_MS, // E2E_FAKE_YTDLP_CORRUPT_AFTER_CHUNK, E2E_FAKE_YTDLP_CORRUPT_RUNS, // 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_FAKE_WRANGLER_AUTH_FAIL=1 fake-wrangler.mjs fails as Cloudflare // refusing the API token ("Authentication error // [code: 10000]"), for the deploy stage's // "[deploy] REFUSED by Cloudflare" sentence — the // fallback of its mode sidecar, which a spec // writes (`/.fake-wrangler- // mode.json` `{"authFail": true}`) // E2E_LIVE_CHECK=skip the deploy stage's live check reads nothing and // records "skipped" (common/publish/liveCheck.ts): // the fake wrangler deploys nothing to read. Set // for the test server below // 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) // // WRANGLER_BIN is not prefixed either: it is the deploy stage's own override // (common/lib/pagesDeploy.ts wranglerBin), pointed below at // e2e/fixtures/bin/fake-wrangler.mjs so no spec can reach Cloudflare — the fake // writes its argv to `.fake-wrangler.json` beside the bundle it was handed. // Three more product variables the publish stages read are set for the test // server (release 18 S4): // ARCHILYZER_BRANCH=main the branch a build stamps (stamps.ts: the env // wins over git). A worktree's branch is never // main, and production refuses a build of any // other — without it every production deploy in // the suite would be refused // CLOUDFLARE_API_TOKEN a dummy: the deploy stage's credential // preflight asks for a token or a wrangler login // on disk; the fake wrangler never sends it, and // the suite must not depend on the host's login // EXPORT_NEXT_BIN e2e/fixtures/bin/fake-next.mjs: a site's and // the hub's build run compose for real, then this // in place of `next build` (it copies the composed // public dir to export/out) // // 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", E2E_BACKOFF_BASE_MS: "20000", E2E_CLIP_WINDOW_GAP_MS: "2000", E2E_LIVE_CHECK: "skip", WRANGLER_BIN: path.resolve(process.cwd(), "e2e", "fixtures", "bin", "fake-wrangler.mjs"), ARCHILYZER_BRANCH: "main", CLOUDFLARE_API_TOKEN: "e2e-fake-token-never-sent", EXPORT_NEXT_BIN: path.resolve(process.cwd(), "e2e", "fixtures", "bin", "fake-next.mjs"), }; 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 // the worktree wrapper. See e2e/baseUrl.ts. process.env.PLAYWRIGHT_BASE_URL = baseURL; // START MODE BY DEFAULT (plans/e2e-speed.md, S1): the same tests took 0.32× the // time under `next start` — a polled route recompiles under `next dev`. Before // any server starts, scripts/e2e-stamp.mjs compares the build's stamp with the // tree and rebuilds when they differ (it says which, and why), so a start-mode // run cannot serve a stale build. CI — the sharded image, which runs its own // `next build` into `.next` — keeps that build and skips the stamp. const E2E_MODE = (() => { const raw = (process.env.E2E_MODE ?? "").trim().toLowerCase(); if (raw === "" || raw === "start") return "start"; if (raw === "dev") return "dev"; throw new Error(`E2E_MODE=${process.env.E2E_MODE} is not a mode: use start (the default) or dev`); })(); const STAMPED_BUILD = E2E_MODE === "start" && !process.env.CI; // The same directory as scripts/e2e-stamp.mjs PACKAGES.editor.distDir. const E2E_DIST_DIR = ".next/e2e"; if (STAMPED_BUILD && !process.env.E2E_BUILD_CHECKED) { const ensured = spawnSync( process.execPath, [path.resolve(process.cwd(), "..", "scripts", "e2e-stamp.mjs"), "ensure", "editor"], { stdio: "inherit" }, ); if (ensured.status !== 0) { throw new Error( "e2e: the editor's start-mode build failed (scripts/e2e-stamp.mjs ensure editor). " + "Fix the build, or run this spec under E2E_MODE=dev.", ); } process.env.E2E_BUILD_CHECKED = "1"; } const webServerCommand = E2E_MODE === "start" ? "pnpm start:test" : "pnpm dev:test"; const webServerEnv: Record = STAMPED_BUILD ? { ...E2E_SERVER_ENV, E2E_NEXT_DIST_DIR: E2E_DIST_DIR } : E2E_SERVER_ENV; // The digest lane's local engine is reached over HTTP, not spawned, so it gets a // webServer entry instead of a fake binary in e2e/fixtures/bin/. Its port is // 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 = portFor("OLLAMA_STUB_PORT"); process.env.OLLAMA_STUB_PORT = String(OLLAMA_STUB_PORT); 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 // driven from a single fixture file during E2E. const exportSettingsFile = path.resolve( process.cwd(), "test-settings.json", ); // Footer/branding on the export server now come from a per-site config // (currentSite()), so point it at a fixture site for deterministic E2E. const exportSitesDir = path.resolve( process.cwd(), "e2e", "fixtures", "sites", ); export default defineConfig({ testDir: "./e2e", timeout: 30_000, // Reap fixture binaries that outlived their run — before this one starts (a // SIGKILLed run never gets to clean up after itself) and again after it ends. // Both log anything they find, because by construction they should find // nothing. See e2e/fixtureProcs.ts. globalSetup: "./e2e/globalSetup.ts", globalTeardown: "./e2e/globalTeardown.ts", // The sharded runner sets CI=true for `reuseExistingServer: !CI` below, not // for retries, and passes an explicit --retries=0 so its failures stay // comparable to a serial run's. See scripts/run-sharded-e2e.mjs. retries: process.env.CI ? 2 : 0, // `json` beside `list`: the run's timing record, which // scripts/e2e-timings.mjs totals per spec against the branch's last run. reporter: process.env.CI ? "github" : [["list"], ["json", { outputFile: "test-results/timings.json" }]], outputDir: "test-results/", // Serial, one worker — deliberate, and not a performance oversight. Three // pieces of shared mutable state are global to the whole run, so two workers // corrupt each other's fixtures rather than merely running slower: // // 1. One hardcoded data root (e2e/helpers.ts:17) that 303 resetData() calls // across 83 of the 87 spec files destroy and recreate — mostly from // *inside* test bodies (e2e/helpers.ts:33-48), not just in beforeEach. // 2. One shared test-settings.json (e2e/helpers.ts:18) that the export // webServer below also reads as SETTINGS_FILE (see exportSettingsFile) — // so resetData's rm+cp deletes a file a second live server is reading. // 3. Four globalThis singletons inside the single Next server — job // registry, worker pool, scheduler, auto-runner — cleared on every // resetData/writeSettings via // app/api/test/invalidate-cache/route.ts:24,32,38,44. // // (3) is the blocker that per-worker TRANSCRIPTS_DIR/SETTINGS_FILE cannot // fix: the singletons live in the server process, so isolation needs a // *server per worker*, not a directory per worker. That is why parallelism // here is one container per shard (scripts/run-sharded-e2e.mjs, which gives // each shard its own servers) rather than workers > 1 in this config. fullyParallel: false, workers: 1, webServer: [ { command: webServerCommand, url: baseURL, timeout: 120_000, reuseExistingServer: !process.env.CI, env: webServerEnv, }, { // The export server stays `next dev` in both modes. The export is a // STATIC export (output: "export"): a build bakes test-settings.json and // the fixture site in at build time, so export-search.spec's settings // rewrite would be asserted against a page that cannot see it; and the // two spec files that use this server ran 42 s in all in release 19's // start-mode run — less than one export build costs. command: `pnpm --filter export run dev --port ${EXPORT_PORT}`, url: exportBaseURL, timeout: 120_000, reuseExistingServer: !process.env.CI, env: { SETTINGS_FILE: exportSettingsFile, SITES_DIR: exportSitesDir, SITE_ID: "testsite", }, }, { command: `node ${path.resolve(process.cwd(), "e2e", "fixtures", "ollama-stub.mjs")}`, // /api/tags is the same endpoint digestApps.ts probes, so playwright's // readiness check and the app's own reachability check agree. url: `http://127.0.0.1:${OLLAMA_STUB_PORT}/api/tags`, timeout: 30_000, reuseExistingServer: !process.env.CI, env: { OLLAMA_STUB_PORT: String(OLLAMA_STUB_PORT) }, }, ], use: { baseURL, trace: "on-first-retry", screenshot: "only-on-failure", video: "retain-on-failure", }, projects: [ { name: "chromium", use: { ...devices["Desktop Chrome"] }, }, ], });