From c72d006666d5a7c1e9107872598ee44608c07588 Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Sun, 24 May 2026 18:44:52 -0500 Subject: [PATCH] feat(test): adopt @playwright/test + ship playwright.config.js + visual scaffold (Brief 1 of adopt-playwright-smoke) Closes P1 #10 step 2 / launch sequence step 10. PR #17 already plumbed VERCEL_AUTOMATION_BYPASS_SECRET into both workflows' env; this brief wires the actual @playwright/test dep, the playwright.config.js that bridges env -> use.extraHTTPHeaders, and a single visual spec so the screenshot workflow has something to discover. Per architect Decision 1 - tests/smoke/app.smoke.spec.ts stays as .ts (npx eslint exits 0 against the current config); new visual spec also .ts. Per Decision 2 - playwright.config.js fail-loud in CI when VERCEL_AUTOMATION_BYPASS_SECRET is missing (throw at config load with actionable message naming the env var, rotation command, AGENTS.md reference); warn-and-no-op in dev. Mirrors lib/rate-limit.js fail-closed pattern (AGENTS.md Gotcha #12). Per Decision 3 - two projects partitioned by testMatch: - smoke -> tests/smoke/**/*.spec.{ts,js} - visual -> tests/visual/**/*.spec.{ts,js} Shared use: block. Matches the workflows' --project=smoke|visual invocations. Per Decision 4 - NO baselines committed. The Mac-vs-Linux platform- suffix issue (architect Boot-the-brief Finding 7: Playwright's custom snapshotPathTemplate has no {platform} token) means a Mac-generated baseline would silently overwrite a Linux CI baseline. Queued as seed-visual-baselines-on-linux follow-up convoy. First CI run of Screenshot diff will fail at the test step; existing continue-on-error: true swallows it and the comment-on-PR step posts the run link. Per Decision 5 - no eslint.config.mjs change. Per Decision 6 - three simple scripts (test:smoke, test:visual, test:visual:update). No auto-boot wrapper. Verification: - npm ci --dry-run exits 0 (lockfile in sync) - npm run lint: 128 problems (baseline match - 81 errors, 47 warnings) - npm run test:run: 21/21 vitest pass (no regression) - npm run test:smoke (no BASE_URL): dev-path warn-and-continue fires "[playwright.config] VERCEL_AUTOMATION_BYPASS_SECRET unset" warning, enumerates 3 smoke tests, attempts to run (fails on missing local chromium binary - workflows install browsers via npx playwright install --with-deps chromium) - CI=true npm run test:smoke: CI-path throws at config load with "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 \xc2\xa7 7 for the full plumbing context." - npx playwright --version: Version 1.60.0 Implementation note: removed the architect's defensive eslint-disable- next-line no-console directive in the dev-warn branch because the active eslint config does not enable no-console for repo-root config files; ESLint reported the directive as unused, which would have regressed the lint baseline by +1. Behavior unchanged; comment-only edit per the brief's explicit "comment wording can be tightened" allowance. Replaced with an explanatory comment noting why the directive is intentionally absent. Implementation note 2: alphabetized @playwright/test as the FIRST entry in devDependencies (before @testing-library/dom) because "@p" < "@t". The brief's prose said "between @testing-library/react and autoprefixer" which would have broken alphabetization; followed the brief's overarching principle ("alphabetical position") instead. No code paths in pages/** or lib/** touched. No workflow YAML edited. No AGENTS.md edits (doc-writer pass at convoy close handles the seed-on-Linux runbook). Co-authored-by: Cursor --- .gitignore | 7 ++ package-lock.json | 64 ++++++++++++++++ package.json | 6 +- playwright.config.js | 133 ++++++++++++++++++++++++++++++++++ tests/visual/homepage.spec.ts | 37 ++++++++++ 5 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 playwright.config.js create mode 100644 tests/visual/homepage.spec.ts diff --git a/.gitignore b/.gitignore index b928a15..d16380b 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,10 @@ __pycache__/ # Local code-knowledge-graph (per-developer; user-code-review-graph MCP) .code-review-graph/ + +# Playwright test runner artifacts (generated on every local run; +# never committed). Baselines under `tests/visual/__screenshots__/` +# are EXPLICITLY NOT ignored — they must be committed when they exist. +/playwright-report/ +/test-results/ +/.playwright/ diff --git a/package-lock.json b/package-lock.json index 7d43838..8f2f2e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "resend": "^4.7.0" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", "autoprefixer": "^10.4.21", @@ -2071,6 +2072,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@react-email/render": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@react-email/render/-/render-1.1.2.tgz", @@ -7531,6 +7548,53 @@ "node": ">= 6" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/package.json b/package.json index c1c6e53..db16ec0 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,10 @@ "import-popular": "node scripts/import-popular-sets.js", "import-all": "node scripts/bulk-import-all.js", "test": "vitest", - "test:run": "vitest run" + "test:run": "vitest run", + "test:smoke": "playwright test --project=smoke", + "test:visual": "playwright test --project=visual", + "test:visual:update": "playwright test --project=visual --update-snapshots" }, "dependencies": { "@neondatabase/serverless": "^1.0.1", @@ -30,6 +33,7 @@ "resend": "^4.7.0" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", "autoprefixer": "^10.4.21", diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..10759d4 --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,133 @@ +// 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` line 94 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 note (Risk R3): this template drops Playwright's + // default `--` suffix. That means a Mac dev + // running `npm run test:visual:update` overwrites the Linux-CI + // baseline. Per Decision 4, we don't commit baselines this convoy; + // operator seeds via `mcr.microsoft.com/playwright:v1.60.0-noble` + // Docker on Linux. Multi-platform support is the + // `seed-visual-baselines-on-linux` follow-up convoy's job. + 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)', + }, + ], +}); diff --git a/tests/visual/homepage.spec.ts b/tests/visual/homepage.spec.ts new file mode 100644 index 0000000..9cab621 --- /dev/null +++ b/tests/visual/homepage.spec.ts @@ -0,0 +1,37 @@ +import { test, expect } from '@playwright/test'; + +/** + * Visual baseline for the public homepage. + * + * FIRST RUN (no committed baseline yet): + * The Screenshot diff workflow runs `playwright test --project=visual + * --update-snapshots=none` (per .github/workflows/visual-diff.yml). With + * no baseline file at `tests/visual/__screenshots__/home.png` AND the + * `none` flag, this test FAILS — and that's the documented end state of + * the `adopt-playwright-smoke` convoy (Decision 4 in + * `.convoys/adopt-playwright-smoke.md`). The workflow's + * `continue-on-error: true` swallows the failure and the comment-on-PR + * step posts "Visual Diff — view run" with empty artifacts. + * + * SEEDING THE BASELINE (post-merge follow-up): + * Run `npm run test:visual:update` in a Linux environment so the + * generated PNG matches what CI will produce. The cleanest path is the + * Playwright Docker image: + * + * docker run --rm -v "$PWD":/work -w /work \ + * mcr.microsoft.com/playwright:v1.60.0-noble \ + * sh -c "npm ci && BASE_URL=https://.vercel.app \ + * VERCEL_AUTOMATION_BYPASS_SECRET= \ + * npm run test:visual:update" + * + * Then commit `tests/visual/__screenshots__/home.png`. This is tracked + * as the `seed-visual-baselines-on-linux` follow-up convoy. + */ +const BASE = process.env.BASE_URL ?? 'http://localhost:3000'; + +test.describe('visual: public homepage', () => { + test('home renders consistently against baseline', async ({ page }) => { + await page.goto(BASE); + await expect(page).toHaveScreenshot('home.png'); + }); +});