E2E Testing
Playwright E2E for Next.js and Fastify. Frontend apps have no separate unit suite.
Frontend apps have no separate unit suite. Two Playwright suites: Web (apps/web) for the product UI, API (apps/api) for Scalar login at /reference. Both use @test.ai addresses when ALLOW_TEST=true.
The Scalar page loads @scalar/api-reference from jsDelivr (pinned version in reference/template.ts). E2E depends on that CDN for the login button script.
Install Chromium for both suites with pnpm setup:playwright (or pnpm setup) if browsers are missing.
Root pnpm qa builds, then ends with pnpm test:e2e (SKIP_BUILD=1, scripts/run-e2e.mjs). That script kills processes on ports 3000 and 3001 unless SKIP_KILL_PORTS=1. Shared spawn env lives in scripts/e2e-local-shared.mjs.
Commands
| Command | Description |
|---|---|
pnpm test:e2e (root) | Fastify E2E, then Next E2E (spawns servers) |
pnpm --filter @repo/web test:e2e:local | Build (unless SKIP_BUILD=1), spawn API + Next, run web E2E |
pnpm --filter @repo/api test:e2e:local | Spawn API (RATE_LIMIT_MAX=10000), run Scalar login E2E — no ANTHROPIC_API_KEY required |
pnpm test:e2e:ui / test:e2e:debug | Playwright UI or debug (in the app) |
Pass URLs with --app / --api, or PLAYWRIGHT_APP_URL / PLAYWRIGHT_API_URL. Defaults: http://localhost:3000 and http://localhost:3001.
pnpm test:e2e --app=https://my-app.vercel.app --api=https://my-api.vercel.appSession model
Use one worker (PGlite cannot take concurrent writers). The setup project logs in as test@test.ai once and writes test-results/.auth/user.json. chromium, security, and chat reuse that file via project storageState. auth, passkey-login, and logout tests use empty storage so they never revoke the shared session.
Import page from @playwright/test in all specs. Navigate to the target route in each authed test (page.goto('/settings/...')).
import { expect, test } from '@playwright/test'
test('shows dashboard when authenticated', async ({ page }) => {
await page.goto('/')
await expect(page.locator('text=Signed In')).toBeVisible()
})Chat E2E needs a real ANTHROPIC_API_KEY (in-app Claude Haiku 4.5 by default). Playwright omits the chat project when hasRealAnthropicKey() is false (empty, sk-ant-xxx, sk-ant-dummy*), so fork CI without org secrets still runs auth and dashboard E2E. When the key is set, chat-assistant.spec.ts skips only quota or credit errors; upstream 5xx and network failures fail the test run. API Scalar E2E (pnpm --filter @repo/api test:e2e:local) does not call AI and does not require Anthropic. API remote tests (ai.spec.ts) use test/utils/ai-remote.ts: skip 402 always; skip 502/503/504 only with placeholder/missing key — real key fails on 502. See AI Architecture.
Test helpers
ALLOW_TEST=true stores plain tokens in verification.token_plain for @test.ai addresses.
| Endpoint | Use |
|---|---|
GET /test/verification/last?type=magic_link|change_email&email= | Email-scoped tokens for web E2E |
GET /test/magic-link/last?email= | Magic link token for Scalar E2E (email required, @test.ai only) |
GET /test/totp/current | Bearer required; in-progress TOTP setup code |
Local spawn scripts set ALLOW_TEST. The API process exits if ALLOW_TEST is true in production.
Playwright projects (order)
- public — legal pages, login smoke, robots/sitemap
- auth —
01-callbacks…06-logout(empty storage; depends on public; file-name order) - setup — writes
user.json(depends on auth) - chromium — dashboard smokes, 404 (storageState)
- security — TOTP, API keys, passkeys CRUD (storageState; never logout)
- passkey-login — passkey sign-in with
e2e-passkey@test.ai(empty storage; depends on setup) - chat — assistant (storageState; mobile viewport; last)
Dedicated emails: test@test.ai (shared), e2e-passkey@test.ai, e2e-email@test.ai (change-email).
Web specs
| Spec | Project | Coverage |
|---|---|---|
public.spec.ts | public | Privacy, terms, login, robots/sitemap |
01-callbacks.spec.ts | auth | OAuth/passkey/web3 missing-param errors |
02-magic-link-auth.spec.ts | auth | Login, token errors, JWT session |
03-proxy-gate.spec.ts | auth | Unauthenticated redirects |
04-update-tokens.spec.ts | auth | Cookie adapter CSRF/origin |
05-change-email.spec.ts | auth | In-page email change verify |
06-logout.spec.ts | auth | Header sign-out revokes sid |
auth.setup.ts | setup | Writes shared storageState |
dashboard.spec.ts | chromium | News, markets, profile, 404 |
security/authenticator.spec.ts | security | TOTP setup/unlink |
security/api-keys.spec.ts | security | API keys CRUD |
security/passkeys.spec.ts | security | Passkeys add/remove |
passkey-auth.spec.ts | passkey-login | Passkey sign-in flow |
chat-assistant.spec.ts | chat | Assistant Who am I? |
CI and mobile
Path-filtered PR workflows: api-e2e.yml, web-e2e.yml, packages-test.yml. See GitHub Actions.
Expo uses Maestro (apps/mobile/.maestro/flows/home.yml). CI Maestro is deferred; see Mobile CI/CD.
Vercel protection (manual)
For protected previews: enable Protection Bypass for Automation on both projects with the same secret, set VERCEL_AUTOMATION_BYPASS_SECRET, and allowlist OPTIONS / or /auth on the API project.
Troubleshooting
Ports in use: bash scripts/kill-test-servers.sh. Token extraction fails: confirm ALLOW_TEST=true and the correct @test.ai email query on /test/verification/last. Magic link 500 mid-suite: local/CI spawn sets RATE_LIMIT_MAX=10000 (default API limit is 100/min per IP). Passkey registration fails in CI: spawn scripts and web-e2e.yml set WEBAUTHN_RP_NAME=Test App when .env.test is absent.