Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

127 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

QAssist

Goal-based, adaptive browser testing you can watch run live. Give it a URL and a plain-English goal; an AI agent drives a real Chromium browser like a user, streams the session live, decides pass/fail, and produces a shareable PDF report.

Status: actively developed. Runs end-to-end and is deployed. See Roadmap for what's intentionally left for later.

What it does

  • Goal-based testing — no selectors or scripts. Describe the goal in English; the agent figures out the steps and adapts if the UI changes.
  • Watch it live — a real-time CDP screencast of the browser streams to the UI over a WebSocket while the test runs.
  • Pass/fail verdict — browser-use's built-in judge decides whether the goal was actually met, with a written rationale.
  • PDF report — a polished one-page report (verdict, stats, summary, session recording link) is generated automatically when a run finishes.
  • Trigger from anywhere — token-authed REST API to start runs and fetch results from your own tools or pipelines.

Stack

Layer Tech
Frontend React + Vite (live viewer)
API Express — REST + WebSocket relay
Agent Python + browser-use driving Playwright Chromium
Report HTML → PDF rendered by the same Chromium (embedded Bricolage Grotesque + IBM Plex Mono)
Model OpenAI (default gpt-4.1)
Packaging Single Docker image — ghcr.io/rszhd/qassist, docker compose up

How it works

┌──────────────┐   POST /api/runs    ┌──────────────────────────────┐
│ React viewer │ ──────────────────▶ │ Express (server/)            │
│ (frontend/)  │ ◀── WS /ws ──────── │  • spawns the Python agent   │
└──────────────┘   frames + events   │  • relays NDJSON → WebSocket │
                                      │  • renders PDF on completion │
                                      └───────────────┬──────────────┘
                                          spawn (1 per run) │ stdout: NDJSON
                                                            ▼
                                      ┌──────────────────────────────┐
                                      │ agent/run_agent.py           │
                                      │  • browser-use + Chromium    │
                                      │  • CDP screencast → "frame"  │
                                      │  • per-step → "step" events  │
                                      │  • final verdict → "done"    │
                                      └──────────────────────────────┘

Express spawns agent/run_agent.py as a child process per run. The script prints newline-delimited JSON to stdout — frame events (JPEG screencast, ~6 fps) plus step/done events — which Express relays over the WebSocket. The screencast is viewer-gated: Express tells the agent over stdin when the first viewer attaches and the last one leaves, and frames are only captured in between, so unwatched runs (e.g. CI-triggered) skip the encode cost entirely. On completion the server calls agent/make_report.py to render the PDF. The worker holds no durable state between runs.

Project layout

agent/                Python agent + report renderer
  run_agent.py        runs one browser-use test, emits NDJSON to stdout
  make_report.py      renders a run's JSON into a PDF (via Chromium)
  fonts/              embedded woff2 fonts (self-contained PDFs)
  requirements.txt
server/
  src/server.js       Express REST API + WebSocket relay + run registry
frontend/             React + Vite UI — Run view (live viewer) and Projects
Dockerfile            multi-stage: builds frontend, bundles Node + Python + Chromium
docker-compose.yml
.env.example

Run it

Docker is the only thing you need installed — Node, Python and Chromium all live inside the image.

Run a release (no clone, no build)

The fastest path: pull a published, tested image. Two files and one command, and you never see the source.

curl -O https://raw.githubusercontent.com/rszhd/qassist/main/docker-compose.release.yml
curl -o .env https://raw.githubusercontent.com/rszhd/qassist/main/.env.example
# generate the one secret .env needs, then start it:
sed -i "s/^KEY_ENCRYPTION_SECRET=$/KEY_ENCRYPTION_SECRET=$(openssl rand -hex 32)/" .env
docker compose -f docker-compose.release.yml up -d

Images are published to ghcr.io/rszhd/qassist on every version tag — :1.2.3 exactly, :1.2 to float on patches, and :latest. Pin the exact version; the file above does. Upgrading is editing that tag and re-running the command, and the schema migrates itself at boot.

