| name | e2e-web |
| description | Run Playwright browser tests against the channelhub web dashboard. Use when validating UI changes (kebab menu, autopilot toggle, escalation card, settings panels) — the SPA has zero coverage from bun:test alone. |
Browser-level E2E for the dashboard
tests/e2e/ runs Playwright specs against an isolated WebFrontend booted on
a random port. No real daemon, no shim, no real Claude — each test seeds the
SessionRegistry directly.
First-time setup
Playwright is installed as a dev dep (@playwright/test). The browser
binaries are NOT installed automatically (they're ~150 MB):
bunx playwright install chromium
bunx playwright install
Running
bun run test:e2e
bun run test:e2e:ui
bunx playwright test --headed
bunx playwright test --debug
bun test does NOT pick up these specs — they end in .e2e.ts rather than
.test.ts / .spec.ts, so the two test runners stay disjoint.
Architecture: spawn the server in Bun, drive the browser from Node
Playwright runs in Node, but the WebFrontend pulls in Bun-only imports
(bun:sqlite, Bun.serve, screen-manager $). So the harness uses two
processes:
[ Playwright spec, Node ] ── stdio ──> [ tests/e2e/server-bin.ts, Bun ]
│
└── http://127.0.0.1:NNNNN
tests/e2e/server-process.ts exposes startServerProcess(opts) which
spawns the Bun child, reads <url>\n<cookie>\n from its stdout, and
returns a stop() handle. Use it in your specs — never import the
WebFrontend directly into a Playwright test.
Writing a new spec
import { test, expect } from '@playwright/test'
import { startServerProcess, type SpawnedServer } from './server-process'
let srv: SpawnedServer
test.beforeEach(async () => {
srv = await startServerProcess({
initialSessions: [{ path: '/proj/x:0', overrides: { name: 'x', trust: 'auto' } }],
})
})
test.afterEach(async () => { await srv.stop() })
test('something on x', async ({ page }) => {
const u = new URL(srv.url)
const eq = srv.cookie.indexOf('=')
await page.context().addCookies([{
name: srv.cookie.slice(0, eq),
value: srv.cookie.slice(eq + 1),
domain: u.hostname, path: '/',
httpOnly: true, sameSite: 'Strict',
}])
await page.addInitScript(() => {
localStorage.setItem('hub_user', JSON.stringify({ id: 11111, first_name: 'Test' }))
})
await page.goto(srv.url)
})
For tests that need to slow down a request (catch a spinner, race UI state
transitions), use page.route():
await page.route('**/api/autopilot', async (route) => {
await new Promise(r => setTimeout(r, 800))
await route.fulfill({ status: 200, body: JSON.stringify({ ok: true }) })
})
What's covered today
tests/e2e/dashboard.e2e.ts:
- Sidebar renders seeded sessions
- Kebab menu opens with trust segments + autopilot item
GET /api/sessions returns 200 with cookie / 401 without
- Autopilot toggle shows the spinner during /api/autopilot in flight
What to add when working on UI
- Personalities settings (planned): list/create/edit/delete flow, default-on-toggle
- Escalation card: render on websocket event, three actions (proceed/answer/dismiss)
- Veto countdown: timer ticks, send/edit/cancel buttons
- Theme switcher: data-theme attribute toggles, contrast-mode survives reload
- Spawn dialog: directory picker, browseRoot scoping, paste-to-upload
CI integration
NOT wired into CI yet. The Playwright run takes ~30-60s plus browser install
(~150 MB) — adds noticeable cost. When ready, the GitHub Actions step is:
- name: Install Playwright browsers
run: bunx playwright install chromium --with-deps
- name: E2E
run: bun run test:e2e
Run it on a separate job from bun test so the fast suite stays fast.
Don't
- Don't hit real network in specs. Always use
page.route() to mock external
fetches if you need them.
- Don't share state between specs. Each
test.beforeEach should boot a fresh
server. Tests run in parallel.
- Don't assert on dynamic timing (animation duration, exact ms). Use
Playwright's auto-waiting locators instead.