// Playwright config for the post-PR-17 `Preview smoke` and // `Screenshot diff` workflows. ESM per the repo's // `"type": "module"` setting in package.json. Companion docs: // `.convoys/adopt-playwright-smoke.md` (Decisions 1-6), // AGENTS.md § 7 (Vercel preview bypass conventions). import { defineConfig } from '@playwright/test'; const BASE_URL = process.env.BASE_URL ?? 'http://localhost:3000'; const BYPASS_SECRET = process.env.VERCEL_AUTOMATION_BYPASS_SECRET; // `CI === 'true'` is the canonical GitHub Actions signal (set by // the runner). Playwright config has no Next.js context, so // `NODE_ENV` is not reliable here. Mirrors the rate-limit.js // fail-closed pattern documented in AGENTS.md Gotcha #12. const IS_CI = process.env.CI === 'true'; if (IS_CI && !BYPASS_SECRET) { // Fail loud in CI per Decision 2. The workflow's `env:` block // (preview-smoke.yml line 101, visual-diff.yml line 85) maps // `secrets.VERCEL_AUTOMATION_BYPASS_SECRET` into the process // env; if it's empty here, the secret is unseeded or the // workflow YAML drift broke the mapping. throw new Error( 'VERCEL_AUTOMATION_BYPASS_SECRET is required in CI to reach ' + 'Vercel-Protection-protected preview deployments. ' + 'Reseed via: gh secret set VERCEL_AUTOMATION_BYPASS_SECRET --body "". ' + 'See AGENTS.md § 7 for the full plumbing context.' ); } if (!BYPASS_SECRET && !IS_CI) { // Dev fallback per Decision 2 — warn once at config load, // proceed without the header. Local runs target localhost // (no preview protection) or a non-protected URL. // (No eslint-disable for `no-console` here: the active config // does not enable `no-console` for repo-root config files; // adding the directive here trips `eslint-comments/no-unused-disable`.) console.warn( '[playwright.config] VERCEL_AUTOMATION_BYPASS_SECRET unset — ' + 'running without the Vercel bypass header. Targets a non-protected ' + 'URL (e.g. http://localhost:3000). Hitting a protected preview without ' + 'this header will return Vercel\'s SSO challenge page.' ); } export default defineConfig({ // Both `tests/smoke/` and `tests/visual/` live under `tests/`. // Project-level `testMatch` (below) partitions them so the // two workflows (`--project=smoke` and `--project=visual`) // each see only the specs they should run. testDir: './tests', // Smoke + visual specs are independent; parallelism within a // single spec adds no value here and would complicate the // per-test screenshot baseline lifecycle. fullyParallel: false, workers: IS_CI ? 1 : undefined, // One retry in CI handles transient Vercel preview flakes // (cold-start, DNS propagation). Local: zero retries — fail // fast so the dev sees the issue immediately. retries: IS_CI ? 1 : 0, // 30s per test is plenty for the 3 smoke checks + 1 visual // screenshot. The convoy file's success metric is < 5min // total workflow runtime; per-test 30s is well inside that. timeout: 30_000, expect: { timeout: 10_000 }, // List reporter in dev for human readability; add HTML in CI // so the `Upload Playwright report on failure` step // (preview-smoke.yml line 104) has a populated `playwright-report/` // to upload. `open: 'never'` keeps the HTML from auto-launching // a browser tab in headless CI. reporter: IS_CI ? [['list'], ['html', { open: 'never' }]] : 'list', // Visual baselines live at `tests/visual/__screenshots__/{ext}`. // Workflow `visual-diff.yml` uploads this exact path as the artifact — // keep them aligned. {arg} is the snapshot name from // `toHaveScreenshot('home.png')` without the extension; {ext} is // the extension with the leading dot. // // Cross-platform CAUTION (still live): this template drops Playwright's // default `--` suffix. A Mac dev running // `npm run test:visual:update` silently overwrites the Linux-CI // baseline with a Mac-rendered PNG that the CT 111 runner will reject // on the next diff. The seeded Linux baseline (PR #58 `83a358b`, // 2026-06-02) is the canonical artifact. Re-seed via the Linux Docker // recipe in `tests/visual/homepage.spec.ts`'s module docblock OR // dispatch the (queued) `seed-visual-baselines` workflow on CT 111. // Never via `npm run test:visual:update` on a Mac. snapshotPathTemplate: 'tests/visual/__screenshots__/{arg}{ext}', use: { baseURL: BASE_URL, // Headers apply to BOTH browser `page.goto(...)` calls AND the // test-level `request` fixture's APIRequestContext (verified // against Playwright docs: `testOptions.extraHTTPHeaders` is // shared between browser context and APIRequestContext // construction). This is why `tests/smoke/app.smoke.spec.ts`'s // third test (`request.get('/api/health')`) reaches the // protected preview without re-injecting the header in the // spec body. If a future hotfix shows the header NOT // propagating to APIRequestContext, see Risk R1 in the // convoy file's Architecture section. extraHTTPHeaders: BYPASS_SECRET ? { 'x-vercel-protection-bypass': BYPASS_SECRET } : undefined, // Trace OFF this convoy. Enabling it would land the bypass // header in the HAR payload (Risk R6); a future polish convoy // owns the trace-on + HAR-sanitization decision. trace: 'off', screenshot: 'off', video: 'off', }, projects: [ { // `Preview smoke` workflow invokes `--project=smoke` (per // preview-smoke.yml line 98). The testMatch keeps the // visual specs out of this project. name: 'smoke', testMatch: 'smoke/**/*.spec.@(ts|js)', }, { // `Screenshot diff` workflow invokes `--project=visual` // (per visual-diff.yml line 82). The testMatch keeps the // smoke specs out of this project (so a `--project=visual` // run doesn't redundantly execute the smoke tests). name: 'visual', testMatch: 'visual/**/*.spec.@(ts|js)', }, ], });