Build from source

Clone the repo, then:

cp .env.example .env      # then generate its one required secret:
sed -i "s/^KEY_ENCRYPTION_SECRET=$/KEY_ENCRYPTION_SECRET=$(openssl rand -hex 32)/" .env
docker compose up --build

The first build takes a few minutes (it downloads Chromium). Either way, open http://localhost:8080, enter a URL + goal, and watch it run. Finished runs expose a Download PDF report button.

Two things worth knowing on a fresh clone:

  • Your OpenAI key goes in the app, not in .env. The agent is bring-your-own-key: open Settings → OpenAI key and paste yours — it is stored encrypted (that is what KEY_ENCRYPTION_SECRET is for) and every run you start is funded by it. Until then, starting a run answers 503 pointing you at Settings. The server holds no key of its own, so an instance you share can never spend your tokens on someone else's runs (US-039).
  • WORKER_API_TOKEN is optional for local use. Leave it unset and the API needs no token, so there's nothing to paste into the UI. The server logs a warning at startup because this leaves the port open to your network — set a token before exposing it beyond localhost.

Everything else, including the Postgres control plane, is wired up by docker compose; there is nothing else to configure.

Configuration

Set in .env (see .env.example):

Variable Default Purpose
WORKER_API_TOKEN Bearer token required on every API/WS call
BROWSER_USE_MODEL gpt-4.1 OpenAI model
MAX_CONCURRENT_SESSIONS 4 Concurrent browser cap — the real throttle. Rule: floor((RAM_GB − 1.5) / 1). Runs over the cap wait in an in-memory FIFO and are told their position live; the queue is not durable, so a restart marks everything still waiting error
MAX_STEPS 60 Safety ceiling on agent steps per run
MAX_RUN_MEMORY_MB 1600 Per-run process-tree RSS cap; over it the run is killed and marked failed. Summed RSS double-counts Chromium's shared pages — a recording run measures ~1177 MB here but only ~660 MB PSS (US-024)
PORT 8080 Express listen port
QA_RECORD 1 Record every session to runs/<runId>/recording.mp4. 0 disables it — frame capture is then skipped entirely while nobody is watching the run
ARTIFACT_RETENTION_DAYS 7 How long runs/<runId>/ (report PDF + mp4 recording) is kept. Swept at startup and every 6 h; the history row and its verdict are kept forever regardless. 0 = never prune
PUBLIC_BASE_URL Public address of this instance (https://qa.example.com). Only used to make the PDF report's "View recording" link resolvable; the recording is served either way
KEY_ENCRYPTION_SECRET Required. Encrypts stored OpenAI keys at rest. Generate once (openssl rand -hex 32) and keep it — losing it makes every stored key undecryptable
DATABASE_URL Required — Postgres control plane (saved tests, run history, and the users row a stored key lives on). Set for you in both paths — docker compose points it at its own db service, npm run dev at the same container on localhost:5433. Without it the server refuses to boot
RESEND_API_KEY Resend key for result email. Unset (or MAIL_FROM unset) = notifications off: prefs still save, nothing sends. /api/health reports this as mail
MAIL_FROM Sender address, on a domain verified with Resend (QAssist <qa@example.com>)
NOTIFY_EMAILS Comma-separated fallback recipients, used when a project names none
NOTIFY_MODE failure Default for tests in no project — one of failure, always, never. failure covers anything that is not a pass, including a run that ended unjudged. Projects carry their own mode
NOTIFY_SECRET WORKER_API_TOKEN Signs unsubscribe links. Falls back to a per-boot random value if the token is blank too, which invalidates links already mailed
OPERATOR_EMAIL operator@qassist.local Seeds the single account row, and is the last-resort recipient after NOTIFY_EMAILS. The default is not a deliverable address
STRIPE_SECRET_KEY
STRIPE_WEBHOOK_SECRET
STRIPE_PRICE_ID
Subscription billing (see Billing). All blank = billing off, every run free — the self-host default. Also needs PUBLIC_BASE_URL, the control plane and AUTH_ENABLED; missing any one leaves the instance free. /api/health reports this as billing
BILLING_EXEMPT_EMAILS OPERATOR_EMAIL Comma-separated accounts that run without subscribing

Per-run artifacts (screenshots, recording.mp4, report_data.json, report.pdf) are written to runs/<runId>/ and persisted via the ./runs volume. Durable metadata — saved tests, suites, run verdicts — lives in Postgres (pgdata volume); schema and rationale in db/README.md.

The two have different lifetimes on purpose: a history row is a few hundred bytes and is kept forever, while the directory beside it is tens of MB and is deleted after ARTIFACT_RETENTION_DAYS. A pruned run keeps its verdict, timings and step count, and simply stops offering the report and recording.

A handful of further variables — APP_HOST, ACME_EMAIL, QASSIST_IMAGE, RUNS_DIR, ROBOTS_TAG — are read only by the production overlay and are documented in DEPLOY.md. A plain docker compose up ignores them entirely.

API

# start a run
curl -X POST http://<host>:8080/api/runs \
  -H "Authorization: Bearer $WORKER_API_TOKEN" -H "Content-Type: application/json" \
  -d '{"goal":"Search for a laptop and add the first result to cart","start_url":"https://example.com"}'
# -> {"runId":"...","status":"running"}

# poll status + result (the history row's columns, plus runId/result/status
# for polling; the run's own page is http://<host>:8080/runs/<runId>)
curl http://<host>:8080/api/runs/<runId> -H "Authorization: Bearer $WORKER_API_TOKEN"

# download the PDF report (202 while generating, 200 when ready)
curl -L http://<host>:8080/api/runs/<runId>/report.pdf \
  -H "Authorization: Bearer $WORKER_API_TOKEN" -o report.pdf

# download the session recording (mp4; 404 if the run wasn't recorded).
# Supports range requests, and — alone among the endpoints — ?token=<token>
# instead of the header, so a <video> element can stream it directly.
curl -L http://<host>:8080/api/runs/<runId>/recording \
  -H "Authorization: Bearer $WORKER_API_TOKEN" -o recording.mp4

# health
curl http://<host>:8080/api/health

# live feed: ws://<host>:8080/ws?runId=<runId>&token=<WORKER_API_TOKEN>

Saved tests, projects and modules

Needs the Postgres control plane (DATABASE_URL); without it these answer 503 and ad-hoc runs above still work.

A saved test is the reusable unit. Grouping is optional: a test can sit in a project, and within it in at most one module (auth, payment, …). A suite is the cross-cutting alternative — an arbitrary many-to-many selection inside one project, so the same test can be in smoke and nightly. Projects, modules and suites are all runnable in one call.

# save a test, then run it (start_url is overridable per run — point CI at a
# fresh preview deploy without editing the test)
curl -X POST http://<host>:8080/api/tests \
  -H "Authorization: Bearer $WORKER_API_TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"login smoke","goal":"log in and see the dashboard","start_url":"https://example.com"}'
curl -X POST http://<host>:8080/api/tests/<testId>/run \
  -H "Authorization: Bearer $WORKER_API_TOKEN" -H "Content-Type: application/json" \
  -d '{"start_url":"https://preview.example.com","trigger":"ci"}'

# organize: a project, a module in it, then file the test under the module
# (project_id is derived from the module — you never set both)
curl -X POST http://<host>:8080/api/projects \
  -H "Authorization: Bearer $WORKER_API_TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"Checkout"}'          # -> {"id":"...","slug":"checkout",...}
curl -X POST http://<host>:8080/api/projects/checkout/modules \
  -H "Authorization: Bearer $WORKER_API_TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"Auth"}'              # -> {"id":"...","slug":"auth",...}
curl -X PUT http://<host>:8080/api/tests/<testId> \
  -H "Authorization: Bearer $WORKER_API_TOKEN" -H "Content-Type: application/json" \
  -d '{"module_id":"<moduleId>"}'

# run a whole module or project. Paths take a slug or a uuid, so CI configs
# don't have to carry ids; one run is started per member test.
curl -X POST http://<host>:8080/api/projects/checkout/modules/auth/run \
  -H "Authorization: Bearer $WORKER_API_TOKEN" -H "Content-Type: application/json" \
  -d '{"trigger":"ci"}'
# -> {"moduleId":"...","runs":[{"runId":"...","testId":"...","status":"queued"}, ...]}

# list/filter: ?project_id=<id>, ?module_id=<id>, or project_id=none (Ungrouped)
curl "http://<host>:8080/api/tests?project_id=<projectId>" \
  -H "Authorization: Bearer $WORKER_API_TOKEN"

Suites work the same way but belong to a project, and their members must too:

curl -X POST http://<host>:8080/api/suites \
  -H "Authorization: Bearer $WORKER_API_TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"smoke","project_id":"<projectId>","test_ids":["<id>","<id>"]}'
curl -X POST http://<host>:8080/api/suites/<suiteId>/run \
  -H "Authorization: Bearer $WORKER_API_TOKEN"

Deleting a module or project never deletes tests — they fall back to Ungrouped. Deleting a project does take its suites with it.

Schedules

POST /api/schedules runs any of those four things on a repeating slot. The schedule names exactly one target — test_id, module_id, suite_id or project_id — and fires the same way the matching /run endpoint does: one run per member test, queued behind MAX_CONCURRENT_SESSIONS like any other.

# every 6 hours: slots are anchored to local midnight, so this fires at
# 00:15 / 06:15 / 12:15 / 18:15 in the schedule's own timezone
curl -X POST http://<host>:8080/api/schedules \
  -H "Authorization: Bearer $WORKER_API_TOKEN" -H "Content-Type: application/json" \
  -d '{"suite_id":"<suiteId>","kind":"hourly","interval_hours":6,"minute":15,
       "tz":"Europe/Berlin"}'
