OnRamp E2E tests (Playwright)
The E2E suites live under playwright/, one folder per domain (core, projects, playbooks, insights, workflows). They're authored and maintained by Claude Code subagents, not hand-written — you drive the agents and they emit TypeScript specs that follow the house conventions automatically.
Every test owns its transactional data lifecycle: it creates what it needs through the UI, uses it, and deletes it — so any suite runs in any order, in parallel, against any environment. What it runs against is not a bare login but a golden seed (devtools/db/, bootstrapped with bun run db.seed — see the README): the seeded login identities, the full lookup/reference tables, and a small set of per-tenant fixtures. The tenet still holds — tests own their transactional rows; the seed only provides the floor.
Pre-seeded fixtures belong in the golden seed, not in a test. If a spec needs data it genuinely cannot create through the UI — e.g. workflows/hubspot.spec reads a cached HubSpot or_integration schema (vendor 0) — that fixture goes into the golden seed via the golden-seed-update skill, which routes it to a migration (if prod needs it too), the seed generator (dev/test-only fixture), or nothing (if it's actually transactional → create it in the test). Never hand-add it to seed_data.sql or fake it per-test. Background: the golden-seed RFC.
Getting started (first run)
Three one-time setup steps the suites don't do for you:
- Deps:
bun install && bun run deps.dev. - Playwright browser —
bun installdoes not install it:bun run test:e2e:install(=playwright install chromium). Skipping this is the "Executable doesn't exist … runplaywright install" error on your firsttest:e2e:*. - Golden DB:
bun run db.seed(Docker required). The suites assume this seed — they log in as the seeded admin (admin@example.com). If you point a suite at your own dev DB instead, either seed it the same way or override the creds to a user that exists there (E2E_USER_EMAIL=… E2E_USER_PASSWORD=…). An auth/login failure at the very start of a run almost always means that login user isn't in the DB you pointed at — not a test bug.
Then bring the stack up and run a suite:
bun run dev --no-hmr # stack up: Vite (https://local.onramp.us:3000) + Flask (:5000)
bun run test:e2e:core # in a second shellWhy --no-hmr: HMR adds nothing when a browser fleet (not you) drives the pages, and under parallel E2E load Vite's HMR websocket drops and its client answers every drop with a full location.reload() of the unbundled module graph — headless Chromium balloons by gigabytes per worker until the OS starts killing things. A stack started plain (bun run dev) works for a quick serial run, but use --no-hmr for anything parallel.
Run the suites
The stack must be up first — HTTPS on local.onramp.us:3000 (Vite), Flask on :5000. Bring it up with bun run dev --no-hmr (see Getting started; the dev-verify skill covers stack health, certs, and recovery).
# Zero-config against the local stack — defaults to the seeded admin + local URL:
bun run test:e2e:projects # core | projects | playbooks | insights | workflows
bun run test:e2e:all # every folder, sequentially
# Watch one spec fail, serially:
bunx playwright test --config=playwright/projects/playwright.config.ts archive.spec.ts --workers=1
# Override creds / URL only for a non-local target (dev/stage):
E2E_USER_EMAIL=… E2E_USER_PASSWORD=… E2E_BASE_URL=https://… bun run test:e2e:projects- Local is zero-config. Creds default to the seeded admin
admin@example.com/OnRampLocal!23(vendor 0); base URL defaults tohttps://local.onramp.us:3000(the local dev stack). Just runbun run test:e2e:<suite>— no env prefix. - Override via env for non-local targets (dev/stage):
E2E_USER_EMAIL/E2E_USER_PASSWORD(real creds from CI/Actions secrets — never committed) andE2E_BASE_URL. CI setsE2E_BASE_URLto its preview URL and relies on the default creds. - Other seeded identity:
customer@example.comis the vendor-1 owner. Most suites assume the admin — seeplaywright-testingfor role preconditions. E2E_WORKERS=Ncaps per-suite worker count. Set it when several suites run at once against the one shared stack soN × suitesdoesn't oversubscribe the box (≈cores − 2total Chromiums is the ceiling). On a 14-core machine:WORKERS=2per folder alone,WORKERS=1when running all folders concurrently.- On failure, read
playwright/<folder>/test-results/<spec>/error-context.md— a full a11y snapshot at the break — before guessing selectors. - The playbooks suite enables the
playbook-versioningflag automatically (itsglobal-setup.ts), so the versioned UX is what runs.
Running in CI
.github/workflows/e2e.yml runs the suites on every PR and on push to dev. It's a per-suite matrix gated by dorny/paths-filter: a PR runs only the domains it touched — every suite is path-gated, including core (its login + global-search smoke runs only when auth, the login UI, global search, or the core specs change). A shared/seed/infra change — or any push to dev — runs all five; a PR touching no relevant paths runs none. The full-suite safety net is the push-to-dev run, not the PR. The single required status check is the e2e suites gate job; a single domain failure re-runs as one job ("Re-run failed jobs"), not the whole matrix.
Two CI-only quirks worth knowing when a test passes locally but not in CI:
- CI serves a built bundle (
PREVIEW_MODE=1 bun run dev), not the Vite dev server. Behaviour that depends on dev-only HMR/source paths won't reproduce. - The app process starts with
CIunset. GitHub setsCI=true, which the backend maps toqa_modeand hides the org login form — so the app runs underenv -u CIwhile Playwright itself keepsCI=truefor its worker count. Retries are 0 by policy (a flaky test is quarantined, not retried — seeplaywright-testing).
Skills you'll use
| Skill | For |
|---|---|
playwright-testing | The conventions (the lifecycle tenet, locator policy, page-object layout) and the three authoring subagents below. Auto-loads when you touch anything under playwright/. |
dev-verify | Bring up and drive the local stack; cert/colima recovery; agent-browser mechanics. |
onramp-db | Inspect the local dev Postgres (uuid → numeric id, columns, seed checks). Read-only conventions. |
workflow-verify | Validate CRM automation / workflow-editor changes locally with no live OAuth (HubSpot/Salesforce field mapping, dealstage). |
Author or fix a test
Three subagents, invoked by name from a Claude Code session:
| You have… | Use | What it does |
|---|---|---|
| A page/flow with no plan | playwright-test-planner | Explores the live app, writes a plan to playwright/<feature>/specs/<area>.md. |
| A plan or a clear scenario | playwright-test-generator | Drives the flow in a real browser, emits tests/*.spec.ts + page objects, runs the suite. |
| A red spec | playwright-test-healer | Runs it, reads the trace/snapshot, fixes the spec or page object. |
All three load the playwright-testing skill first, so the lifecycle tenet and locator policy come for free — you don't restate them. Give the agent just: which feature folder, the route/scenario, and confirm the creds env is set.
Scale with Claude workflows
When the same treatment needs to hit many specs at once — auditing every spec against a new convention, healing a whole suite, a cross-folder migration — a single conversation can't coordinate it. Reach for a Claude workflow: a script that fans out subagents in the background and returns one structured result. Trigger it by including the word workflow in your prompt.
The pattern that fits E2E is a pipeline: audit, then heal, partitioned by folder.
- Audit — one read-only subagent per spec classifies it against the target convention. Returns structured findings.
- Heal — one subagent per feature folder fixes all of that folder's specs + page objects, then verifies with
bun run test:e2e:<folder>.
Collision rules (non-negotiable for parallel runs):
- One subagent owns one feature folder. Page objects are shared within a folder, so two agents in the same folder corrupt each other. Partition by folder, never finer.
playwright/shared/is off-limits to fan-out agents (auth, helpers, base config). Only a single serialized agent touches it, and only for a genuine shared-infra root cause.- Verify your own folder only (
test:e2e:<folder>), nevertest:e2e:all, during a run. - Throttle with
E2E_WORKERSso concurrent folder suites don't oversubscribe the one shared stack.
Model routing. A workflow's subagents inherit the session model unless the script routes a stage to another. Keep orchestration/audit on the strong model, and route implementation/heal stages to a cheaper one — see the claude-workflows skill for the convention and the one-line agent(..., { model }) knob. Limits: ≤16 concurrent agents, 1000 per run.
Folders & layout
playwright/
├── shared/ # imported by every folder — auth, random, capture-id, base config (not a test folder)
├── core/ # login/auth + global search
├── projects/ # projects CRUD, members, data fields, tasks, archive, search
├── playbooks/ # playbooks, modules, roles, versioning modes (globalSetup enables playbook-versioning)
├── insights/ # Project Schedule Timeline (Gantt)
└── workflows/ # Ramps / workflow authoring (HubSpot trigger)Per folder: specs in tests/, element queries in pages/*.page.ts (locators only), UI-driven create/delete helpers in fixtures/ (they return captured ids). The thin playwright.config.ts calls createBaseConfig({ name }) from playwright/shared/config.base.ts; cross-suite infra (auth.ts, random.ts, capture-id.ts) is imported from shared/, never copied or imported across folders. A new domain is a new folder — copy playwright/projects/.
Local gotcha — the Flask reloader
The dev API runs with --reload, which watches *.zip. Playwright writes trace zips into test-results/ mid-run; without an exclude that restarts the API and flakes every test. make dev.api passes --exclude-patterns "*/test-results/*/*:*/playwright-report/*/*". Watchdog's * is per-segment (not fnmatch), so a naive */test-results/* silently no-ops — keep the two-segment tail if you edit it.