Multi-tenant Playwright execution control plane — job queues, workers, failure taxonomy, retries, API keys, artifacts, webhooks, and agent browser sessions.
Built in public as a portfolio/control-plane slice of production browser-automation platforms (think fleet orchestration, not “SDET helpers”).
Teams outgrow “run Playwright in CI” when they need:
- Multi-tenant isolation and API keys
- Shared worker fleets with concurrency caps
- Classified retries (don’t thrash on hard assertion failures)
- Artifacts + webhooks for product integrations
- Long-lived browser sessions for agents / interactive automation
playfleet is a small, readable TypeScript implementation of that control plane.
| Concern | Playwright in GHA | playfleet |
|---|---|---|
| Tenancy | One repo / one org; secrets sprawl | First-class tenants + hashed API keys |
| Queue / backpressure | Workflow concurrency hacks | Postgres queue + FOR UPDATE SKIP LOCKED |
| Failure policy | Retry whole job or nothing | Heuristic classes; APP_ASSERTION does not retry |
| Artifacts | Upload-artifact per workflow | Per-attempt logs/traces, download API |
| Webhooks | Separate glue | HMAC-signed terminal callbacks |
| Agent browsers | Ephemeral runners; no connect API | POST /v1/sessions → chromium.connect(ws) |
| Multi-product | Duplicate workflows per app | One fleet, many tenants |
| Cost model | Pay per minute of runner | Hold a pool; amortize browser cold starts |
CI is great for PR gates. A control plane is for productized browser execution: SaaS test infra, accessibility fleets, agent tools, on-demand sessions.
Use both: CI for change validation, playfleet for runtime platform.
┌─────────────────────────────────────────┐
│ Clients │
│ curl / SDK / dashboard / agent scripts │
└───────────────┬─────────────────────────┘
│ Bearer pfk_live_…
▼
┌──────────────────────────────────────────────────────────────────┐
│ API (Hono + Node) │
│ • Auth (hashed API keys) │
│ • Tenants, jobs, webhooks, artifacts metadata │
│ • Sessions: Playwright launchServer + idle/hard TTL reaper │
│ • Landing / + /dashboard │
└───────────────┬───────────────────────────────┬──────────────────┘
│ │
│ INSERT jobs │ wsEndpoint (localhost)
▼ ▼
┌───────────────┐ ┌─────────────────┐
│ Postgres │ │ Agent client │
│ tenants │ │ chromium.connect│
│ jobs/attempts│ └─────────────────┘
│ artifacts │
│ webhooks │
│ sessions │
└───────┬───────┘
│ claim: SKIP LOCKED + tenant max_concurrency
▼
┌───────────────┐
│ Worker(s) │
│ run PW tests │──▶ ARTIFACTS_DIR (trace zip + logs)
│ classify │──▶ HMAC webhooks on terminal
│ retry/backoff│
└───────────────┘
Job state machine: queued → running → (queued on retry) → succeeded | failed.
Session state machine: starting → ready → closing → closed (or error).
# Postgres (compose maps 5433→5432; bare local often 5432)
export DATABASE_URL=postgres://playfleet:playfleet@localhost:5432/playfleet
npm install
npm run db:migrate
npm run smoke:install # Playwright browsers for job fixtures
npm run dev:api # :8080
npm run dev:worker # claims jobs
# Tenant + key (open bootstrap in development)
curl -s -X POST localhost:8080/v1/tenants \
-H 'content-type: application/json' \
-d '{"name":"Acme","slug":"acme","maxConcurrency":2}' | jq .
export PF_KEY='pfk_live_…' # shown once
curl -s -X POST localhost:8080/v1/jobs \
-H "Authorization: Bearer $PF_KEY" \
-H 'content-type: application/json' \
-d '{"name":"smoke","payload":{"suite":"smoke"}}' | jq .Dashboard: http://localhost:8080/dashboard
Docker: docker compose up --build (API + worker + Postgres; shared pf_artifacts volume).
- Payload: suite (
smoke|flaky|expect-fail), browser, retry policy, timeout - Workers claim with tenant concurrency awareness
- Attempt history on every job
Ordered heuristics in src/classify.ts. Notable rule: APP_ASSERTION is not retryable — product bugs shouldn’t burn fleet capacity.
- Per-attempt log + Playwright output zip under
ARTIFACTS_DIR GET /v1/artifacts/:id/download- Webhooks:
X-Playfleet-Signature: sha256=…HMAC over body
curl -s -X POST localhost:8080/v1/sessions \
-H "Authorization: Bearer $PF_KEY" \
-H 'content-type: application/json' \
-d '{"name":"agent"}' | jq .session.connect
# Client Playwright version must match server (see package.json)
# const browser = await chromium.connect(wsEndpoint)Example: examples/agent-login — external script logs into a public demo site via a hosted browser.
Bind: 127.0.0.1 by default (same host / SSH tunnel).
TTL: idle 5m (keepalive extends), hard 30m.
| Area | Paths |
|---|---|
| Public | GET /, /dashboard, /health |
| Bootstrap | POST /v1/tenants |
| Auth | GET /v1/me, POST /v1/api-keys |
| Jobs | POST/GET /v1/jobs, GET /v1/jobs/:id |
| Artifacts | GET /v1/jobs/:id/artifacts, GET /v1/artifacts/:id/download |
| Webhooks | GET/POST/DELETE /v1/webhooks |
| Sessions | POST/GET/DELETE /v1/sessions, POST …/keepalive |
Auth: Authorization: Bearer pfk_live_… or X-API-Key.
Numbers from scripts/bench.mjs on the machine that ran them — not a sales claim.
# API + worker already running; PF_KEY set
npm run bench| Field | Value |
|---|---|
| Date | 2026-07-21 |
| Host | ubuntu-4gb-nbg1-1 (Hetzner-class VPS) |
| CPU | 4× Intel Xeon (Skylake) |
| RAM | ~7.6 GiB |
| Node | v22 |
| Workload | suite: smoke, maxAttempts: 1, traces off |
| Workers | 1 |
| Tenant maxConcurrency | 4 |
| Metric | Result |
|---|---|
| Jobs | 20/20 succeeded |
| Submit burst | 393 ms |
| Wall clock | 53.9 s |
| Throughput | 22.3 jobs/min |
| Latency p50 | 29.9 s |
| Latency p95 | 53.5 s |
Full machine snapshot: docs/BENCHMARKS.md.
Caveats
- Cold Chromium start dominates short jobs; real suites are slower.
- Single worker; scale is roughly linear until CPU/RAM or Postgres claim contention.
- No multi-region, no spot preemption, no browser pool reuse in the job path yet.
- Session mode is not included in jobs/min (different lifecycle).
Re-run and commit updated docs/BENCHMARKS.md when hardware or code changes materially.
| Milestone | Status |
|---|---|
| M0 scaffold | done |
| M1 API + queue | done |
| M2 worker executes | done |
| M3 retries + classification | done |
| M4 multi-tenant + API keys | done |
| M5 artifacts + webhooks + dashboard | done |
| M6 session mode | done |
| M7 polish (this) | done |
Possible next: browser pool reuse, edge WS proxy for remote session connect, sharded workers, OIDC.
See CONTRIBUTING.md and GOOD_FIRST_ISSUES.md (tracking issue #1).
MIT — LICENSE
Built by Criston Mascarenhas · github.com/crstnmac/playfleet