# -> {"id":"...","kind":"hourly","interval_hours":6,"enabled":true,
#     "next_run_at":"2026-07-23T04:15:00.000Z", ...}

# nightly, and weekly on a Tuesday
-d '{"test_id":"<testId>","kind":"daily","hour":2,"minute":30,"tz":"Europe/Berlin"}'
-d '{"project_id":"<id>","kind":"weekly","weekday":2,"hour":9,"tz":"Europe/Berlin"}'

# list (filter by any target column), pause, re-time, remove
curl "http://<host>:8080/api/schedules?suite_id=<suiteId>" -H "Authorization: Bearer $WORKER_API_TOKEN"
curl -X PUT http://<host>:8080/api/schedules/<id> -H "Authorization: Bearer $WORKER_API_TOKEN" \
  -H "Content-Type: application/json" -d '{"enabled":false}'
curl -X DELETE http://<host>:8080/api/schedules/<id> -H "Authorization: Bearer $WORKER_API_TOKEN"

kind is hourly (with interval_hours ∈ 1, 2, 3, 4, 6, 8, 12), daily (hour/minute) or weekly (plus weekday, 0 = Sunday). tz is an IANA name and defaults to the server's; times mean wall-clock time there, so a daily slot keeps its hour across a DST change. Every write recomputes next_run_at, which is what the scheduler claims each minute.

