Skip to content

Repository files navigation

playfleet

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”).

License: MIT


Why this exists

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.


Why not just CI? (GitHub Actions / GHA)

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.


Architecture

                    ┌─────────────────────────────────────────┐
                    │              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).


Quick start

# 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).


Feature tour

Jobs + workers

  • Payload: suite (smoke | flaky | expect-fail), browser, retry policy, timeout
  • Workers claim with tenant concurrency awareness
  • Attempt history on every job

Failure taxonomy

Ordered heuristics in src/classify.ts. Notable rule: APP_ASSERTION is not retryable — product bugs shouldn’t burn fleet capacity.

Artifacts + webhooks

  • Per-attempt log + Playwright output zip under ARTIFACTS_DIR
  • GET /v1/artifacts/:id/download
  • Webhooks: X-Playfleet-Signature: sha256=… HMAC over body

Sessions (agents)

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.


API surface (summary)

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.


Benchmarks (honest)

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

Reference run (this repo’s build host)

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.


Roadmap

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.


Contributing

See CONTRIBUTING.md and GOOD_FIRST_ISSUES.md (tracking issue #1).

License

MIT — LICENSE

Built by Criston Mascarenhas · github.com/crstnmac/playfleet

About

Multi-tenant Playwright execution control plane — job queues, workers, failure taxonomy, retries, API keys, artifacts, webhooks, and agent browser sessions.

Resources

Contributing

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages