Open-source E2E and load testing for any web application.
Charlie lets QA engineers define browser flows once and run them as end-to-end correctness tests (Playwright) or load tests (k6) against any environment of any project — on demand, on a schedule, or automatically when code merges. It reports results to a dashboard and to Slack, keeps a full audit trail of who changed what, and imports flows from a JSON file so you can author them however you like — including with your own AI assistant.
Charlie is not tied to any framework, cloud, or domain. If it runs in a browser and answers on a URL, Charlie can test it.
Most teams stitch together three things by hand: a place to store test flows, a way to run them at scale, and a way to see what happened. Charlie is that glue, opinionated and self-hostable:
- One flow, two modes. Author a flow once. Run it as a Playwright E2E check (does the journey work?) or feed the same intent into a k6 HTTP load scenario (does it work under N concurrent users?).
- Bring your own Playwright code. For journeys too complex for step-based flows, point a flow at a GitHub repo of real Playwright tests. Charlie clones it, runs
playwright testagainst the chosen environment, and reports back — code flows and step flows run side by side. See docs/CUSTOM_TESTS.md. - Composable flows. Reuse a group of steps across flows — author a
loginflow once and drop it into others as a singleuseFlowstep; Charlie inlines its current steps at run time. - Any project, any environment. Register multiple projects; each has environments (
dev,qa,staging,prod, or whatever you name) with their own base URL, headers, and secrets. Point a run at exactly one. - Runs where and when you want. Trigger manually from the dashboard, from Slack, on a cron interval, or automatically on a merge to a watched branch.
- Slack-native.
/charlie run checkout --env qakicks off a run; results post back to the channel when they finish. - Author anywhere, import as JSON. Flows are a portable JSON document. Write them in the editor, export them to review in a pull request, or have your own AI assistant generate the file (there's a skill for that) and upload it — Charlie validates it strictly against the flow schema and your project before anything is saved. See docs/FLOW_IMPORT_EXPORT.md.
- Accountable by default. Google SSO for login; every mutating action (flow edit, schedule change, run trigger, member change) is written to an immutable audit log.
Charlie splits cleanly into a control plane on Cloudflare and a compute plane on GitHub Actions.
┌──────────────────────── Cloudflare ────────────────────────┐
Browser │ Worker (Hono) D1 (SQLite) Durable Objects │
Slack ──▶│ · SPA + REST API · projects · per-run coordinator │
GitHub ─▶│ · Google SSO · flows/runs · live progress fan-out │
webhooks │ · audit log · audit_log Queues · Cron · R2 │
└───────────────┬──────────────────────────────┬─────────────┘
│ workflow_dispatch │ vu-result / artifacts
▼ ▲
┌──────────────────────── GitHub Actions ───────┴─────────────┐
│ Reusable workflow · matrix fan-out │
│ Playwright (E2E) k6 (HTTP load) │
└─────────────────────────────────────────────────────────────┘
- Cloudflare holds all state and does none of the heavy lifting. The Worker serves the dashboard and API, authenticates users, coordinates runs, and stores reports. D1 is the system of record; R2 holds large artifacts (screenshots, videos, traces, HTML reports); Durable Objects coordinate each live run; Queues decouple dispatch; Cron Triggers fire scheduled runs.
- GitHub Actions runs the actual browsers and load generators. Charlie dispatches a reusable workflow; each virtual user or shard reports back to the Worker API and uploads artifacts to R2.
See ARCHITECTURE.md for the full design.
| Layer | Choice |
|---|---|
| Control plane | Cloudflare Workers (Hono), Durable Objects, Queues, Cron Triggers |
| Database | Cloudflare D1 (SQLite) |
| Artifact storage | Cloudflare R2 |
| Frontend | React + Vite SPA, served by the Worker |
| Compute | GitHub Actions (GitHub-hosted runners) |
| E2E engine | Playwright |
| Load engine | k6 (protocol-level HTTP) |
| Auth | Google SSO (OIDC) |
| Integrations | Slack app, GitHub App |
| Language / tooling | TypeScript, Bun (install/runtime), Nx (monorepo), Biome, Wrangler |
Charlie ships as a self-hosted, single-organization tool: you deploy it to your own Cloudflare account, restrict Google SSO to your allowed email domains, and manage many projects inside that one organization. The data model reserves room to grow into multi-org later, but the v1 product is one org, many projects.
Other confirmed decisions:
- GitHub-hosted runners for the first release (self-hosted runner support is a later, additive feature).
- k6 for load, Playwright for E2E — a single flow definition compiles to either.
- No AI inside Charlie — no provider keys, no source-code analysis, no model calls. Flow authoring that wants an assistant happens in the engineer's own harness; Charlie's contract is the JSON document it validates and runs.
| Doc | Contents |
|---|---|
| ARCHITECTURE.md | Components, run lifecycle, data flow, topology |
| docs/DATA_MODEL.md | D1 schema, entities, relationships, audit log |
| docs/AUTH.md | Google SSO, sessions, RBAC, audit trail, API keys |
| docs/TEST_ENGINES.md | Flow format, Playwright + k6 execution, engine abstraction |
| docs/CUSTOM_TESTS.md | Code flows: run your own Playwright tests from a GitHub repo |
| docs/CI_INTEGRATION.md | GitHub App, reusable workflow, dispatch, on-merge triggers, cron |
| docs/SLACK.md | Slash commands, run reporting, install flow |
| docs/FLOW_IMPORT_EXPORT.md | The portable flow document: export, upload, validation |
| docs/API.md | REST endpoint reference |
| EXECUTION_PLAN.md | Phased, detailed build plan with acceptance criteria |
In development. Phases 0–7 are complete and verified.
Phase 1 — Auth, RBAC & audit:
- Cloudflare Worker (Hono) serving the React SPA and a
/apisurface, D1 as the system of record. - Google OIDC login with PKCE +
state, ID-token verification, and a domain gate; first user becomesowner. - Session cookies (hash stored in D1), the single authenticate → authorize → rate-limit middleware, and the role→capability RBAC matrix.
- Machine auth via scoped, hashed API keys.
- An append-only audit log written in the same D1 transaction as every mutation, with secret redaction.
- Members and API-key management (UI + API).
Phase 2 — Projects, environments & flow authoring:
- Projects, environments, flows, and immutable
flow_versions(migration0003). - CRUD APIs for all three, every mutation audited; environment secrets AES-GCM encrypted at rest and returned only masked.
@charlie/flow-core: the ZodFlowStepschema,{{secrets.*}}/{{vars.*}}placeholder resolution, the engine-agnostic step executor +actionRegistry, and version diffing.- SPA: project list/detail, environment editor with masked secrets, a step-based flow editor (add/edit/reorder), and flow version history with per-version diffs.
Phase 3 — Execution plane (the control/compute handshake):
- Runs, shards, results, and reports (migration
0004); a Cloudflare Queue decouples dispatch, a Durable Object (Run Coordinator) tracks shard check-ins, aggregates results, fans out live progress over SSE, enforces a dead-shard timeout, and writes the terminal report; R2 holds artifacts. POST /api/runs→ queued run + Coordinator init + enqueue; queue consumer mints a run token and dispatches the GitHub reusable workflow, resolvinggha_run_id.- Machine-callback routes authorized by short-lived, run-scoped tokens: flow bundle (with decrypted secrets), shard-result, artifact upload, finalize. Cancel + GitHub App token/dispatch/resolve/cancel modules.
packages/runner: a CLI (fetch-flow/execute/finalize) with a Playwright engine implementing the flow-core adapter, plus thecharlie-run.ymlreusable workflow.- SPA: trigger runs, run list, and a run detail page with live SSE progress and an artifact viewer.
Phase 4 — Load engine (k6):
@charlie/flow-corecompiles a flow into a k6 HTTP scenario (goto→request,fill→form body,submit→POST,extractregex→response capture,waitFor(ms)→think-time,setHeader→headers; clicks/DOM asserts are surfaced as "not applicable in load mode").- Named profiles (
smoke/load/stress) supply stage/threshold presets; a flow'sloadProfileoverrides either. packages/runnerk6 engine: esbuild-bundles the k6 entrypoint with the compiled scenario baked in (k6 can't import Node at runtime), runsk6 run, parses the end-of-test summary, and posts aload_summary(p50/p95/p99, RPS, error rate, per-threshold pass/fail); thresholds decide pass/fail and name the breached metric.- SPA: a load report view with metric cards, a latency distribution chart, and threshold pass/fail.
The same flow definition runs as Playwright (Phase 3) or k6 (Phase 4) with no edits.
Phase 5 — Scheduling & triggers:
schedules(migration0005) withruns.schedule_idfor per-schedule history; run creation is factored into one path (createRun) shared by manual, cron, and merge triggers.- A Cloudflare Cron Trigger (once a minute) runs a sweep that selects due cron schedules, claims each with a conditional D1 update (so a tick fires exactly once even across overlapping invocations), creates a run, and advances
next_due_at; a small UTC cron parser computes due times. - A GitHub webhook receiver (
/webhooks/github) verifies theX-Hub-Signature-256HMAC, then matches apush/merged-pull_requeston a watched branch toon_mergeschedules (by the project'ssource_repo), creating a run tagged with the commit andtrigger = merge. - SPA: per-project schedule management — cron builder with presets, branch watcher, enable/disable, next-run display, and per-schedule run history.
Phase 6 — Slack integration:
integrations(migration0006) storing the single-workspace Slack app's bot token + signing secret AES-GCM encrypted at rest (never returned to a client);runs.slack_channel+ a per-project default channel as report targets./slack/command: verifies the Slack request (5-minute replay window +v0HMAC), acks within 3s, then does the work asynchronously — maps the Slack user to a Charlie user by verified email, gates on the sameruns.triggercapability as the web app (refusing + auditing otherwise), and creates atrigger = slackrun — replying viaresponse_url./slack/interactivityhandles the Re-run button.- Command grammar
run/e2e/load/status/last/help. - Post-run reporter: the Run Coordinator posts a Block Kit pass/fail message (flows passed, or load percentiles + breached threshold) with View report and Re-run buttons to the originating channel, and scheduled/merge runs report to the project's default channel.
- SPA: an Integrations settings page (connect/disconnect Slack) and a per-project default report channel.
Phase 7 — flow import & export (replaces the withdrawn AI flow-generation phase):
- Flows round-trip through a portable JSON document,
charlie.flows/v1(packages/flow-core/src/portable.ts). Export a project or a single flow (with itsuseFlowdependencies pulled in, so the file imports standalone); upload a document to create flows or add versions to existing ones. - The document references composed flows by name, not by database id — ids are meaningless in a file written elsewhere. Export rewrites ids → names; import resolves names → ids against the document and the target project.
- Uploads are untrusted: strict Zod parsing (unknown keys rejected with a JSON path), then document semantics (duplicate names, self-reference, code-flow reference, cycles), then a check against the target project (name clashes per
create/upsertmode,kindchanges, unresolvable references, cycles the import would close). Only then does it write — every flow and its audit rows in one atomic D1 batch. The UI always dry-runs first and shows the plan plus the environment secrets the steps expect. - Imported flows carry
origin = importand aflow.import_create/flow.import_versionaudit row naming the uploader. - No AI runs inside Charlie. The
ai_providers/ai_analyses/flow_draftstables, theai-analyzeworkflow, and the provider adapters were removed in migration0005. Teams that want a flow drafted by a model do it in their own harness with their own key; thecharlie-flow-jsonskill (npx skills add justraman/charlie --skill charlie-flow-json) teaches Claude Code or Codex the document format, and the resulting file is uploaded like any other. See docs/FLOW_IMPORT_EXPORT.md.
Composable flows (useFlow):
- A step flow can include another steps flow via a
useFlowstep (e.g. a sharedlogingroup run first). The control plane inlines the referenced flow's current steps into the run bundle recursively — with same-project, no-code-reference, and cycle guards — so engines still receive a flat step list and extracted vars/headers carry forward. See docs/TEST_ENGINES.md.
Custom Playwright code flows:
- A flow can be a
codeflow — a pointer (flow_versions.code_spec) to a GitHub repo of real Playwright tests — alongside the existingstepsflows (flows.kind, migration0003). - On a run, the runner clones the repo (with a short-lived, run-scoped GitHub App clone token minted into the bundle), installs its dependencies (package manager auto-detected from the lockfile), and runs
playwright test, injecting the environment asCHARLIE_BASE_URL/CHARLIE_HEADERSplus each environment secret under its own name (so an existing suite needs no renaming). Pass/fail comes from Playwright's JSON report; the report and traces upload to R2 via the same shard-result pipeline as step flows. - A worked template ships in
examples/playwright-custom-tests; the contract is documented in docs/CUSTOM_TESTS.md. Acharlie-playwrightClaude Code skill (npx skills add justraman/charlie --skill charlie-playwright, also surfaced in the flow-creation UI) teaches the model the env contract so AI-written tests run here unchanged.
See EXECUTION_PLAN.md for what's next (Phase 8: reporting depth, hardening & open-source release).
Requires Bun; the Wrangler CLI and Nx come in as dev dependencies. Charlie is an Nx-managed monorepo (apps/web, packages/flow-core, packages/runner): a single root package.json holds all dependencies, each project is defined by a project.json, and Nx runs/caches typecheck/test/build per project (nx affected scopes to what changed). Bun is the installer and runtime (no workspaces); the one cross-package import, @charlie/flow-core, resolves from source via TypeScript path mappings (for tsc and Bun) plus a wrangler/esbuild alias (for the Worker bundle). Biome is the linter/formatter, run repo-wide.
bun install
# Apply migrations to a local D1 database
bun run db:migrate:local
# Configure secrets for local dev
cp .dev.vars.example apps/web/.dev.vars # then fill in Google OIDC client id/secret + CHARLIE_KEK
# Run the Worker (SPA + API + local D1) on http://localhost:8787
bun run --cwd apps/web dev:worker
# Or run the Vite HMR dev server (proxies /api to the Worker) alongside it
bun run devSet ALLOWED_EMAIL_DOMAINS in apps/web/wrangler.toml (or via --var) so the first Google login can bootstrap the organization. Useful checks:
bun run typecheck # nx run-many -t typecheck (cached, all projects)
bun run test # nx run-many -t test (cached)
bun run build # nx run @charlie/web:build
bun run affected # only what changed vs main
bun run graph # interactive project graph
bun run lint # Biome (repo-wide)
# Run a single project's target directly:
bunx nx run @charlie/flow-core:testApache-2.0. See LICENSE.