A slot fires once even if the server was down for several — missed slots are not replayed. A test with a run still queued or running is skipped for that slot while its siblings in the same suite go ahead. Deleting the target deletes its schedules.

From CI/CD

A pipeline triggers a module or a suite — the set of tests that covers a change — passing the fresh preview URL as start_url, then polls each run and fails the job unless every one comes back passed. That's curl plus a poll loop, no Action and no plugin: docs/ci.md has the script and ready-made GitHub Actions and GitLab CI jobs.

Run history

GET /api/runs lists finished and in-flight runs newest first, from the same control plane (503 without DATABASE_URL). Every row carries the test's name and grouping, so a history table renders from one request.

# filters combine: test_id, project_id, module_id, status (comma-separated),
# since/until (ISO timestamps on created_at), limit (≤200) and offset
curl "http://<host>:8080/api/runs?test_id=<testId>&status=failed,error&limit=20" \
  -H "Authorization: Bearer $WORKER_API_TOKEN"
# -> {"runs":[{"id":"...","status":"failed","test_name":"login smoke",
#              "success":false,"created_at":"...","has_recording":true, ...}],
#     "total":37,"limit":20,"offset":0}

total is the unpaginated count, for paging. A run's project is its test's, reached by join — so a run whose test was later deleted keeps its history row (goal and start_url were copied at enqueue time) but matches no project filter. Once retention prunes runs/<id>/, artifacts_deleted_at is set and the row reports no recording or report while the verdict survives.

