End-to-end tests
The Playwright harness that owns the server, its preflight checks, the offline vendor fakes, the live gate, and the loop knobs.
apps/web/e2e/ drives the real app in Chromium. The harness owns the whole run: services, build, server, throwaway database, and the teardown of all of it.
What a run does
pnpm infra; nothing is built until all pass.next build into a per-suite directory, so a run never touches the one pnpm dev uses.retries: 0, retaining a trace for every failure.Offline by construction
| Would need the internet | What the suite uses instead |
|---|---|
| OpenRouter | A local fake: fixed model catalog, deterministic streamed completions, exact prices |
| Stripe | A local fake serving the endpoints the billing plugin calls |
| Umami, Chatwoot, Turnstile | Aborted at the browser. Any other host fails the spec, naming the URL |
| The desktop release feed | The same fake origin |
Ports and services
| Fact | Value |
|---|---|
| Web suite port | 4100 + (checkout hash % 900); WEB_E2E_ORIGIN overrides |
| Desktop suite port | 5100 + (the same hash % 900); DESKTOP_E2E_ORIGIN overrides |
| Build directory | apps/web/.next-e2e-web and apps/web/.next-e2e-desktop |
| Services | Postgres, Redis, Mailpit, Inngest, MinIO - each on its pnpm infra default port |
Each checkout gets its own port, database, Redis key prefix and Inngest app id, so two worktrees can run at once.
The harness refuses to start against a cache that is not on this machine. It writes to the cache and its teardown deletes every key under its own prefix, so a REDIS_URL or UPSTASH_REDIS_REST_URL pointing at a live one stops the run until you set the knob below.
Where a failure's evidence lives
Playwright empties test-results/ at the start of every run - including the run you start to reproduce the failure. Read the copy the harness keeps instead.
| Want | Look in |
|---|---|
Trace, screenshot, video, error-context.md | apps/web/e2e-logs/<suite>-<run id>/ |
| The server's own log for that run | apps/web/e2e-logs/<suite>-<run id>.log |
The run prints both paths as it tears down. Older runs are pruned, so copy anything you need to keep.
The live gate
*.live.spec.ts files run only under pnpm e2e:live. The harness first probes each vendor key and coding CLI with one real minimal turn; anything that cannot complete one prints not run: <item> — <why> and its specs never register. The run exits 0 when nothing is available.
Live runs reach real vendors and cost real money. pnpm e2e:ci does neither. See Live vendor tests for the keys, the cost, and how the lane gates.
Knobs
Set the bottom three only when your services are not the ones pnpm infra starts. GENERATESAAS_E2E_EXTRA_SERVER_ENV is the one extension point for a project whose own runtime schema requires a key this harness never heard of - it only ADDS, and naming a key the harness already sets fails the run by name.
| Variable | Effect |
|---|---|
GENERATESAAS_E2E_DEV_SERVER=1 | Run against next dev - nothing is cached, so nothing can be stale |
GENERATESAAS_E2E_LIVE=1 | Treat the run as live, for IDE and UI-mode sessions |
GENERATESAAS_E2E_EXTRA_SERVER_ENV | Extra server environment as a flat JSON object: {"CLI_SHARED_SECRET":"e2e-secret"} |
WEB_E2E_ORIGIN / DESKTOP_E2E_ORIGIN | Move a suite's origin, for two checkouts that hash to one port |
SMTP_HOST=::1 | Mailpit binds SMTP on IPv6 only, so 127.0.0.1:1025 is refused |
STORAGE_ACCESS_KEY_ID / _SECRET_ACCESS_KEY | Your MinIO is not on the compose file's minioadmin pair |
GENERATESAAS_BATTERY_DATABASE_URL | The admin connection CREATE DATABASE runs against, when yours is not postgres:postgres@127.0.0.1:5432 |
GENERATESAAS_E2E_ALLOW_REMOTE_CACHE=1 | Accept a cache outside this machine, teardown's prefix delete included |
CI=1 pins one worker. Locally Playwright takes one per core, and all of them drive the same server - past about four they starve it and unrelated specs time out. Pass --workers=4.
pnpm e2e:ci covers the web app only. The desktop app has its own Playwright harness, with its own command, its own port and its own build directory - see desktop end-to-end tests.
Adding a spec
apps/web/e2e/<journey>.spec.ts importing { expect, test } from "@playwright/test".ids, ARIA roles, visible text - over CSS classes.newE2eContext(browser) from e2e/fixtures, never browser.newContext - that helper installs the origin block.expect(...).toPass(), so a pre-hydration no-op click retries.