Agent E2E testing
How to drive Playwright (or any headless browser) through auth-gated flows on this repo. Written for AI agents.
How to drive Playwright (or any headless browser) through auth-gated flows on this repo. Written for AI agents — humans probably want Local development instead.
TL;DR
Three paths. Pick by what you're doing:
- Direct localhost (
pnpm dev) — simplest, and where to start.pnpm devserves the worktree athttp://localhost:<port>in localhost-direct dev mode; the in-page OTP login works with no proxy and no cookie injection. (Path 0 below.) - Whistle / Proxyman + browse
https://todayai.dev— when you want the production-like domain and real OAuth callbacks to round-trip. Setup is heavy (one-time). (Path A below.) - Direct API sign-in +
addCookies— for scripted / headless tests that mint a session cookie out-of-band and inject it into Playwright. Works against any port, any branch. (Path B below.)
If unsure, start with direct localhost. For the API-based path, the script apps/web/scripts/onboarding-reset.sh does the heavy lifting.
Path 0: Direct localhost (localhost-direct dev mode)
pnpm dev runs each worktree on its own port — http://localhost:<port>, the
first free port scanned from 4060 — in localhost-direct dev mode
(LOCALHOST_BFF=true, committed in apps/web/.env.development). In this mode
the auth BFF strips the Domain attribute from relayed Set-Cookie headers,
so the better-auth session cookie is stored host-only for localhost and the
full in-page OTP login works directly — no whistle, no proxy:
await page.goto('http://localhost:<port>/login')
// drive the email-OTP form normally — submit email, enter the 6-digit codeThis is the simplest path for most agent e2e: no proxy setup, no cookie
injection. Pull the OTP with the gws CLI (see CLAUDE.md) or, for the
allowlisted fixtures, the /internal/e2e/last-otp endpoint.
Stale-doc note — there is no
INVALID_ORIGINtrap. An earlier version of this guide warned that submitting the login form on barelocalhostreturns403 {"code":"INVALID_ORIGIN"}. That was measured to be wrong:auth.todayai.dev's better-auth accepts alocalhostOrigin(thetrustedOriginscheck is not enforced on the OTP endpoints — it accepts even an arbitrary cross-origin value). The real reason barelocalhostused to fail was the session cookie coming back scoped toDomain=.todayai.dev, which alocalhostpage cannot store — and that is exactly what localhost-direct dev mode fixes.
Path A: Whistle (real-domain proxy)
Best for: humans clicking through a flow, debugging real OAuth callbacks, validating a fresh feature against the live dev backend.
pnpm i -g whistle
w2 start
w2 ca # install + trust whistle's CA certIn whistle's web UI (default http://127.0.0.1:8899), import dev-tools/whistle/today-remote-dev.rules. The rules map:
todayai.dev → localhost:4060 (local web)
admin.todayai.dev → localhost:4061 (local admin)
auth.todayai.dev → passthrough (real auth-service)
api.todayai.dev → passthrough (real API gateway)Configure the system / browser to use 127.0.0.1:7777 as HTTP proxy, then browse https://todayai.dev/login. OAuth, cookies, sessions all round-trip naturally.
One catch for agents: Playwright MCP / a fresh Chromium context will not pick up your system proxy settings. You need chromium.launch({ proxy: { server: 'http://127.0.0.1:7777' } }) and ignoreHTTPSErrors: true (whistle's CA isn't trusted by Playwright's bundled browser). For most agent tasks this is more friction than Path B.
Path B: Direct API sign-in (headless / CI)
Bypass the in-page sign-in. Mint a session cookie via auth.todayai.dev directly, then inject it into Playwright. Useful for headless or CI runs that want a logged-in context without driving the OTP UI, and it works regardless of which port the dev server is on. (For interactive agent e2e, Path 0 — the in-page login on direct localhost — is usually simpler.)
Credentials
Live at .env.cloud in the parent repo root (not in worktrees). Relevant keys:
E2E_OTP_ENDPOINT_URL=https://auth.todayai.dev/internal/e2e/last-otp
E2E_OTP_ENDPOINT_SECRET=... # bearer for the read-back endpoint
E2E_USER_EMAIL=e2e-13@todayai.dev
E2E_ADMIN_EMAIL=e2e-03@todayai.devIn a worktree under .claude/worktrees/<name>/, .env.cloud is not here — it's at the repo's actual root. The reset script handles this with git rev-parse --git-common-dir.
One-line: reset + sign in
apps/web/scripts/onboarding-reset.sh # uses E2E_USER_EMAIL
apps/web/scripts/onboarding-reset.sh other-fixture@todayai.devOutput ends with the target user's session token (printed to stderr-prefix lines plus a final raw token on stdout). The script:
- OTP-signs in as
E2E_ADMIN_EMAIL - Mints a JWT for
https://api.today.aiaudience viaauth.todayai.dev/api/token - OTP-signs in as the target user (so we know their
userId) - Calls
POST /v1/admin/onboarding/clearwithX-User-Id: <target-id>to wipe their onboarding state - Prints the target user's session token
Inject into Playwright
The session token alone isn't enough — Better Auth signs cookies and the cookie value is <token>.<signature>. Easier path: just sign in via curl with -c cookies.txt and import the cookie file.
import { chromium } from '@playwright/test'
const browser = await chromium.launch()
const ctx = await browser.newContext()
await ctx.addCookies([
{
name: '__Secure-better-auth.session_token',
value: '<token>.<urlencoded-signature>', // exact value from cookies.txt
domain: '.todayai.dev',
path: '/',
httpOnly: true,
secure: true,
sameSite: 'Lax',
expires: -1,
},
])
const page = await ctx.newPage()
await page.goto('https://todayai.dev/onboarding') // via whistle
// or:
await page.goto('http://localhost:4060/onboarding') // direct, cookie still applies because domain is .todayai.dev — but the page's own fetch() to auth.todayai.dev will work; only the in-page sign-in form would failFor Playwright MCP (no Node-level access), you can either:
- Live with the limitation — drive every step that does NOT require a new sign-in (post-login pages work once cookies are seeded by an earlier curl + manual
addCookies). - Or write a small Node script that uses Playwright's API directly, runs the reset + cookie-injection, and saves a
storageState.jsonPlaywright MCP can later load.
Why not just OTP through the in-page UI
You can — once. After ~3 attempts the auth backend rate-limits the email for several minutes (429). For iterative test runs, the API-based sign-in is faster and doesn't burn through the rate budget.
Resetting onboarding state
The fixture users (e2e-13, e2e-03) persist between sessions. After a successful onboarding walk-through, they're stuck on step: 'done' — /onboarding redirects to / and you can't replay the funnel.
The reset script's third step (POST /v1/admin/onboarding/clear with X-User-Id) wipes:
userOnboardingrowuserProfilerow (preferredName, goal)agentProfilerow (agentName, agentAvatar)
Connectors / channels are NOT cleared — that's a separate concern (real OAuth connections).
Common pitfalls
429 Too Many Requestson/api/auth/email-otp/send-verification-otp— you OTP'd that email too many times in the past few minutes. Wait 5 min or use a different fixture email./onboardingredirects to/— fixture user already finished. Runapps/web/scripts/onboarding-reset.sh.- Worktree on non-default port (4061) — whistle's rule maps
todayai.dev → localhost:4060. Either edit the rule for your worktree's port, or stop the main:4060server and rebind the worktree there. .env.cloudnot found in worktree — it lives at the parent repo root. The reset script handles this; if you're rolling your own, find it viadirname $(git rev-parse --git-common-dir)(with theworktrees/<name>segment stripped if present).- Cookie doesn't seem to apply — check it's set on
.todayai.dev(with leading dot), nottodayai.dev. Better Auth's cookie name is__Secure-better-auth.session_token, exact case. The__Secure-prefix requiressecure: trueand HTTPS — forhttp://localhost:4060direct, the cookie still attaches because the domain match is on.todayai.dev(browser delivers the cookie when same-domain fetches go out, regardless of the page's own scheme).
Related docs
- Local development — full whistle / Proxyman setup, multi-app
- Auth cookie current state — cookie domain + lifetime details
- Auth interaction architecture — how auth-service / api-gateway / web split responsibilities
apps/web/e2e/lib/otp-login.ts— the canonical implementation of Path B in TypeScript (used byapps/web/playwright.smoke.config.tsand friends)apps/web/scripts/onboarding-reset.sh— convenience wrapper
Cloud dev (Claude Code on the web)
Running this repo end-to-end inside Claude Code on the web — credential flows, env panel, network allowlist, MCP servers.
Claude skills
Cloud-dev sessions load skills from .claude/skills/ on demand. What skills exist in this repo, what they do, and when to add a new one.