Email notifications

Off unless RESEND_API_KEY and MAIL_FROM are both set — GET /api/health answers mail so you can tell "not configured" from "nothing failed yet". Prefs live on the project, so one recipient list covers every test in it:

# who hears about this project's runs, and when.
# notify: failure (default) | always | never — "failure" is anything that is
# not a pass, so an errored or unjudged run mails too.
curl -X PUT http://<host>:8080/api/projects/checkout \
  -H "Authorization: Bearer $WORKER_API_TOKEN" -H "Content-Type: application/json" \
  -d '{"notify":"failure","notify_emails":["qa@example.com","lead@example.com"]}'

Prefs are settable on PUT only, never on create. An empty notify_emails falls through to NOTIFY_EMAILS, then to OPERATOR_EMAIL. Each finished run decides for itself, one mail per recipient with the PDF attached; a run started ad-hoc from the Run view never mails, having no test and no project.

Every mail carries a signed unsubscribe link — the one route in the app that takes no bearer token, because the person clicking it was mailed a report and does not have the instance's token. Suppression is by address and instance- wide, so being added to a second project cannot quietly re-subscribe someone:

# who has opted out, and putting one back
curl http://<host>:8080/api/notifications/suppressions \
  -H "Authorization: Bearer $WORKER_API_TOKEN"
curl -X DELETE http://<host>:8080/api/notifications/suppressions/qa@example.com \
  -H "Authorization: Bearer $WORKER_API_TOKEN"

Billing

Off unless you turn it on, and self-hosting is always free. With STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET and STRIPE_PRICE_ID unset there is no billing UI, no gating and no /api/billing — the instance behaves as if this feature did not ship. GET /api/health answers billing either way.

Turning it on gates starting a run and nothing else: an account without an active subscription gets 402 from every run trigger, and its schedules stop firing (they are never deleted, and resume on resubscribe). Reading stays open — history, run detail, steps, reports and recordings all keep working, so cancelling is never a data-loss event. LLM tokens remain BYOK on every tier: a subscription pays for hosting, not for the model.

It also requires AUTH_ENABLED and PUBLIC_BASE_URL. Billing charges users, so an instance with no real users — single-token or open — is never gated, and Stripe needs somewhere to send the customer back to.

active and trialing may run. past_due keeps running until current_period_end, because Stripe retries a declined card for around two weeks and cutting off a paying customer on the first failed retry is the worse bug. canceled, unpaid, incomplete and "never subscribed" are refused.

Only one plan exists: one recurring Stripe price, taken through Checkout, with changes and cancellation handled by the Stripe Customer Portal. There is no payment UI of our own, and no stripe dependency — the integration is three form-encoded POSTs and one HMAC.

Testing it locally (Stripe test mode — nothing here touches live money):

