Today Platform Web — Dev Docs
Automation

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 dev serves the worktree at http://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 code

This 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_ORIGIN trap. An earlier version of this guide warned that submitting the login form on bare localhost returns 403 {"code":"INVALID_ORIGIN"}. That was measured to be wrong: auth.todayai.dev's better-auth accepts a localhost Origin (the trustedOrigins check is not enforced on the OTP endpoints — it accepts even an arbitrary cross-origin value). The real reason bare localhost used to fail was the session cookie coming back scoped to Domain=.todayai.dev, which a localhost page 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 cert

In 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.dev

In 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.dev

Output ends with the target user's session token (printed to stderr-prefix lines plus a final raw token on stdout). The script:

  1. OTP-signs in as E2E_ADMIN_EMAIL
  2. Mints a JWT for https://api.today.ai audience via auth.todayai.dev/api/token
  3. OTP-signs in as the target user (so we know their userId)
  4. Calls POST /v1/admin/onboarding/clear with X-User-Id: <target-id> to wipe their onboarding state
  5. 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 fail

For 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.json Playwright 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:

  • userOnboarding row
  • userProfile row (preferredName, goal)
  • agentProfile row (agentName, agentAvatar)

Connectors / channels are NOT cleared — that's a separate concern (real OAuth connections).

Common pitfalls

  • 429 Too Many Requests on /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.
  • /onboarding redirects to / — fixture user already finished. Run apps/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 :4060 server and rebind the worktree there.
  • .env.cloud not found in worktree — it lives at the parent repo root. The reset script handles this; if you're rolling your own, find it via dirname $(git rev-parse --git-common-dir) (with the worktrees/<name> segment stripped if present).
  • Cookie doesn't seem to apply — check it's set on .todayai.dev (with leading dot), not todayai.dev. Better Auth's cookie name is __Secure-better-auth.session_token, exact case. The __Secure- prefix requires secure: true and HTTPS — for http://localhost:4060 direct, 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).

On this page