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. 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"); // SSR branding comes from currentSite() reading SITES_DIR//site.json. // Point the dev server at the fixture site so branding assertions are stable. const TEST_SITES_DIR = path.resolve(process.cwd(), "e2e", "fixtures", "sites"); // Server-side reads (layout settings, summaries totalCount) hit the real // `export/public/` on disk — that's tolerated for these tests, since we only // care about modal/cue behavior. Browser fetches are all route-mocked per // spec via `installRoutes`. buildFixtureSettings(TEST_SETTINGS_FILE); // transcript-downloads.spec flips `transcriptDownloads: false` into the served // fixture site for one test and restores it afterwards. A run killed inside that // test leaves the key behind, and every modal spec before it would then fail on // the missing controls — so strip it here, before any spec runs. // header.spec rewrites the fixture's title and social links for a test and // carries the original file in `_e2ePristine` (parseSite drops the key): a run // killed inside it is put back here the same way. const FIXTURE_SITE = path.join(TEST_SITES_DIR, "testsite", "site.json"); { const raw = fs.readFileSync(FIXTURE_SITE, "utf8"); const site = JSON.parse(raw) as Record; if (typeof site._e2ePristine === "string") { fs.writeFileSync(FIXTURE_SITE, site._e2ePristine); } else if ("transcriptDownloads" in site) { delete site.transcriptDownloads; fs.writeFileSync(FIXTURE_SITE, `${JSON.stringify(site, null, 2)}\n`); } } // `public/sw.js` is a generated, gitignored artifact: `compose:site` copies it // from `export/service-worker/site-sw.js` only for a PWA-shipping site (see // common/bin/compose-site.ts). The e2e harness runs `next dev` without a compose // step, so pwa.spec's `GET /sw.js` would depend on a stale build artifact being // left on disk. Stage it here (mirroring compose) so the service-worker test is // deterministic on a clean checkout. The icons it caches need no staging: the // `app/icons/[file]` and `app/favicon.ico` route handlers render them. // // Unlink before copying. In a git worktree `public/sw.js` is a symlink into // the primary checkout (the gitignored entries of `public/` are linked from // there), and copyFileSync writes THROUGH a link: a site-mode run in a worktree // replaced the primary's composed hub worker with the site one. Removing the // path first removes only the link, so the copy lands in this checkout. const SW_SRC = path.resolve(process.cwd(), "service-worker", "site-sw.js"); const SW_DEST = path.resolve(process.cwd(), "public", "sw.js"); if (fs.existsSync(SW_SRC)) { fs.rmSync(SW_DEST, { force: true }); fs.copyFileSync(SW_SRC, SW_DEST); } // E2E_MODE (start | dev), the editor's and umtool's switch: those suites // default to `start`, a production build the stamp vouches for // (scripts/e2e-stamp.mjs). This suite always runs under `next dev`: the export is a // STATIC export (output: "export"), and several of its spec files rewrite the // fixture site or settings mid-run and assert the page that follows — a build // would have baked the old ones in. // `E2E_MODE=start` is accepted and says so; anything else is refused. { const mode = (process.env.E2E_MODE ?? "").trim().toLowerCase(); if (mode !== "" && mode !== "start" && mode !== "dev") { throw new Error(`E2E_MODE=${process.env.E2E_MODE} is not a mode: use start or dev`); } if (mode === "start" && !process.env.E2E_BUILD_CHECKED) { process.stderr.write( "export e2e: E2E_MODE=start has no build to run here (a static export bakes its fixtures in at build time) — running next dev\n", ); process.env.E2E_BUILD_CHECKED = "1"; } } export default defineConfig({ testDir: "./e2e", timeout: 30_000, retries: process.env.CI ? 2 : 0, // `json` beside `list`: the run's timing record, which // scripts/e2e-timings.mjs totals per spec against the branch's last run. reporter: process.env.CI ? "github" : [["list"], ["json", { outputFile: "test-results/timings.json" }]], outputDir: "test-results/", fullyParallel: false, workers: 1, webServer: { command: `pnpm dev --port ${PORT}`, url: baseURL, timeout: 120_000, reuseExistingServer: !process.env.CI, env: { SETTINGS_FILE: TEST_SETTINGS_FILE, SITES_DIR: TEST_SITES_DIR, SITE_ID: "testsite", }, }, use: { baseURL, trace: "on-first-retry", screenshot: "only-on-failure", video: "retain-on-failure", }, projects: [ { name: "chromium", use: { ...devices["Desktop Chrome"], // The modal lays out its cue list below an aspect-video iframe sized // to ~min(92vw,960px). At 1280×720 the cue area collapses to ~50px, // so most assertions can't see the list. 1440×1200 leaves ~500px of // visible cue list. viewport: { width: 1440, height: 1200 }, }, }, ], });