# 1. a test-mode price to sell, and the endpoint's signing secret
stripe login
stripe listen --forward-to localhost:8080/api/billing/webhook   # prints whsec_…

# 2. .env: the test keys, plus billing's other preconditions
#    STRIPE_SECRET_KEY=sk_test_…   STRIPE_WEBHOOK_SECRET=whsec_…
#    STRIPE_PRICE_ID=price_…       PUBLIC_BASE_URL=http://localhost:8080
#    AUTH_ENABLED=1                SESSION_SECRET=…   DATABASE_URL=…

# 3. sign in, hit Subscribe in Settings, pay with Stripe's test card
#    4242 4242 4242 4242, any future expiry, any CVC.

# 4. drive the rest of the lifecycle without waiting for a renewal
stripe trigger customer.subscription.deleted    # → runs start returning 402

stripe listen is not optional for local work: the webhook is what records the subscription, and it authenticates by signature over the exact bytes Stripe sent — so it cannot be faked with a hand-rolled curl.

Local development

This section is for working on QAssist. To just run it, see Run it above — that path needs Docker only.

Developing needs Node 22+ and Python 3.11+ on the host, plus Docker: the dev server starts the Postgres control plane as a container for you (see below), so Docker has to be running even when you aren't using the full stack.

One-time setup:

cp .env.example .env      # set WORKER_API_TOKEN and KEY_ENCRYPTION_SECRET

# agent venv (browser-use + Playwright Chromium); needs python3-venv or uv
cd agent && uv venv .venv && uv pip install -r requirements.txt --python .venv/bin/python \
  && .venv/bin/playwright install chromium
# without uv: python3 -m venv .venv && .venv/bin/pip install -r requirements.txt && ...

cd ../server && npm install
cd ../frontend && npm install

Then run both dev servers, each with hot reload and logs in the foreground:

# terminal 1 — API on :8081 (node --watch; loads ../.env, uses agent/.venv;
# auto-starts the compose Postgres on 127.0.0.1:5433 and connects to it)
cd server && npm run dev
# terminal 2 — frontend on :5173 (Vite HMR; proxies /api and /ws to :8081)
cd frontend && npm run dev

Open http://localhost:5173 and paste your WORKER_API_TOKEN. Dev defaults to port 8081 so it can't collide with the Docker stack on 8080; override with PORT=<p> npm run dev (server) and API_PORT=<p> npm run dev (frontend), keeping the two in sync.

The dev database

npm run dev runs docker compose up -d --wait db first, so Postgres is already healthy by the time the server boots; pending migrations in db/migrations/ are then applied automatically. You don't set DATABASE_URL — the dev script defaults it to the container on 127.0.0.1:5433. The control plane is required (US-039): if Postgres isn't reachable the server refuses to boot and says so, rather than serving a half-app.

  • Port 5433 already taken? It's mapped that way to avoid colliding with a local Postgres on 5432. To use a different database entirely, pass the URL in the shell — DATABASE_URL=postgres://… npm run dev — not via .env, since the script's default is applied after .env is read.
  • Reset the data: docker compose down -v drops the pgdata volume; the next npm run dev recreates the schema from scratch.
  • npm test needs none of this — the control-plane tests run the real migrations against an in-memory Postgres (pg-mem).

Deployment

Runs as a single container (docker compose up -d) on any Linux host with Docker (4 vCPU / 8 GB is comfortable for ~4 concurrent sessions). The app listens on port 8080.

To put it on a public hostname over HTTPS, layer the production overlay on the same base file — it adds a Traefik reverse proxy with automatic Let's Encrypt certificates and stops publishing 8080 on the host:

docker network create qassist-edge
docker compose -p qassist-proxy -f docker-compose.proxy.yml up -d
docker compose -p qassist -f docker-compose.yml -f docker-compose.prod.yml up -d

The runbook — DNS, the .env values the overlay reads, verifying the WebSocket, deploying a new tag, and the certificate store — is DEPLOY.md.

Roadmap

Planned work lives in backlog/ — one file per user story, organized by sprint folder (sprint/current/, sprint/next/, unscheduled/, released/), with status, dependencies, and acceptance criteria. Finished stories move into sprint/<name>/done/, so the sprint folder itself always lists exactly the work that is left.

Current sprint — the self-host release (in backlog/sprint/current/). Shipped and in the app today:

  • Control plane (Postgres) — saved tests & suites (US-009), projects & modules (US-023), run history (US-011), scheduled runs (US-010), failure email notifications (US-012), and a page per run (US-030).
  • Session recording — an MP4 per run (US-006).
  • Registration-flow verification — email-confirmation signups (US-013).
  • Subscription billing for a hosted tier — Stripe Checkout and the Customer Portal, entirely env-gated (US-022); see Billing. Self-hosting stays free: with STRIPE_* unset there is no billing UI and no gating.

Left before the release is cut:

  • Public HTTPS — Caddy on 443 terminating TLS; unblocks CI/CD (US-007).
  • CI/CD trigger — GitHub/GitLab run a module or a suite via a documented curl step (US-008, needs US-009); a reusable Action and a GitHub App are a later story (US-029).
  • A licensed, public repo — AGPL-3.0 (US-031) and a CI pipeline that publishes a versioned image per tag, so self-hosting is a pull rather than a build (US-032). Next sprint (in backlog/sprint/next/): a report with per-step screenshots and the session recording (US-020).

Later (backlog/unscheduled/): SMS/social registration tiers, PR status checks, scaling to ~100 concurrent sessions (US-015), and a possible desktop app.

Notes

  • The worker is stateless per run — durable state belongs in the control plane above.
  • How the open-source repo relates to the paid hosted tier (and the future private cloud repo): docs/repo-model.md.
  • Secure it before exposing publicly: always behind HTTPS, always with the token.
  • Some sites (Reddit, Cloudflare-heavy pages) block datacenter IPs and will fail from a server — expected, not a bug.

Contributing

Patches welcome. CONTRIBUTING.md covers getting the stack running, which test suite to run for what, the house style, and the one procedural ask — a DCO Signed-off-by line, which git commit -s adds for you.

License

AGPL-3.0-only. In plain terms:

  • Self-hosting is free, for anything, forever — personal or commercial, no seat count, no feature gate, no key to buy. Running QAssist is not what the licence asks anything about. Model tokens are bring-your-own on every tier, so the only bill is the one you already have with your LLM provider.
  • Modify it and run your version freely. The obligation attaches to distribution — and, because this is the AGPL rather than the GPL, to offering your modified version to others as a network service. Do that, and those users are entitled to your source under the same licence.
  • That is the whole reason for the A: it keeps a competitor from taking this code, running it as a closed hosted product, and giving nothing back — while leaving every actual self-hoster completely unrestricted.

Contributions stay under the same licence and you keep your copyright — DCO, not a CLA.

A paid hosted tier at qassist.run is planned, and it runs this codebase — not a fork of it, and not a more capable private sibling. Even the Stripe billing is here, env-gated: with STRIPE_* unset, which is the self-host default, there is no billing UI and no gating at all. Payment there covers hosting; it buys no feature you don't have here.

A private repo exists for the commercial layer around that deployment, and the boundary is a deliberate constraint rather than a place features go to disappear: the default is public, and only what is meaningless without our billing or our infrastructure may live on the private side. Dependencies run one way — that repo consumes this one's published image and its token-authed API, and this repo never learns it exists. The rules, and the routing test applied to every new feature, are in docs/repo-model.md.

About

Goal-based, adaptive browser testing you can watch run live. Give it a URL and a plain-English goal; an AI agent drives a real Chromium browser like a user, streams the session live, decides pass/fail, and produces a shareable PDF report.

Resources

Contributing

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages