# Dungbeetle > Dungbeetle is a snapshot and visual regression testing tool — a free CLI plus self-hostable cloud — built for AI agents and the humans they work for. # Introduction Dungbeetle is a **snapshot and visual regression testing tool** — a free CLI plus a self-hostable cloud — built for AI agents and the humans they work for. It is the regression safety net for anything you ship: web, desktop, terminal, APIs, games. Zero adoption cost, runs anywhere. It captures your app's output as stable, reviewable JSON and produces **semantic diffs** instead of brittle pixel comparisons. Output is normalized (timestamps, UUIDs, and temp paths are masked) so baselines stay readable and survive cosmetic churn. ## Why Dungbeetle Most snapshot tools force a choice between two extremes: brittle pixel diffs that break on a one-pixel anti-aliasing shift, or unstructured text dumps that are impossible to review. Dungbeetle takes a third path — it normalizes each kind of output into deterministic JSON and compares the **meaning**: - A terminal color change shows up as a styling diff, not a wall of escape codes. - A renamed button is a single changed attribute, not a remove-plus-add cascade. - A screenshot that shifts within tolerance simply passes. The result is a baseline you can read in a pull request and a diff that tells you *what changed*. ## What it captures - 🖥️ **[Terminal](/capture-types/terminal)** — command output with ANSI normalization. - 🌐 **[Web](/capture-types/web)** — structured DOM snapshots (`url`/`html` fetch by default, or a `playwright` driver when a browser is available). - ⚡ **[Performance](/capture-types/performance)** — baselines from k6 metrics, compared with numeric tolerance. - 🪟 **[Desktop](/capture-types/desktop)** — accessibility-tree snapshots (role/name/state), with an experimental native macOS driver (`driver: "macos-ax"`). - 🔌 **[API](/capture-types/api)** — REST/GraphQL response snapshots (status, headers, JSON body) with structural diffs and numeric tolerance. - 🔎 **[Check](/capture-types/check)** — snapshots of a tool's report on your app (routes, schema, scheduled jobs, static analysis) as a stable keyed record. - 🎮 **[Game](/capture-types/game)** — scripted walkthroughs snapshotting semantic game state at named markers, deterministic and headless, with optional per-marker screenshots (Godot 4.x first). - 🎭 **Shared masking** for dynamic values, and stable JSON diffs you can review in a pull request. ## Works with your AI agent Coding agents change output fast; Dungbeetle catches what they break. The [MCP server](/mcp/) lets Claude Code, Cursor, Codex, or Gemini CLI list recent runs, read the semantic diff of a failure, and — with your confirmation — approve and promote baselines. Agents authenticate with [scoped, revocable tokens you mint and own](/mcp/auth); a human always owns the account and the final approval. ## How baselines work Baselines are committed under `dungbeetle.snapshots/` so changes show up in code review. An optional [self-hostable cloud server](/cloud) stores runs and baselines centrally when you'd rather not commit them. ## Vocabulary - **Capture** — the act of recording a target's current output. - **Snapshot** — the artifact a capture produces. - **Baseline** — the approved snapshot new output is compared against. - **Run** — one CLI execution and its report (every result from one `test`/`ci`). - **Repository** — the unit the cloud organizes around: one codebase, its baselines, and its runs. - **Walkthrough** — a scripted flow through a game (inputs, waits, assertions) that a `game` target replays deterministically. - **Marker** — a named point in a walkthrough where state (and optionally a screenshot) is captured; markers key the snapshot and the review UI. ## Next steps - [Install Dungbeetle](/guide/installation) and check your environment. - Follow the [quick start](/guide/quick-start) to capture your first baseline. - Learn the [configuration](/configuration/) format. - Building **on** Dungbeetle? The CLI's developer docs (architecture, adding a capture type) live in the CLI repository. --- # Installation ## Requirements - **Node.js 22 or newer.** The cloud server uses the built-in `node:sqlite`, which needs Node ≥ 22.5 (see the [cloud server guide](/cloud)). - No native build step and no external services for the CLI. Some capture types have optional, opt-in dependencies: - **Web (Playwright driver)** needs a Chromium browser — see [Web snapshots](/capture-types/web#playwright-driver). The default `fetch` driver needs nothing. - **Performance** needs [k6](https://k6.io) installed and on `PATH` — see [Performance baselines](/capture-types/performance). ## Install ```sh npm install -g dungbeetle # global `dungbeetle` binary ``` Or per-project: ```sh npm install --save-dev dungbeetle && npx dungbeetle --help ``` ## Verify ```sh dungbeetle --version dungbeetle --help ``` Once you have a `dungbeetle.config.json`, run [`dungbeetle doctor`](/cli/local#doctor) to validate your config, paths, targets, and optional browser setup before capturing baselines. ## Next Head to the [quick start](/guide/quick-start) to scaffold a config and capture your first baseline. --- # Quick start Three commands take you from nothing to a baseline that runs in CI. ```sh dungbeetle init # scaffold dungbeetle.config.json dungbeetle update # capture targets and write the first baselines dungbeetle test # compare current output against the baselines ``` ## What each step does 1. **`dungbeetle init`** writes a starter [`dungbeetle.config.json`](/configuration/) in the current directory. 2. **`dungbeetle update`** captures every configured target and writes baselines under `dungbeetle.snapshots/`. **Commit these** — they're the reviewable record of your app's output. 3. **`dungbeetle test`** captures current output, compares it against the baselines, and exits non-zero on any difference — so it drops straight into CI. JSON/HTML reports are written to the paths you pass via `--json` / `--html`. A failing web test shows a semantic DOM diff by default; before/after screenshots are embedded too when they were captured (the Playwright driver — the default `fetch` driver does DOM-only, no browser). ## First-run behavior If baselines don't exist yet, `dungbeetle test` fails with `missing` results and tells you which baseline path is absent. Run `dungbeetle update` to create them, then `dungbeetle test` passes on unchanged output. The [report statuses](/cli/#report-statuses) are `updated`, `passed`, `failed`, `missing`, and `error`. ## In CI Use [`dungbeetle ci`](/cli/local#ci) for machine-readable output: ```sh dungbeetle ci --json report.json --html report.html ``` Add `--json-only` to suppress HTML and console output when you only need the JSON. ## Working with a subset of targets Pass `--target ` to `update`, `test`, or `ci` to work with one or more named targets instead of the whole suite: ```sh dungbeetle update --target home nav dungbeetle test --target home ``` ## Try the examples The repository ships runnable examples under [`examples/`](https://github.com/DungbeetleTech/client/tree/main/examples) — `demo`, `perf`, `desktop`, and `p1/playwright`. They include committed baselines, so `test` and `ci` pass without a prior `update`: ```sh dungbeetle update --config examples/demo/dungbeetle.config.json # refresh baselines dungbeetle test --config examples/demo/dungbeetle.config.json # compare ``` ## Next - Follow the full [walkthrough](/guide/walkthrough) — local, commit-to-repo, and cloud review in one pass. - Learn the full [configuration format](/configuration/). - Pick a capture type: [Terminal](/capture-types/terminal), [Web](/capture-types/web), [Performance](/capture-types/performance), [Desktop](/capture-types/desktop). - Store baselines and runs centrally with the [cloud server](/cloud). --- # Walkthrough One end-to-end path through Dungbeetle, in three stages you can adopt in order: run it **locally** on your machine, **commit snapshots to your repo** and review changes in pull requests, then graduate to the **cloud** for hosted review and analytics. Every command works as written. ## Local Install Dungbeetle, capture a baseline, introduce a regression, and read the semantic diff — no account, entirely on your machine. ```sh # Install Dungbeetle (global) npm install -g dungbeetle # Install Dungbeetle (project) # npm install --save-dev dungbeetle ``` ### Setup Create the config (see [Configuration](/configuration/) for more details) ```sh # Initialise dungbeetle init # Capture baseline dungbeetle update # Test for differences dungbeetle test ``` ### Preview local report ```sh # … change the page / command / fixture your target captures dungbeetle ci --json report.json --html report.html open report.html ``` For a complete, copy-paste walkthrough in a real project — with screenshots of the diff report at each step — follow one of the **Examples**: [Laravel](/examples/laravel), [VitePress](/examples/vitepress), [Python](/examples/python), or [Go](/examples/go). ## Commit snapshots to the repo for manual review The lightest team workflow keeps baselines in your repository and reviews changes the same way you review code — no cloud account. 1. Commit the baseline directory the CLI writes: ```sh dungbeetle update git add dungbeetle.snapshots/ git commit -m "Add Dungbeetle baselines" ``` 2. In CI, run `dungbeetle ci` on every push. A regression fails the job and writes a semantic-diff report you can publish as a build artifact: ```sh dungbeetle ci --json report.json --html report.html ``` 3. When a diff is intentional, run `dungbeetle update`, commit the changed files under `dungbeetle.snapshots/`, and the **baseline change shows up in the pull request** — reviewers approve it alongside the code. Because the diff is a readable node-level change, not a binary blob, it reviews like any other source change. This is the whole workflow for many teams. Reach for the cloud only when you'd rather not commit baselines, or want hosted review and analytics (next). ## Review & approve with analytics Use the Dungbeetle cloud server to store results centrally and review diffs in a browser — without committing baselines. This uses the **managed cloud** (currently in [closed beta](/cloud#getting-access)); running the server on your own infrastructure is an [enterprise self-hosting](/guide/self-hosting) option. ### 1. Create an account Open your Dungbeetle cloud (the managed URL you were given for the beta; enterprise self-hosters use their own server, e.g. `http://localhost:4317`) and register — email + password, or **Continue with Google / GitHub**. Signup creates your **Personal** team automatically; the header breadcrumb's team dropdown switches between teams if you later create or join more. ![The Dungbeetle register page: an email and password form alongside Google and GitHub sign-in buttons.](/walkthroughs/cloud-register.png) ### 2. Create a repository On the **Repositories** page (`/ui/repos`), choose **New repository** (for the active team) to mint a `client_id` / `client_secret`. The secret is shown **once** — copy it now (the page also prints the CI environment variables to set). ![A linked repository revealing its one-time client secret and the CI environment variables to set.](/walkthroughs/cloud-link-repo.png) ### 3. Push a run from CI (or locally) ```sh dungbeetle ci --json .dungbeetle/ci-report.json --with-snapshots --json-only DUNGBEETLE_CLIENT_ID=cid_… DUNGBEETLE_CLIENT_SECRET=csec_… \ dungbeetle push --report .dungbeetle/ci-report.json --branch main --commit "$GIT_SHA" ``` `--with-snapshots` ships candidate snapshots so an approved run can be **promoted** to new hosted baselines (step 4). The CLI pushes to the hosted cloud by default; self-hosting? Point it at your server with `--server ` or `DUNGBEETLE_SERVER_URL`. ### 4. Review and approve Open the run and read the per-target **semantic** diff — a node-level text change, not a re-rendered pixel blob. Targets with screenshots also get a **visual comparison**: before/after side by side, or an onion-skin overlay with a blend slider; API targets show their JSON-path diff the same way. Then record an approve/reject decision. The audit trail is append-only; approving with **Promote** turns the candidate snapshots into new hosted baselines. ![A failed run's review page: before/after/diff screenshots with a side-by-side / onion-skin toggle, the semantic diff (a text change from $42.00 to $58.00), and the review form with approve and promote controls.](/walkthroughs/cloud-review.png) Once you've decided, the review form gives way to your entry in the PR-style **timeline** — its **Edit decision** disclosure reopens the same form, pre-filled, and submitting appends a superseding decision (history is never rewritten). ![The same run after approving: the review form is gone, the timeline shows the recorded decision with its promotion note, and the open Edit decision disclosure carries the pre-filled superseding form.](/walkthroughs/cloud-review-decided.png) _Screencast — from the home page: sign in → open the failed run → read the diff → approve and promote → the timeline takes over, ready to edit the decision._ ### 5. Check analytics Visit a repository's **Analytics** for pass rate, trend, and per-target flakiness. ![The analytics page: total runs, pass rate and failed-run counts, a trend chart, and a per-target flakiness table.](/walkthroughs/cloud-analytics.png) ## Next steps - **Web (Playwright) target** — configure a `web` target, mask a dynamic element, approve a baseline. See [Web capture type](/capture-types/web). - **Terminal target** — capture stdout/stderr with ANSI normalization. See [Terminal capture type](/capture-types/terminal). - **CI integration** — wire `dungbeetle ci` into your CI pipeline and read the report artifact. - **Enterprise self-hosting** — run the Dungbeetle cloud on your own infrastructure (an enterprise offering). See [Self-hosting](/guide/self-hosting). --- # Cloud server The Dungbeetle cloud server is an authenticated API with a server-rendered review UI, backed by SQLite (via the built-in `node:sqlite`, no native build), that lets teams run in CI and store results centrally **without committing baselines to the repo**. It's available as a managed service (currently in closed beta) or — for enterprise — self-hosted. Nothing about authoring tests changes: you run `dungbeetle test` / `ci` exactly as before, then push the resulting report. ::: tip Node requirement The server needs Node ≥ 22.5 for `node:sqlite`. On Node 22.x the engine is behind `--experimental-sqlite` (set by the `start` script); on Node 24 it works out of the box with an experimental warning. ::: ## Getting access The Dungbeetle cloud is a **managed service, currently in closed beta** — we're busy with some testing, and access is limited to allowlisted email addresses. [Request access](https://dungbeetle.dev/ui/request) and we'll notify you by email when your access is ready. Once your address is allowlisted, sign up (which creates your **Personal** team), then use **New repository** on the Repositories page (`/ui/repos`) for the active team to mint a `client_id` / `client_secret` pair for CI (the secret is shown once — copy it immediately). To run the server on your **own infrastructure** instead, see [Enterprise self-hosting](/guide/self-hosting) — it's available to enterprise customers on request, not as a public self-serve deploy. ## How auth works Visitors **sign up and sign in by themselves**: email + password (with email verification and password reset) or OAuth — **Google** and **GitHub** — when those providers are configured (see [Configuration](#configuration)). New email accounts can use Dungbeetle immediately and are nudged to verify; OAuth accounts are verified by the provider. Passwords are stretched with scrypt; only the hash is stored. The API-token path below remains for CI and operator/scripted access. Under the hood it's a two-part model: - A **user API token** (minted per user, stored as a SHA-256 hash) authenticates a person to the web UI. - Repositories belong to a **team** (the billing account), and members of that team can access them — access is by team membership, not 1:1 user ownership. Creating a repository mints a `client_id` / `client_secret` pair that CI uses to push (`DUNGBEETLE_CLIENT_ID` / `DUNGBEETLE_CLIENT_SECRET`). Everything a request can read or write is scoped to a repository. - **AI agents** (Claude Code, Cursor, Codex, Gemini CLI, the MCP server) don't get accounts or the CI pair — a human mints them a **scoped agent token** (`dbat_…`) via `dungbeetle login` / `/ui/connect`, bound to one repository and revocable at `/ui/agents`. See [Agent authentication](/mcp/auth). The web UI signs in by exchanging an API token for an opaque, expiring session (the token itself never enters the browser), enforces a strict nonce-based CSP, validates request origin on state-changing actions (CSRF), and throttles failed sign-in / API auth attempts. ## Teams A **team** is the unit that owns repositories and carries the plan and billing. Every user gets a **Personal** team on signup, and can create more. Every authenticated page carries a **breadcrumb** top-left in the header — the active team and, on repository pages, the repository, each segment with a switcher dropdown. The active team scopes what you see — the Repositories page (`/ui/repos`) lists that team's repositories and the Usage page (`/ui/usage`) shows that team's usage and plan. Each team has its own page (the breadcrumb's team name, or **Settings → Teams**) tabbed like a repository: **Team** (recent activity, latest failures, and recent repositories with an add control), **Repos** (all of the team's repositories), **Members** (where owners and admins manage roles and **invite people by email** — the invitee accepts via an emailed link to join), **Usage** (the team's usage meters), and **Settings** (upload a logo, rename the team; an IP allow-list is coming soon). Switching and deleting stay on Settings → Teams; the Personal team can't be deleted, and a team must have no repositories before it can be. Signing in lands on **Home** (`/ui/home`) — the active team's **Team** tab. Every other global destination lives in the header's **avatar menu**: Home, Repositories, Usage, Roadmap, and Docs, then Profile, **Settings** (`/ui/settings`, with **Profile** name/email, **Account** password / connected OAuth accounts / account deletion, **Teams**, and **API tokens** sections in a side-nav), and Sign out. ## Push runs and baselines Produce a report as usual, then upload it with the repository's credentials: ```sh dungbeetle ci --config dungbeetle.config.json --json .dungbeetle/ci-report.json --json-only DUNGBEETLE_CLIENT_ID=cid_… DUNGBEETLE_CLIENT_SECRET=csec_… \ dungbeetle push --report .dungbeetle/ci-report.json --branch main --commit "$GIT_SHA" ``` Baselines can be versioned in the cloud instead of the repo: ```sh DUNGBEETLE_CLIENT_ID=cid_… DUNGBEETLE_CLIENT_SECRET=csec_… \ dungbeetle push-baselines --config dungbeetle.config.json ``` The server keeps a versioned history per `(project, target)`: a new version is created **only when the content digest changes**; an unchanged re-upload is deduped and reports `unchanged`. `--client-id` and `--client-secret` may be passed as flags or via the `DUNGBEETLE_*` environment variables — or skip the pair entirely on a developer machine: after [`dungbeetle login`](/cli/cloud#login) both commands fall back to the stored agent token. Both commands target the hosted cloud by default. Self-hosting? Point the CLI at your server with `--server ` or `DUNGBEETLE_SERVER_URL`. ## Review, approve, and promote A server-rendered review UI ships with the server (no separate build step). Sign in at the server root with a user API token: - **Repository tabs** — every repository is a tabbed page; the header shows the team / repo breadcrumb (each segment has a switcher dropdown): **Repo** (a state summary: pending/closed reviews, flakiest targets, run timeline), **Runs**, **Analytics**, and **Settings** (General / Tokens / Automation). Runs and Analytics carry a branch filter that defaults to the repository's configured default branch. - **Runs list** shows a repository's recent runs with pass/fail, failure count, review state, and branch. - **Run detail** reads like a pull-request review: a verdict header, each target's status and semantic diff, and the decisions as a **timeline** (the review form shows until you've decided). - **Review** records an `approved` / `rejected` / `pending` decision with a reviewer name and optional note — an **append-only audit trail**. When a run is uploaded with candidate snapshots (`dungbeetle test --with-snapshots` / `dungbeetle ci --with-snapshots`, then `dungbeetle push`), approving can also **promote** them to new hosted baseline versions — so teams accept new baselines from the UI without touching the repo. ![A failed run's semantic diff (a text change from $42.00 to $58.00) above the review form with approve and promote controls.](/walkthroughs/cloud-review.png) ## Analytics The server aggregates stored runs into project trends and per-target flakiness (a repository's **Analytics** tab), over a window of the most recent runs (default 200): - **Pass rate** — passed runs / total runs in the window. - **Trend** — runs and failed runs aggregated per day. - **Flakiness (per target)** — `failRate`, `flips` (pass↔fail transitions), and `flakeRate` = `flips / (observations − 1)`, where `0` is perfectly stable and a value near `1` is the signature of a flaky target. ![The analytics page: total runs, pass rate and failed-run counts, a trend chart, and a per-target flakiness table.](/walkthroughs/cloud-analytics.png) ## Roadmap & ranking A public **roadmap** at `/ui/roadmap` lists what's being considered, ordered by **community rank** (1 = highest priority). The team sets a base priority; signed-in users **reorder their own list** with up/down controls, and those personal orderings aggregate into the community rank. Guests see the ranked list and a prompt to sign in. Paid users can additionally **suggest** new items (added as `proposed` for the community to rank). ## Configuration Using the managed cloud needs no server configuration — the CLI targets the hosted cloud by default and only needs `DUNGBEETLE_CLIENT_ID` and `DUNGBEETLE_CLIENT_SECRET` to push (see above). Server **deployment** configuration — data directory, TLS, object storage (S3 / R2), at-rest encryption, SSO/OAuth providers, and request limits — is part of the [enterprise self-hosting](/guide/self-hosting) runbook, provided to self-host customers on request. ## Security posture - **Transport.** Client credentials and the session cookie are bearer secrets — run over TLS in production. `dungbeetle push` / `push-baselines` warn when they would send credentials to a non-loopback `http://` URL. - **Data at rest.** The DB holds queryable metadata; larger content (run reports, baseline snapshots, screenshots) lives in on-disk blobs. `node:sqlite` has no built-in cipher, so encrypt the whole data volume at the host level (LUKS/dm-crypt or a KMS-backed cloud volume); optionally set `DUNGBEETLE_ENCRYPTION_KEY` to seal each blob with AES-256-GCM (backward-compatible with existing plaintext blobs). - **Credential management.** Mint additional API tokens (shown once, optional expiry), see last-used time, and revoke them under **Settings → API tokens**. Repository push tokens live under the repository's **Settings → Tokens**: mint labelled tokens (a label is required, expiry optional), rotate one in place (same `client_id`, runs/baselines preserved), or remove it without touching the others. **Agent tokens** (scoped, one repository, minted via the [device flow](/mcp/auth)) are listed and revoked at `/ui/agents`. - **Audit logging.** Security-relevant events — sign-in success/failure, API auth failures and rate-limiting, repository create/delete, token mint/revoke, client-secret rotation, and review decisions — are emitted as structured JSON lines on stdout, identifying the actor and never logging secrets. ## API surface (v1) > Full request/response shapes and status codes are in the > [Server API reference](/api/). This is the summary. All `/api/v1` routes accept HTTP Basic auth (the repository's `client_id` as username, `client_secret` as password — all-or-nothing, for CI) or a Bearer [agent token](/mcp/auth) (`dbat_…`, limited to its granted scopes). Either credential resolves to a repository, which scopes the request. | Method | Path | Auth | Purpose | | --- | --- | --- | --- | | GET | `/health` | none | Liveness check | | GET | `/healthz` | none | Readiness + `version` / `uptime` | | POST | `/api/v1/runs` | Basic or Bearer | Ingest a run report | | GET | `/api/v1/runs` | Basic or Bearer | List recent runs (agent discovery) | | GET | `/api/v1/runs/:id` | Basic or Bearer | Fetch a stored run + report | | POST | `/api/v1/runs/:id/review` | Basic or Bearer | Record a review (approve/reject/pending), optionally promoting candidates to baselines | | GET | `/api/v1/runs/:id/screenshots/:artifactId` | Basic or Bearer | Fetch a content-addressed screenshot artifact (immutable) | | POST | `/api/v1/screenshots/probe` | Basic or Bearer | Report which screenshot digests the server is missing | | PUT | `/api/v1/screenshots/:digest` | Basic or Bearer | Upload a screenshot artifact (200 if deduped, 201 if new) | | POST | `/api/v1/baselines` | Basic or Bearer | Upload a baseline (new version, or 200 if deduped) | | GET | `/api/v1/baselines` | Basic or Bearer | List targets with their latest version | | GET | `/api/v1/baselines/:target` | Basic or Bearer | Version history for a target | | GET | `/api/v1/baselines/:target/latest` | Basic or Bearer | Latest version + snapshot | | GET | `/api/v1/baselines/:target/versions/:version` | Basic or Bearer | A specific version + snapshot | | GET | `/api/v1/analytics` | Basic or Bearer | Repository trend + per-target flakiness | | GET | `/api/v1/repository` | Basic or Bearer | Identify the repository the credentials resolve to (for deep links) | | GET | `/api/v1/usage` | Basic or Bearer | Current-period usage + plan limits for the repository's team (metering only) | | DELETE | `/api/v1/token` | Bearer only | Self-revoke the presented agent token | The run-ingestion payload is `{ report, branch?, commit? }`, where `report` is the JSON report the CLI already writes. The repository is authoritative from the client credentials, so any `report.project` value is advisory. ## Data model Main SQLite tables: `users`, `tokens`, `accounts` (a **team** — its name, plan, and billing state), `team_memberships` (which users belong to a team, and their role), `repositories` (each carries an `account_id` tying it to its owning team), `runs`, `run_results`, and `baselines` (hosted baseline history — one immutable, monotonically versioned row per `(repository_id, target_name, version)` with a content digest). Screenshots are content-addressed: `screenshot_artifacts` holds one row per unique image (keyed by sha-256 digest) and `run_screenshots` links a run result to the artifacts it references. The database holds queryable metadata only; blobs live on disk under the data directory: - Run reports: `/runs/.json`. Inline screenshots are offloaded to artifact blobs before the report is stored, leaving `screenshotRefs` (digests) in the report. - Baselines: `/baselines///v/snapshot.json` (and `screenshot.png` when present). - Screenshot artifacts: `/screenshots//.png`. --- # Pricing Dungbeetle pricing is a **free-forever CLI** plus an optional, flat-priced **managed cloud** — and **self-hosting the server** is an enterprise option. For many teams the free CLI is the whole product; the managed cloud is a paid convenience layer, not a required tier. | | What you get | Cost | | --- | --- | --- | | **CLI** (`dungbeetle`) | The whole capture → diff → review-in-PR workflow | **Free, forever** | | **Managed cloud** | Hosted baselines, a review-and-approve UI, flakiness analytics | **Paid** (optional) | | **Self-hosted server** | The cloud server on your own infrastructure | **Enterprise** | ## Free — the CLI The **CLI & engine** (`dungbeetle`) is [FSL-1.1-ALv2](/reference/licensing) — free for any use except offering it as a competing product (it converts to Apache-2.0 two years after each release). Capture snapshots, commit the baselines under `dungbeetle.snapshots/` in your repo, and review changes in your pull requests — **no server, no bill, ever**. > If you never want a vendor in the loop, you're done — the CLI is the whole > product, not a crippled tier. ## Managed cloud — closed beta For teams who'd rather not commit baselines, a **managed hosted cloud** is in **closed beta** while we're busy with some testing — [request access](https://dungbeetle.dev/ui/request) and we'll notify you by email. It offers central run/baseline storage, a review-and-approve UI, and flakiness analytics. It's free during the beta; the pricing below is the proposed structure for general availability. | Tier | Price/mo | Repos | Storage | Snapshots/mo | Retention | | --- | --- | --- | --- | --- | --- | | **Free** | $0 | 1 | 2 GB | 5,000 | 14 days | | **Starter** | $29 | 5 | 25 GB | 25,000¹ | 60 days | | **Team** | $99 | 25 | 150 GB | 100,000¹ | 180 days | | **Business** | $249 | 100 | 750 GB | unlimited | 1 year · SSO | | **Enterprise** | Custom | unlimited | custom | unlimited | custom / self-host | Every plan includes **unlimited seats** — we never charge per head. ¹ Soft fair-use on paid tiers, not a hard cap (the Free tier's 5,000 is a hard limit). Storage beyond the plan is the **only** metered overage, at **$0.10 / GB-month** — there is no per-snapshot charge. ### What makes this different - **Flat, predictable price.** Most visual-testing services meter every screenshot, so your bill grows with your test suite. Dungbeetle's hosted tiers are flat; the only thing that can overflow is storage, billed transparently. - **No per-seat charge.** Invite your whole team (from **Settings → Teams**) — plans attach to the team and pricing is by usage, not heads. - **No lock-in.** The data model and API are open and versioned, and your baselines live in your repo by default — you're never locked in by your own data. ### How the dimensions work - **Repos** — repositories pushing runs, counted per team. - **Storage** — total size of stored baselines + run artifacts (PNG blobs dominate). The one overage dimension. - **Snapshots/mo** — captured snapshots ingested in a billing period; a hard cap on Free, a soft fair-use guide on paid tiers, unlimited on Business+. - **Seats** — unlimited on every plan; pricing is by usage, never per head. - **Retention** — how long runs and artifacts are kept before automatic pruning. ## Enterprise Need the server on your **own infrastructure** — data residency, air-gapped networks, strict compliance? **Self-hosting the Dungbeetle cloud server is an enterprise option, offered on request** under a commercial / self-host license (BUSL-1.1) with a support SLA — not a free self-serve download. See [Enterprise self-hosting](/guide/self-hosting) to scope a deployment. ## FAQ **Is Dungbeetle really free?** Yes — the CLI is. Commit baselines to your repo and you get the full capture / diff / review-in-PR workflow with no server and no bill. The hosted cloud sells convenience (central storage, a review UI, analytics), not the core capability. **What counts as a "snapshot"?** One captured target output (a terminal capture, a DOM snapshot, a performance run, …) ingested into a run. **Can I move between the managed cloud and self-hosting?** Yes — the data model and API are the same, so an enterprise self-hosted server and the managed cloud both take the same `dungbeetle push`. **Migrating from another tool?** Dungbeetle is built for teams that already chose self-hostable/OSS visual testing. If you're moving off a sunsetting tool, get in touch — we want to make that painless. **Comparing options?** Honest head-to-heads: [vs Playwright's toHaveScreenshot](/compare/playwright), [vs Percy](/compare/percy), [vs Chromatic](/compare/chromatic), and a fair [roundup of the 2026 tools](/compare/best-visual-regression-testing-tools). Moving off a sunsetting OSS tool? See the [migration guides](/migrate/). --- # Enterprise self-hosting Dungbeetle's [hosted cloud](/cloud) is the default way to store runs and baselines centrally. For organisations that need the server to run **on their own infrastructure** — data residency, air-gapped networks, or strict compliance — self-hosting the Dungbeetle cloud server is available to **enterprise customers on request**. Self-hosting is not a public, self-serve download. The server is [BUSL-1.1](/reference/licensing) source-available, but it is distributed and supported under an enterprise agreement rather than published as a turnkey public image. ::: warning Always point the CLI at your server The Dungbeetle CLI and MCP server default to the hosted cloud (`https://dungbeetle.dev`), so every self-hosted deployment must set the server URL explicitly: pass `--server ` to `dungbeetle login`, `push`, `push-baselines`, and `anon` (e.g. `dungbeetle login --server https://dungbeetle.your-org.com`), or set `DUNGBEETLE_SERVER_URL=https://dungbeetle.your-org.com` in CI and in the `env` block of MCP client configs. ::: ## What's included - The Dungbeetle cloud server deployed in your environment (single container or your orchestrator), with the same review/approve UI, hosted baselines, and analytics as the managed cloud. - A deployment runbook and a prebuilt image delivered through a private channel. - Setup support for TLS, object storage (S3 / R2), encryption at rest, SSO, backups, and upgrades. - A commercial / self-host license and a support SLA. ## How to get it 1. **Get in touch** — share your team size, target environment (cloud / on-prem / air-gapped), and compliance needs. 2. **Qualification** — we confirm scope: data residency, SSO, retention, support level. 3. **Agreement** — enterprise license and support terms. 4. **Onboarding** — you receive the deploy runbook, image access, and setup assistance. [**Talk to us about enterprise self-hosting →**](/pricing#enterprise) ## Just want it for free? You don't need a server at all. The **Dungbeetle CLI** is free: capture snapshots, commit the baselines under `dungbeetle.snapshots/` in your repository, and review changes in your normal pull-request flow. The hosted cloud — and enterprise self-hosting — are for teams that would rather not commit baselines and want a central review UI plus analytics. See [Pricing](/pricing). --- # Configuration `dungbeetle init` writes a `dungbeetle.config.json` to your project root. Everything Dungbeetle does — what it captures, how it normalizes output, and how strict diffs are — comes from this one file. This section breaks the config into focused pages: - [Lifecycle](/configuration/lifecycle) — the `setup → start → wait → capture → teardown` flow and the capture targets. - [Normalization](/configuration/normalization) — ANSI handling and mask rules that keep baselines stable. - [Comparison](/configuration/comparison) — numeric and pixel tolerances. - [Schema](/configuration/schema) — the full field-by-field reference. ## Simple The smallest config that captures a terminal command and a web page: ```json { "version": 1, "project": { "name": "my-app" }, "lifecycle": { "capture": [ { "kind": "terminal", "name": "cli-help", "command": "my-app --help" }, { "kind": "web", "name": "home", "url": "http://localhost:3000" } ] } } ``` `baselinesDir` (`dungbeetle.snapshots`) and `artifactsDir` (`.dungbeetle/artifacts`) fall back to their defaults when omitted. ## Advanced A fuller config: a built-and-served app (lifecycle `start` / `wait`), a browser-backed web capture with a screenshot, mask rules, and loosened tolerances. ```json { "version": 1, "project": { "name": "my-app" }, "baselinesDir": "dungbeetle.snapshots", "artifactsDir": ".dungbeetle/artifacts", "lifecycle": { "setup": ["npm run build"], "start": ["npm run preview"], "wait": { "url": "http://localhost:4173", "timeoutMs": 30000 }, "capture": [ { "kind": "terminal", "name": "cli-help", "command": "my-app --help" }, { "kind": "web", "name": "home", "url": "http://localhost:4173", "driver": "playwright", "accessibility": true, "screenshot": true } ], "teardown": [] }, "normalization": { "ansi": "semantic", "masks": [ { "name": "iso-timestamp", "pattern": "\\b\\d{4}-\\d{2}-\\d{2}T[\\d:.]+Z\\b", "replacement": "" }, { "name": "uuid", "pattern": "\\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\b", "replacement": "" } ] }, "comparison": { "numericTolerance": { "relative": 0.1 }, "pixelTolerance": { "maxChangedRatio": 0.01 } } } ``` ## Validate your config Run [`dungbeetle doctor`](/cli/local#doctor) to validate the config, capture targets, baseline paths, missing HTML fixtures, and optional Playwright browser setup before you capture baselines. --- # Lifecycle `lifecycle` runs in order: `setup` → `start` → `wait` → `capture` → `teardown`. - **`setup`** runs before capture (build steps, fixtures). - **`start`** runs as background processes for long-lived dev servers. - **`wait`** gates readiness before capture — `wait.command` and `wait.url` are polled until they succeed or `wait.timeoutMs` elapses. - **`capture`** is the list of targets to snapshot. - **`teardown`** runs after capture; background `start` processes are then stopped. `setup`, `start`, and `teardown` are arrays of shell command strings; `capture` is an array of target objects. ```json { "lifecycle": { "setup": ["npm run build"], "start": ["npm run preview"], "wait": { "url": "http://localhost:4173", "timeoutMs": 30000 }, "capture": [ { "kind": "terminal", "name": "cli-help", "command": "my-app --help" } ], "teardown": [] } } ``` Each `capture` target has a `kind` (`terminal`, `web`, `check`, `performance`, `desktop`, `api`, `game`), a unique `name`, and kind-specific options documented under [Capture types](/capture-types/terminal). The full field tables live in the [schema](/configuration/schema#capture-targets). --- # Normalization Normalization keeps baselines stable across runs: - **`ansi`** is `"semantic"` (keep styling as structured segments) or `"strip"` (text only). - **`masks`** replace dynamic values everywhere before comparison, so timestamps, UUIDs, and temp paths don't cause false diffs. Each mask has a `name`, a `pattern` (a regular expression), and a `replacement`. ```json { "name": "uuid", "pattern": "\\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\b", "replacement": "" } ``` Dungbeetle ships sensible default masks; rules you add are applied on top. See the [schema](/configuration/schema#normalization) for the field reference. --- # Comparison `comparison` controls how strict diffs are. Both tolerances default to `{}` (exact): - **`numericTolerance`** (`absolute` / `relative`) loosens metric and numeric diffs — numbers within the epsilon compare as equal. Useful for accessibility values and [performance](/capture-types/performance) metrics that jitter run to run. - **`pixelTolerance`** (`maxChangedRatio` / `perChannelThreshold`) loosens screenshot diffs. `maxChangedRatio` is the fraction of pixels (0–1) allowed to differ; `perChannelThreshold` is the per-channel 0–255 delta tolerated before a pixel counts as changed (absorbs anti-aliasing noise). When a screenshot change is within `pixelTolerance`, the snapshot passes; failing results include a `pixel` summary (changed pixels, ratio, dimensions) in the JSON report. See the [schema](/configuration/schema#comparison) for the field reference. --- # Configuration schema The full `dungbeetle.config.json` shape. See the [Configuration overview](/configuration/) for a narrative walkthrough. ## Top-level fields | Field | Type | Description | | --- | --- | --- | | `version` | number | Config schema version (currently `1`). | | `project.name` | string | Project name, used in reports. | | `baselinesDir` | string | Where baselines are written (default `dungbeetle.snapshots`). Commit this. | | `artifactsDir` | string | Where JSON/HTML reports go (default `.dungbeetle/artifacts`). | | `lifecycle` | object | Capture lifecycle — see below. | | `normalization` | object | ANSI handling and mask rules. | | `comparison` | object | Numeric and pixel tolerances. | ## lifecycle Runs in order: `setup` → `start` → `wait` → `capture` → `teardown`. | Field | Type | Description | | --- | --- | --- | | `setup` | string[] | Shell commands run before capture. | | `start` | string[] | Shell commands run as background processes for long-lived dev servers. | | `wait.command` | string | Polled until it succeeds. | | `wait.url` | string | Polled until it responds. | | `wait.timeoutMs` | number | Readiness timeout (and default per-target timeout). | | `capture` | target[] | The targets to snapshot. | | `teardown` | string[] | Shell commands run after capture; `start` processes are then stopped. | ## Capture targets Every target has a `kind`, a unique `name`, and an optional `timeoutMs`. Command-running kinds (`terminal`, `check`, `performance`, `desktop`) also take an optional `cwd`. Kind-specific options: ### Common: `reportingDir` Every target accepts an optional `reportingDir` — the reporting directory its snapshots file under, as a nested path of lowercase slug segments: ```json { "kind": "web", "name": "checkout", "url": "…", "reportingDir": "regression/checkout" } ``` Paths may be nested (`regression/checkout`), must be lowercase (`[a-z0-9._-]` segments separated by `/`; a leading `./` is accepted and stripped), stay within 128 characters, and default to `"reporting"`. The cloud stores the directory with each result and groups run reviews by it. ### terminal | Field | Description | | --- | --- | | `command` | The command to run. | See [Terminal snapshots](/capture-types/terminal). ### check | Field | Description | | --- | --- | | `tool` | Which parser to use (`laravel-routes`, `phpstan`, `pest`, `pint`, …). Required. | | `command` | Override the tool's default command. | | `output` | Read tool output from this file instead of (or after) running `command`. | See [Check snapshots](/capture-types/check). ### web | Field | Description | | --- | --- | | `url` | Fetch and snapshot a live page (default driver, no browser). | | `html` | Path to a local HTML fixture to snapshot instead. | | `driver` | `"playwright"` for browser-backed capture. | | `accessibility` | (Playwright) include an accessibility snapshot. | | `screenshot` | (Playwright) capture a screenshot for pixel diffing. | | `screenshotFile` | Ingest an existing image instead of capturing a page. | | `screenshotMode` | `"strict"` (default) or `"advisory"` visual gating. | | `pixelTolerance` | Visual tolerance override for this target. | | `browser.channel` / `browser.executablePath` | (Playwright) browser selection. | | `viewport.width` / `viewport.height` | (Playwright) viewport size. | See [Web snapshots](/capture-types/web). ### performance | Field | Description | | --- | --- | | `tool` | Which parser to use: `k6` (default), `ab`, or `autocannon`. | | `script` | A k6 script; Dungbeetle runs `k6 run --summary-export`. | | `summary` | An existing tool-output file to ingest (no tool run). | | `command` | The tool command to run (required for `ab` / `autocannon`). | | `metrics` | Restrict the snapshot to selected metric names. | | `k6Path` | k6 binary to use (default `k6` on `PATH`). | | `env` | Extra environment variables for the tool run. | See [Performance baselines](/capture-types/performance). ### desktop | Field | Description | | --- | --- | | `tree` | A saved accessibility-tree JSON file. | | `command` | A command that prints an accessibility tree as JSON. | | `driver` | `"macos-ax"` for native macOS capture, or `"ocr"` for screenshot + OCR. | | `app` | (macos-ax / ocr) the running app to capture / snapshot name. | | `maxDepth` | (macos-ax) max tree depth to walk. | | `ocrFallback` | Fall back to screenshot + OCR when the structured capture is empty or fails. | | `screenshot` | (ocr) a saved image file to OCR. | | `screenshotCommand` | (ocr) command that captures an image to the `{out}` path. | | `ocrCommand` | (ocr) command that OCRs `{image}` to text on stdout (default: `tesseract {image} stdout`). | See [Desktop snapshots](/capture-types/desktop). ### api | Field | Description | | --- | --- | | `url` | The endpoint to call. | | `method` | HTTP method (default `GET`, or `POST` with a `body`/`query`). | | `headers` | Request headers, sent as-is. | | `body` | Raw request body, sent verbatim. | | `query` / `variables` | GraphQL sugar: posts the standard JSON envelope. | | `includeHeaders` | Response headers to keep (default `["content-type"]`). | See [API snapshots](/capture-types/api). ### game | Field | Description | | --- | --- | | `engine` | Adapter id; `"godot"` is the only engine today. | | `project` | Engine project directory (contains `project.godot`). | | `walkthrough` | Path to the walkthrough script (JSON). | | `mode` | `"semantic"` (default, headless) or `"visual"` (per-marker screenshots). | | `enginePath` | Engine binary (falls back to `DUNGBEETLE_GODOT_PATH`, then `PATH`). | | `seed` / `physicsFps` | Determinism knobs (defaults `0` / `60`). | | `screenshotMode` | `"advisory"` (default) or `"strict"` visual gating. | | `pixelTolerance` | Visual tolerance override for this target. | | `markers` | Per-marker `pixelTolerance` overrides, keyed by marker name. | See [Game snapshots](/capture-types/game). ## normalization | Field | Type | Description | | --- | --- | --- | | `ansi` | `"semantic"` \| `"strip"` | Keep styling as structured segments, or text only. | | `masks` | mask[] | `{ name, pattern, replacement }` — replace dynamic values everywhere. | ## comparison | Field | Type | Description | | --- | --- | --- | | `numericTolerance.absolute` | number | Absolute epsilon for numeric equality. | | `numericTolerance.relative` | number | Relative epsilon (fraction). | | `pixelTolerance.maxChangedRatio` | number | Fraction of pixels (0–1) allowed to differ. | | `pixelTolerance.perChannelThreshold` | number | Per-channel 0–255 delta tolerated per pixel. | Both tolerances default to `{}` (exact). See [Comparison](/configuration/comparison). --- # Agents Coding agents ship changes faster than anyone can eyeball. This example gives a **swarm of them a safety net**: every agent tests its own work against your baselines, the clean changes push straight through, and anything that regresses lands in **your** review queue — not on `main`. Below, four agents — **Claude Code, Cursor, Codex, and Gemini CLI** — build a VitePress docs site on the **same branch at once**. Three edits pass. One quietly drops the hero's colour below AA contrast; Dungbeetle catches it and parks it for review. Nothing here is staged — the edits were made by a real model and the preview is a real render of the site after each one. ## You own the account. Agents get scoped tokens. The rule that makes this safe: **a human always owns the account and the data.** An agent never sees your password. It gets a **scoped, revocable token** (a `dbat_…`) bound to one repository — you approve it in the browser and can revoke it any time from the dashboard. Promoting a new baseline is a **human** decision. ## Prerequisites - **Node.js 22 or newer** (`node --version`). - A repository with Dungbeetle baselines. If you don't have one yet, follow the [VitePress example](/examples/vitepress) first — it takes about five minutes. - A Dungbeetle account on the [cloud server](/cloud) (the free tier is enough). ## 1. Capture baselines you trust Baselines are the contract the swarm is held to, so a human sets them first: ```sh npm i -g dungbeetle dungbeetle init # scaffold dungbeetle.config.json, add your capture targets dungbeetle update # capture those targets and save them as baselines dungbeetle test # sanity check — everything passes ``` ## 2. Connect an agent From the agent's shell, start the device-authorization flow. It prints a one-time code and waits while **you** approve it in the browser: ```sh dungbeetle login ``` ``` Visit https://dungbeetle.dev/ui/connect and enter code: BEETLE-7Q4X Waiting for approval… ✓ approved Saved a scoped token for acme/docs (runs:write, baselines:read, reviews:write). ``` The token is stored under `$XDG_CONFIG_HOME/dungbeetle/credentials.json` (mode `0600`). It's scoped to a single repository and can't change your account or promote baselines on its own. See [Agent authentication](/mcp/auth) for the full scope list and the device-flow details. ## 3. The agent's loop Now the agent works exactly like you would — it just runs against a token you granted: ```sh dungbeetle test # compare its changes against your baselines dungbeetle push # upload the run; passes are green, diffs wait for review ``` - **Passes** push straight through — the branch stays green. - **Diffs** are uploaded but **not** promoted. They land in the review UI with a semantic diff and a before/after screenshot, attributed to the agent that produced them. In the demo, Cursor's "make the brand lighter" edit is a real regression: ## 4. You review The regression waits in your queue. You **approve** (which promotes the new baseline) or **request changes** (which sends the agent back to fix it). In the demo you request changes; Cursor reverts the colour and its next `test` passes — and only then does the branch go green again. This is the whole point: the swarm moves fast, and the one decision that changes what "correct" means — promoting a baseline — stays with you. ## 5. Wire it into your agent tool Your agent doesn't need to shell out to the CLI — Dungbeetle ships an **MCP server** so the agent can capture, test, push, and read runs as tools, on the same scoped token. Setup guides per tool: - [Claude Code](/mcp/claude-code) - [Cursor](/mcp/cursor) - [OpenAI Codex](/mcp/codex) - [Gemini CLI](/mcp/gemini-cli) See the [MCP overview](/mcp/) for the tool list and the [agent loop](/mcp/tools), and [Agent authentication](/mcp/auth) for how tokens and scopes work. ## Next steps - Give each agent its own token so runs are attributed per agent, and revoke any one of them instantly from the dashboard. - Read more on the trust model in [Security](/reference/security). - Push runs to the [cloud server](/cloud) to review diffs in a browser without committing baselines to your repo. --- # Playwright Run Dungbeetle in **any existing Playwright project** and get value immediately: your Playwright visual snapshots go under dungbeetle review, and every test run can be pushed to the cloud for a grouped, reviewable report — no changes to your tests. ## Prerequisites - **Node.js 22 or newer**. - A project using [`@playwright/test`](https://playwright.dev/docs/intro) — anything scaffolded with `npm init playwright@latest` works. ## 1. Initialize Dungbeetle From the Playwright project root: ```sh npm add -D dungbeetle npx dungbeetle init ``` The wizard detects the Playwright project (via `playwright.config.ts` or the `@playwright/test` dependency), pre-fills the project name and git remote, and suggests the **playwright** template. Accepting the defaults writes a `dungbeetle.config.json` with a raw capture over your Playwright snapshots: ```json { "kind": "raw", "name": "playwright-snapshots", "paths": ["tests/**/*-snapshots/**"], "reportingDir": "playwright/snapshots" } ``` The `testDir` is read from your Playwright config, so a custom `e2e/` layout is picked up automatically. Non-interactive environments (CI, scripts) get the same result with `npx dungbeetle init --template playwright` or `--yes`. ## 2. Baseline and test Playwright's `*-snapshots/` directories (created by [`toHaveScreenshot()`](https://playwright.dev/docs/test-snapshots)) are your visual baselines — Dungbeetle snapshots them by content digest: ```sh npx playwright test # produce/refresh Playwright snapshots npx dungbeetle update # record the dungbeetle baseline npx dungbeetle test # later: any added/removed/changed snapshot fails ``` A changed Playwright snapshot shows up as a named digest diff: ``` ~ files["home.spec.ts-snapshots/home-chromium-darwin.png"].digest: sha256:… → sha256:… ``` Where Playwright's `--update-snapshots` refreshes its own screenshots, `dungbeetle update` refreshes the dungbeetle baseline over them — the same mental model, one level up. ## 3. Push every test run to the cloud Add the Dungbeetle reporter to `playwright.config.ts`: ```ts export default defineConfig({ reporter: [["list"], ["dungbeetle/playwright"]], }); ``` Each `npx playwright test` run now also writes `.dungbeetle/playwright/report.json` — one result per test, grouped per Playwright project (browser) under `playwright/` reporting directories, with the first screenshot attachment of each test included. Push it for review: ```sh npx dungbeetle push --report .dungbeetle/playwright/report.json ``` The run review page groups results by reporting directory — `./playwright/chromium`, `./playwright/firefox`, … — so a multi-browser run reads like a tree, not a flat list. Reporter options: `["dungbeetle/playwright", { outputFile: "custom.json", projectName: "my-app" }]`. ## Terminology map | Playwright | Dungbeetle | | --- | --- | | project (browser config) | target / reporting directory (`playwright/`) | | snapshots (`*-snapshots/`, `--update-snapshots`) | baselines (`dungbeetle update`) | | `npx playwright show-report` | run review page (cloud) / HTML report (local) | | reporter | `dungbeetle/playwright` reporter | --- # Learn by example: Wildmatch **From hello world to fully tested, one small change at a time.** This is the story of *Wildmatch* — Tinder for wildlife — a deliberately tiny [Hono](https://hono.dev) app that grows from a single health-check endpoint into a production service with every dungbeetle capture type watching it. At each stage the app changes, something drifts, dungbeetle catches it, and a reviewer decides. ::: tip Nothing here is fabricated Every snippet, transcript, and screenshot on this page is emitted by [`docs/scripts/capture-wildmatch.sh`](https://github.com/DungbeetleTech/cloud/blob/main/docs/scripts/capture-wildmatch.sh), which really builds the app stage by stage, runs the real CLI, and pushes real runs to a real (throwaway) dungbeetle server. ::: ## Stage 1 — Hello world Wildmatch begins the way every service does: one endpoint that says it's alive. <<< @/examples/wildmatch/stage1-server.js{js} Initialize dungbeetle in the project — the wizard detects what it can and scaffolds a config: ```sh npx dungbeetle init --yes ``` <<< @/examples/wildmatch/stage1-init.txt{ansi} Point the config at the health check. dungbeetle boots the app itself (`lifecycle.start` / `wait`), captures `GET /healthz`, and snapshots the JSON — **your first snapshot**: <<< @/examples/wildmatch/stage1-config.json{json} <<< @/examples/wildmatch/stage1-update.txt{ansi} And here is that first snapshot as a reviewer sees it — this is the app's own review component (not a screenshot), rendered from the run this page's capture script really pushed:
PASSED api:healthz dungbeetle.snapshots/healthz.json

No diff — this target matched its baseline.

Raw captured data
{
  "body": {
    "status": "ok"
  },
  "bodyType": "json",
  "headers": {
    "content-type": "application/json"
  },
  "kind": "api",
  "status": 200
}
Live example — the healthz snapshot, rendered by the app's own review component.
From now on, `dungbeetle test` fails if that response ever changes shape: Push the run to the cloud and the story gets an audit trail: ![Stage 1 in the review UI — one green api target, the very first run.](/wildmatch/story/stage1-review.png) ## Stage 2 — First screenshots The deck arrives: eight profiles, a server-rendered swipe card, and a Playwright suite using [`toHaveScreenshot()`](https://playwright.dev/docs/test-snapshots). Playwright writes its visual baselines to `tests/**/*-snapshots/` — and that directory is exactly what dungbeetle's **raw capture** ingests, so the visual baselines themselves go under review: <<< @/examples/wildmatch/stage2-config.json{json} Then design asks for a warmer like button. One CSS colour changes — this is the whole drift, teal to red:
The swipe card before the drift — teal like button. The swipe card after the drift — red like button.
`npx playwright test` fails locally, and the developer — as developers do — accepts the new look with `--update-snapshots`: <<< @/examples/wildmatch/stage2-test-fail.txt{ansi} Playwright is now green again. But the *accepted image drift* still has to get past review — `dungbeetle test` names the changed snapshot by digest: <<< @/examples/wildmatch/stage2-ci.txt{ansi} The raw capture's review panel, live — the digest diff pins exactly which snapshot images moved:
FAILED raw:playwright-snapshots dungbeetle.snapshots/playwright-snapshots.json
~ tests/wildmatch.spec.js-snapshots/deck-after-like-darwin.png: "sha256:6d68e9d4cf770f3b6253747323078481f3279983382f4765e1e77f452e3351cc" → "sha256:70bf22ce50ba911e18657f34f00dfd73999a0470a6a3b42bc6f0a07a52faa1fd"
~ tests/wildmatch.spec.js-snapshots/deck-first-card-darwin.png: "sha256:ba40bb1db4409a3c4bb3666f8c073a0142b741ced1cd837e93aec41fc95fb8fa" → "sha256:250bcaecc2d77edaf1d581c38021ad5775179416854303a7c1477a1ba71e6365"
Raw captured data
{
  "dir": ".",
  "files": {
    "tests/wildmatch.spec.js-snapshots/deck-after-like-darwin.png": {
      "bytes": 27715,
      "digest": "sha256:70bf22ce50ba911e18657f34f00dfd73999a0470a6a3b42bc6f0a07a52faa1fd"
    },
    "tests/wildmatch.spec.js-snapshots/deck-first-card-darwin.png": {
      "bytes": 28011,
      "digest": "sha256:250bcaecc2d77edaf1d581c38021ad5775179416854303a7c1477a1ba71e6365"
    }
  },
  "kind": "raw"
}
Live example — the raw capture's digest diff for the refreshed Playwright snapshots.
![Stage 2 in the review UI — the raw capture pins the exact snapshot images that changed.](/wildmatch/story/stage2-review.png) ## Stage 3 — Every capture type Wildmatch grows into full coverage — the same app, watched five ways: | Target | Kind | Watches | |--------|------|---------| | `healthz`, `animals` | [api](/capture-types/api) | response contracts | | `playwright-snapshots` | [raw](/capture-types/raw) | Playwright's visual baselines | | `swipe-page` | [web](/capture-types/web) | DOM structure + a screenshot | | `deck-lint` | [terminal](/capture-types/terminal) | the deck lint's stdout | | `swipe-load` | [performance](/capture-types/performance) | a k6 summary export | <<< @/examples/wildmatch/stage3-config.json{json} The benchmark behind `swipe-load` really ran — 4 VUs hammering the swipe endpoint, summary exported for dungbeetle to snapshot: <<< @/examples/wildmatch/stage3-k6.txt{ansi} ::: info Noted where honest The **performance** target snapshots a `k6 run --summary-export` file — the benchmark runs when the team reruns it, not on every push, because live load timings jitter run to run. And the [desktop](/capture-types/desktop) and [game](/capture-types/game) capture types don't apply to a web app, so this story doesn't show them. ::: Then one line of data changes: Stanley the sloth finds a match and leaves the deck. Watch how many targets notice a single removed array entry: <<< @/examples/wildmatch/stage3-ci.txt{ansi} Two of those targets, live. The deck API's semantic diff:
FAILED api:animals dungbeetle.snapshots/animals.json
~ $.body[7]: {"funFact":"I like to take things slow. Very slow.","id":"stanley","name":"Stanley","photo":"/wildmatch/photos/stanley.svg","species":"Three-toed sloth"} → undefined
Raw captured data
{
  "body": [
    {
      "funFact": "I can hear a mouse squeak from 100 feet away. Great listener.",
      "id": "roxy",
      "name": "Roxy",
      "photo": "/wildmatch/photos/roxy.svg",
      "species": "Red fox"
    },
    {
      "funFact": "I eat for 12 hours a day. Looking for someone who loves long dinners.",
      "id": "bao",
      "name": "Bao",
      "photo": "/wildmatch/photos/bao.svg",
      "species": "Giant panda"
    },
    {
      "funFact": "I can turn my head 270 degrees. I will always look back at you.",
      "id": "otis",
      "name": "Otis",
      "photo": "/wildmatch/photos/otis.svg",
      "species": "Great horned owl"
    },
    {
      "funFact": "I drink through my skin. Low-maintenance, mostly.",
      "id": "fernando",
      "name": "Fernando",
      "photo": "/wildmatch/photos/fernando.svg",
      "species": "Tree frog"
    },
    {
      "funFact": "My stripes are as unique as fingerprints. Literally one of a kind.",
      "id": "tallulah",
      "name": "Tallulah",
      "photo": "/wildmatch/photos/tallulah.svg",
      "species": "Bengal tiger"
    },
    {
      "funFact": "I propose with pebbles. Old-fashioned romantic.",
      "id": "pip",
      "name": "Pip",
      "photo": "/wildmatch/photos/pip.svg",
      "species": "Gentoo penguin"
    },
    {
      "funFact": "Three hearts, all of them available.",
      "id": "ophelia",
      "name": "Ophelia",
      "photo": "/wildmatch/photos/ophelia.svg",
      "species": "Octopus"
    }
  ],
  "bodyType": "json",
  "headers": {
    "content-type": "application/json"
  },
  "kind": "api",
  "status": 200
}
Live example — the deck API snapshot diff after Stanley's exit.
And the web target's screenshot comparison — flip between side-by-side and the onion-skin blend, exactly like the review page:
PASSED web:swipe-page dungbeetle.snapshots/swipe-page.json
Before
Before screenshot
After
After screenshot
Before screenshot After screenshot

No diff — this target matched its baseline.

Raw captured data
{
  "driver": "playwright",
  "kind": "web",
  "root": [
    {
      "attributes": {
        "lang": "en"
      },
      "children": [
        {
          "attributes": {},
          "children": [
            {
              "attributes": {
                "charset": "utf-8"
              },
              "children": [],
              "tagName": "meta",
              "type": "element"
            },
            {
              "attributes": {
                "content": "width=device-width,initial-scale=1",
                "name": "viewport"
              },
              "children": [],
              "tagName": "meta",
              "type": "element"
            },
            {
              "attributes": {},
              "children": [
                {
                  "type": "text",
                  "value": "Wildmatch"
                }
              ],
              "tagName": "title",
              "type": "element"
            },
            {
              "attributes": {},
              "children": [
                {
                  "type": "text",
                  "value": "body { margin: 0; font-family: system-ui, sans-serif; background: #F2EDE4; display: grid; place-items: center; min-height: 100vh; } .card { width: 340px; background: #fff; border-radius: 20px; box-shadow: 0 10px 30px rgba(0,0,0,.12); overflow: hidden; } .card img { display: block; width: 100%; height: auto; } .bio { padding: 16px 20px 4px; } h1 { margin: 0; font-size: 1.5rem; } h1 small { font-weight: 400; font-size: 1rem; color: #777; } p { margin: .5rem 0 0; color: #444; } .actions { display: flex; justify-content: center; gap: 28px; padding: 18px 0 22px; } button { width: 64px; height: 64px; border-radius: 50%; border: 0; font-size: 1.6rem; cursor: pointer; } .nope { background: #F6E3E0; } .like { background: #E63946; } .done { text-align: center; padding: 48px 32px; }"
                }
              ],
              "tagName": "style",
              "type": "element"
            }
          ],
          "tagName": "head",
          "type": "element"
        },
        {
          "attributes": {},
          "children": [
            {
              "attributes": {
                "class": "card"
              },
              "children": [
                {
                  "attributes": {
                    "alt": "Roxy the red fox",
                    "height": "340",
                    "src": "/photos/roxy.svg",
                    "width": "340"
                  },
                  "children": [],
                  "tagName": "img",
                  "type": "element"
                },
                {
                  "attributes": {
                    "class": "bio"
                  },
                  "children": [
                    {
                      "attributes": {},
                      "children": [
                        {
                          "type": "text",
                          "value": "Roxy"
                        },
                        {
                          "attributes": {},
                          "children": [
                            {
                              "type": "text",
                              "value": "· Red fox"
                            }
                          ],
                          "tagName": "small",
                          "type": "element"
                        }
                      ],
                      "tagName": "h1",
                      "type": "element"
                    },
                    {
                      "attributes": {},
                      "children": [
                        {
                          "type": "text",
                          "value": "I can hear a mouse squeak from 100 feet away. Great listener."
                        }
                      ],
                      "tagName": "p",
                      "type": "element"
                    }
                  ],
                  "tagName": "div",
                  "type": "element"
                },
                {
                  "attributes": {
                    "class": "actions"
                  },
                  "children": [
                    {
                      "attributes": {
                        "aria-label": "Nope",
                        "class": "nope",
                        "data-dir": "left"
                      },
                      "children": [
                        {
                          "type": "text",
                          "value": "❌"
                        }
                      ],
                      "tagName": "button",
                      "type": "element"
                    },
                    {
                      "attributes": {
                        "aria-label": "Like",
                        "class": "like",
                        "data-dir": "right"
                      },
                      "children": [
                        {
                          "type": "text",
                          "value": "❤️"
                        }
                      ],
                      "tagName": "button",
                      "type": "element"
                    }
                  ],
                  "tagName": "div",
                  "type": "element"
                }
              ],
              "tagName": "main",
              "type": "element"
            },
            {
              "attributes": {},
              "children": [
                {
                  "type": "text",
                  "value": "for (const b of document.querySelectorAll(\"button[data-dir]\")) { b.addEventListener(\"click\", async () => { await fetch(\"/api/swipes\", { method: \"POST\", headers: { \"Content-Type\": \"application/json\" }, body: JSON.stringify({ animalId: \"roxy\", direction: b.dataset.dir }), }); location.href = \"/?i=1\"; }); }"
                }
              ],
              "tagName": "script",
              "type": "element"
            }
          ],
          "tagName": "body",
          "type": "element"
        }
      ],
      "tagName": "html",
      "type": "element"
    }
  ],
  "screenshot": {
    "byteLength": 27399,
    "mimeType": "image/png",
    "sha256": "7bac07b3ce0167a41bb8dc8cd4da305719d171e339bffe8c9a769ee2a1c8ef3b"
  }
}
Live example — the web target's screenshot comparison; toggle side-by-side vs onion skin.
![Stage 3 in the review UI — one data change, several targets reporting it.](/wildmatch/story/stage3-review.png) ## Stage 4 — Production, fully tested A human reviews Stanley's exit and decides — through the real review form: approve, promote the new baselines, leave a note. The decision becomes part of the run's audit trail: ![The decided run — approved and promoted, with the audit trail replacing the review form.](/wildmatch/story/stage4-decided.png) Production hygiene closes the loop: every target routes into a **reporting directory**, so reviews and history group by area: <<< @/examples/wildmatch/stage4-config.json{json} In CI, the whole flow is two commands per push — capture + compare, then upload: ```yaml # .github/workflows/dungbeetle.yml (excerpt) - run: npx dungbeetle ci --json report.json --with-snapshots --json-only - run: > npx dungbeetle push --report report.json --server $DUNGBEETLE_SERVER_URL --client-id $DUNGBEETLE_CLIENT_ID --client-secret $DUNGBEETLE_CLIENT_SECRET --description "$COMMIT_MESSAGE" ``` And the History tab tells Wildmatch's whole visual story — hello world, the deck, the red like button, Stanley's goodbye — as an onion-skin timeline: ![The History tab — Wildmatch's visual evolution across the story's runs.](/wildmatch/story/stage4-history.png) ## Where to go from here - The app itself lives in the repo: [`docs/scripts/wildmatch/`](https://github.com/DungbeetleTech/cloud/tree/main/docs/scripts/wildmatch) — run it, break it, watch dungbeetle catch it. - [Playwright example](/examples/playwright) — the raw-capture pattern on your own test suite. - [Capture types](/capture-types/api) — everything Wildmatch used, in depth. - [Cloud walkthrough](/guide/walkthrough) — reviewing, promoting, analytics. --- # Laravel A complete, copy-paste walkthrough: start a **new Laravel app**, snapshot its welcome page with Dungbeetle, introduce a change, and read the semantic diff. It uses the default `web` capture with the zero-dependency `fetch` driver — **no browser install required**. ## Prerequisites - **PHP 8.2+** and **Composer** — to create and serve the Laravel app. - **Node.js 22 or newer** — Dungbeetle's runtime (`node --version` to check). ## 1. Create a new Laravel app Scaffold a fresh project with Composer and move into it: ```sh composer create-project laravel/laravel example-app cd example-app ``` That gives you Laravel's default welcome page, served at `http://127.0.0.1:8000` by `php artisan serve`. ![The default Laravel welcome page in a browser at http://127.0.0.1:8000.](/examples/laravel-welcome.png) ## 2. Install Dungbeetle Add Dungbeetle as a dev dependency so the version is pinned per project: ```sh npm install --save-dev dungbeetle npx dungbeetle --version ``` ## 3. Scaffold a config Generate a starter `dungbeetle.config.json`: ```sh npx dungbeetle init ``` `init` detects the Laravel app via `composer.json` and scaffolds [check targets](/capture-types/check) for it automatically — routes, about, schedule, schema, plus tests/style/static-analysis for whichever tools are installed. Those work as-is (step 9 puts them through their paces). For this walkthrough's welcome-page snapshot, replace the config with the one below. It tells Dungbeetle to **start the Laravel dev server**, **wait until it responds**, and **snapshot the welcome page** — then shut the server down afterward: ```json { "version": 1, "project": { "name": "example-app" }, "baselinesDir": "dungbeetle.snapshots", "artifactsDir": ".dungbeetle/artifacts", "lifecycle": { "start": ["php artisan serve"], "wait": { "url": "http://127.0.0.1:8000", "timeoutMs": 30000 }, "capture": [ { "kind": "web", "name": "welcome", "url": "http://127.0.0.1:8000" } ] } } ``` Everything here is a default-friendly choice: baselines land in `dungbeetle.snapshots/`, artifacts in `.dungbeetle/artifacts/`, and the `web` target uses the default `fetch` driver (no Playwright, no browser). See [Web snapshots](/capture-types/web) and the full [configuration reference](/configuration/schema) for every option. ## 4. Validate the setup Catch config and readiness problems before capturing anything: ```sh npx dungbeetle doctor ``` ## 5. Capture the first baseline ```sh npx dungbeetle update ``` Dungbeetle boots `php artisan serve`, waits for `http://127.0.0.1:8000`, captures a structured DOM snapshot of the welcome page, writes it under `dungbeetle.snapshots/`, and stops the server. **Commit the baseline** — it's the reviewable record of your app's output: ```sh git add dungbeetle.snapshots git commit -m "Add Dungbeetle baseline for welcome page" ``` ## 6. Make a change and compare Edit the welcome view so the output differs — for example, change the heading in `resources/views/welcome.blade.php`: ```blade

Welcome to Example App

``` Now compare current output against the baseline: ```sh npx dungbeetle test ``` `test` boots the server again, re-captures, and **exits non-zero** because the DOM changed. The diff is **semantic** — a node-level `text-changed`, not a pixel blob. ## 7. Read the report Generate an HTML report and open it: ```sh npx dungbeetle ci --json report.json --html report.html open report.html # macOS; use xdg-open on Linux ``` _Screencast: doctor → the edited Blade view fails `test` with a semantic diff → `update` accepts it → `test` passes._ _Flow video — the terminal session replayed, then the served page with its change, then the failing run's HTML report._ ## 8. Accept the new baseline Once the change is intended, promote it to the new baseline and commit: ```sh npx dungbeetle update git add dungbeetle.snapshots && git commit -m "Update welcome baseline" ``` ## 9. Snapshot the app's shape The scaffolded [check targets](/capture-types/check) from step 3 snapshot what the app *is*, not just what it renders — every route with its middleware, the scheduled jobs, the database schema, the test suite's results, and style/static analysis when Pint or Larastan are installed. Each is one line of config: ```json { "kind": "check", "name": "routes", "tool": "laravel-routes" } ``` Baseline them all at once: ```sh npx dungbeetle update && git add dungbeetle.snapshots && git commit -m "Baseline app shape" ``` Now make a **risky change** — remove a route, or run a migration that drops a column — and `npx dungbeetle test` names it precisely: ```diff ~ $.data.GET|HEAD /orders: {"middleware":["web","auth"],…} → undefined ~ $.data.users.nickname: "varchar" → undefined ``` A route that lost its `auth` middleware, a dropped column, a newly failing test — each is one named line in the diff, not a wall of tool output. _Screencast: `init` detects the Laravel app and scaffolds the check targets → baseline → a renamed route fails `test` with the route **and** the Feature test that hits it, both named._ ## 10. Review in the cloud Push a run to a Dungbeetle server to review and approve these diffs in a browser — without committing baselines to the repo: ```sh npx dungbeetle ci --json .dungbeetle/ci-report.json --with-snapshots --json-only DUNGBEETLE_CLIENT_ID=cid_… DUNGBEETLE_CLIENT_SECRET=csec_… \ npx dungbeetle push --report .dungbeetle/ci-report.json --branch main ``` Pushes go to the hosted cloud by default. Self-hosting? Point the CLI at your server with `--server ` or `DUNGBEETLE_SERVER_URL`. The review UI renders each check target as a risk-first table — the removed route, the dropped column, and the newly failing test sort to the top with summary chips on the clean targets: _Screencast: sign in → open the failing run → read the risk-first check tables → approve and promote the new baselines._ See [Push runs and baselines](/cloud#push-runs-and-baselines) for the API details. ## Next steps - Add more targets — extra routes, JSON API responses, or an Artisan command via a [`terminal`](/capture-types/terminal) capture. - Need browser-rendered DOM, accessibility, or screenshots? Switch the target to the [Playwright driver](/capture-types/web#playwright-driver). - Wire `npx dungbeetle ci` into [CI](/guide/quick-start#in-ci), or push runs to the [cloud server](/cloud) to review diffs in a browser without committing baselines. --- # VitePress A complete, copy-paste walkthrough: scaffold a **new VitePress site**, snapshot its rendered home page with Dungbeetle, change a page, and read the semantic diff. It uses the default `web` capture with the zero-dependency `fetch` driver — **no browser install required**. ## Prerequisites - **Node.js 22 or newer** — runs both VitePress and Dungbeetle (`node --version`). ## 1. Create a new VitePress site Make a project and run the VitePress setup wizard, accepting the defaults (root `./docs`, and **yes** to adding npm scripts): ```sh mkdir vitepress-site && cd vitepress-site npm init -y npm add -D vitepress npx vitepress init ``` The wizard adds `docs:dev`, `docs:build`, and `docs:preview` scripts. `build` outputs static HTML to `docs/.vitepress/dist`; `preview` serves it at `http://localhost:4173`. ![The default VitePress home page in a browser at http://localhost:4173.](/examples/vitepress-home.png) ## 2. Install Dungbeetle ```sh npm install --save-dev dungbeetle npx dungbeetle --version ``` ## 3. Scaffold a config Generate a starter config, then replace it with the one below. It **builds the site**, **starts the preview server**, **waits until it responds**, and **snapshots the home page** — then shuts the server down: ```sh npx dungbeetle init ``` ```json { "version": 1, "project": { "name": "vitepress-site" }, "baselinesDir": "dungbeetle.snapshots", "artifactsDir": ".dungbeetle/artifacts", "lifecycle": { "setup": ["npm run docs:build"], "start": ["npm run docs:preview"], "wait": { "url": "http://localhost:4173", "timeoutMs": 30000 }, "capture": [ { "kind": "web", "name": "home", "url": "http://localhost:4173" } ] } } ``` The `web` target uses the default `fetch` driver (no Playwright, no browser). See [Web snapshots](/capture-types/web) and the full [configuration reference](/configuration/schema). ## 4. Validate the setup ```sh npx dungbeetle doctor ``` ## 5. Capture the first baseline ```sh npx dungbeetle update ``` Dungbeetle builds the site, boots the preview server, waits for `http://localhost:4173`, captures a structured DOM snapshot of the home page, writes it under `dungbeetle.snapshots/`, and stops the server. **Commit the baseline:** ```sh git add dungbeetle.snapshots git commit -m "Add Dungbeetle baseline for docs home" ``` ## 6. Make a change and compare Edit the home page hero in `docs/index.md` — for example, change the `tagline` in the frontmatter: ```yaml hero: tagline: Snapshot-tested docs ``` Now compare current output against the baseline: ```sh npx dungbeetle test ``` `test` rebuilds, re-captures, and **exits non-zero** because the DOM changed. The diff is **semantic** — a node-level `text-changed`, not a pixel blob. ## 7. Read the report ```sh npx dungbeetle ci --json report.json --html report.html open report.html # macOS; use xdg-open on Linux ``` _Screencast: doctor → the edited docs/index.md fails `test` with a semantic diff → `update` accepts it → `test` passes._ _Flow video — the terminal session replayed, then the served page with its change, then the failing run's HTML report._ ## 8. Accept the new baseline ```sh npx dungbeetle update git add dungbeetle.snapshots && git commit -m "Update docs home baseline" ``` ## 9. Review in the cloud Push a run to a Dungbeetle server and the review UI shows the hero change as both a semantic DOM diff and a before/after screenshot comparison. _Screencast: sign in → open the failing run → semantic diffs, screenshot comparison with onion skin → approve and promote the new baselines._ ## Next steps - Snapshot more pages — add a `web` target per route (`/markdown-examples`, `/api-examples`). - Need browser-rendered DOM, accessibility, or screenshots? Switch to the [Playwright driver](/capture-types/web#playwright-driver). - Wire `npx dungbeetle ci` into [CI](/guide/quick-start#in-ci), or push runs to the [cloud server](/cloud) to review diffs in a browser without committing baselines. --- # Python A complete, copy-paste walkthrough: start a **new FastAPI app**, snapshot its rendered page with Dungbeetle, change a route, and read the semantic diff. It uses the default `web` capture with the zero-dependency `fetch` driver — **no browser install required**. A [CLI variant](#snapshot-a-cli-instead) is at the end. ## Prerequisites - **Python 3.10+** with `pip` and `venv`. - **Node.js 22 or newer** — Dungbeetle's runtime (`node --version`). ## 1. Create a new FastAPI app Make a project, set up a virtual environment, and install FastAPI + Uvicorn: ```sh mkdir fastapi-app && cd fastapi-app python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate pip install fastapi uvicorn ``` Create `main.py` with a single HTML route: ```python from fastapi import FastAPI from fastapi.responses import HTMLResponse app = FastAPI() @app.get("/", response_class=HTMLResponse) def index() -> str: return "

Hello from FastAPI

" ``` Run it to confirm it serves at `http://127.0.0.1:8000`: ```sh uvicorn main:app --port 8000 ``` ![The FastAPI page in a browser at http://127.0.0.1:8000.](/examples/python-home.png) ## 2. Install Dungbeetle ```sh npm install --save-dev dungbeetle npx dungbeetle --version ``` This adds a `package.json` to the project; commit it alongside your Python code. ## 3. Scaffold a config Generate a starter config, then replace it with the one below. It **starts Uvicorn**, **waits until it responds**, and **snapshots the page** — then shuts the server down: ```sh npx dungbeetle init ``` ```json { "version": 1, "project": { "name": "fastapi-app" }, "baselinesDir": "dungbeetle.snapshots", "artifactsDir": ".dungbeetle/artifacts", "lifecycle": { "start": ["uvicorn main:app --port 8000"], "wait": { "url": "http://127.0.0.1:8000", "timeoutMs": 30000 }, "capture": [ { "kind": "web", "name": "index", "url": "http://127.0.0.1:8000" } ] } } ``` The `web` target uses the default `fetch` driver (no Playwright, no browser). See [Web snapshots](/capture-types/web) and the full [configuration reference](/configuration/schema). ## 4. Validate the setup ```sh npx dungbeetle doctor ``` ## 5. Capture the first baseline ```sh npx dungbeetle update ``` Dungbeetle boots Uvicorn, waits for `http://127.0.0.1:8000`, captures a structured DOM snapshot, writes it under `dungbeetle.snapshots/`, and stops the server. **Commit the baseline:** ```sh git add dungbeetle.snapshots git commit -m "Add Dungbeetle baseline for index route" ``` ## 6. Make a change and compare Edit the heading in `main.py`: ```python return "

Hello from Example App

" ``` Now compare current output against the baseline: ```sh npx dungbeetle test ``` `test` boots the server again, re-captures, and **exits non-zero** because the DOM changed. The diff is **semantic** — a node-level `text-changed`, not a pixel blob. ## 7. Read the report ```sh npx dungbeetle ci --json report.json --html report.html open report.html # macOS; use xdg-open on Linux ``` _Screencast: doctor → the edited main.py fails `test` with a semantic diff → `update` accepts it → `test` passes._ _Flow video — the terminal session replayed, then the served page with its change, then the failing run's HTML report._ ## 8. Accept the new baseline ```sh npx dungbeetle update git add dungbeetle.snapshots && git commit -m "Update index baseline" ``` ## Snapshot a CLI instead For a command-line tool, capture stdout/stderr with a [`terminal`](/capture-types/terminal) target instead of a `web` one — no server needed: ```json { "version": 1, "project": { "name": "python-cli" }, "lifecycle": { "capture": [ { "kind": "terminal", "name": "help", "command": "python -m yourtool --help" } ] } } ``` `dungbeetle update` / `test` then snapshot and diff the command's output, with ANSI normalized to stable structured segments. ## 9. Review in the cloud Push a run to a Dungbeetle server and the review UI shows the whole story in one place: the DOM's semantic diff, the FastAPI endpoint's JSON diff with its numeric changes, and before/after screenshots from the Playwright driver. _Screencast: sign in → open the failing run → semantic diffs, screenshot comparison with onion skin → approve and promote the new baselines._ ## Next steps - Add a `web` target per route, or mix `web` and `terminal` targets in one config. - Other frameworks work the same way — point `start` at `flask run` or `python manage.py runserver` and set `wait.url` to match. - Need browser-rendered DOM or screenshots? Use the [Playwright driver](/capture-types/web#playwright-driver). Push runs to the [cloud server](/cloud) to review diffs in a browser. --- # Go A complete, copy-paste walkthrough: start a **new Go HTTP service** (standard library, no dependencies), snapshot its rendered page with Dungbeetle, change a handler, and read the semantic diff. It uses the default `web` capture with the zero-dependency `fetch` driver — **no browser install required**. A [CLI variant](#snapshot-a-cli-instead) is at the end. ## Prerequisites - **Go 1.21+** (`go version`). - **Node.js 22 or newer** — Dungbeetle's runtime (`node --version`). ## 1. Create a new Go service Make a project, initialize a module, and write a minimal `net/http` server: ```sh mkdir go-service && cd go-service go mod init example.com/go-service ``` Create `main.go`: ```go package main import ( "fmt" "net/http" ) func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "

Hello from Go

") }) http.ListenAndServe(":8080", nil) } ``` Run it to confirm it serves at `http://127.0.0.1:8080`: ```sh go run . ``` ![The Go service's page in a browser at http://127.0.0.1:8080.](/examples/go-home.png) ## 2. Install Dungbeetle ```sh npm install --save-dev dungbeetle npx dungbeetle --version ``` This adds a `package.json` to the project; commit it alongside your Go code. ## 3. Scaffold a config Generate a starter config, then replace it with the one below. It **starts the service**, **waits until it responds**, and **snapshots the page** — then shuts the service down: ```sh npx dungbeetle init ``` ```json { "version": 1, "project": { "name": "go-service" }, "baselinesDir": "dungbeetle.snapshots", "artifactsDir": ".dungbeetle/artifacts", "lifecycle": { "start": ["go run ."], "wait": { "url": "http://127.0.0.1:8080", "timeoutMs": 30000 }, "capture": [ { "kind": "web", "name": "index", "url": "http://127.0.0.1:8080" } ] } } ``` The `web` target uses the default `fetch` driver (no Playwright, no browser). See [Web snapshots](/capture-types/web) and the full [configuration reference](/configuration/schema). ## 4. Validate the setup ```sh npx dungbeetle doctor ``` ## 5. Capture the first baseline ```sh npx dungbeetle update ``` Dungbeetle boots the service, waits for `http://127.0.0.1:8080`, captures a structured DOM snapshot, writes it under `dungbeetle.snapshots/`, and stops the service. **Commit the baseline:** ```sh git add dungbeetle.snapshots git commit -m "Add Dungbeetle baseline for index route" ``` ## 6. Make a change and compare Edit the heading in `main.go`: ```go fmt.Fprint(w, "

Hello from Example Service

") ``` Now compare current output against the baseline: ```sh npx dungbeetle test ``` `test` boots the service again, re-captures, and **exits non-zero** because the DOM changed. The diff is **semantic** — a node-level `text-changed`, not a pixel blob. ## 7. Read the report ```sh npx dungbeetle ci --json report.json --html report.html open report.html # macOS; use xdg-open on Linux ``` _Screencast: doctor → the edited main.go fails `test` with a semantic diff → `update` accepts it → `test` passes._ _Flow video — the terminal session replayed, then the served page with its change, then the failing run's HTML report._ ## 8. Accept the new baseline ```sh npx dungbeetle update git add dungbeetle.snapshots && git commit -m "Update index baseline" ``` ## Snapshot a CLI instead For a command-line binary, capture stdout/stderr with a [`terminal`](/capture-types/terminal) target instead of a `web` one — no server needed. Build first, then snapshot: ```json { "version": 1, "project": { "name": "go-cli" }, "lifecycle": { "setup": ["go build -o yourtool ."], "capture": [ { "kind": "terminal", "name": "help", "command": "./yourtool --help" } ] } } ``` `dungbeetle update` / `test` then snapshot and diff the command's output, with ANSI normalized to stable structured segments. ## 9. Review in the cloud Push a run to a Dungbeetle server and the review UI shows the whole story in one place: the DOM's semantic diff, the `cart-api` JSON diff with its numeric changes, and before/after screenshots from the Playwright driver. _Screencast: sign in → open the failing run → semantic diffs, screenshot comparison with onion skin → approve and promote the new baselines._ ## Next steps - Add a `web` target per route, or mix `web` and `terminal` targets in one config. - Frameworks like Gin or Echo work the same way — point `start` at your run command and set `wait.url` to match the listen address. - Need browser-rendered DOM or screenshots? Use the [Playwright driver](/capture-types/web#playwright-driver). Push runs to the [cloud server](/cloud) to review diffs in a browser. --- # Godot — snapshot-test your game flow Script a walkthrough of your game, mark the moments that matter, and let Dungbeetle fail CI when the **gameplay** changes — with a diff you can read. This guide runs the example game that ships in the CLI repository: title menu → collect three squares → game over. By the end you'll have: a green headless run in ~3 seconds, a deliberate gameplay change caught as a two-line semantic diff, and a flake harness proving the run is deterministic. _Screencast — the whole loop, live: doctor → baseline → green `ci` → a collectible moves 40px → `ci` fails with the gameplay diff → revert → `flake` proves 5/5 deterministic runs._ _Flow video — the terminal session replayed, then the failing visual-mode run's HTML report: the gameplay diff plus before/after/diff screenshots at every marker._ ## Prerequisites - A Godot 4.x binary (the editor download is fine). - The Dungbeetle CLI repository (the example lives in `examples/game/`), or your own project with the [adapter installed](/capture-types/game#the-adapter). Point Dungbeetle at your Godot binary once: ```sh export DUNGBEETLE_GODOT_PATH="/Applications/Godot.app/Contents/MacOS/Godot" ``` ## 1. Look at the pieces Three files make a game target: **The config** (`examples/game/dungbeetle.config.json`) — a `game` target pointing at a Godot project and a walkthrough: ```json { "kind": "game", "name": "godot-demo", "engine": "godot", "project": "examples/game/godot", "walkthrough": "examples/game/walkthrough.json" } ``` **The walkthrough** (`examples/game/walkthrough.json`) — the flow, with markers at menu, mid-run, and game over: ```json { "steps": [ { "wait": 10 }, { "screenshot": "menu" }, { "input": "ui_accept" }, { "waitFor": "Main:started == true", "timeoutTicks": 60 }, { "input": "move_right", "mode": "down" }, { "waitFor": "Main:game_over == true", "timeoutTicks": 600 }, { "input": "move_right", "mode": "up" }, { "wait": 10 }, { "screenshot": "game-over" }, { "assert": "Player:collected >= 3" } ] } ``` **The game's opt-in** — nodes join the `dungbeetle` group and expose the fields that matter (`examples/game/godot/player.gd`): ```gdscript func _ready() -> void: add_to_group("dungbeetle") func get_dungbeetle_state() -> Dictionary: return {"collected": collected} ``` ## 2. Validate the setup ```sh npm run game:doctor ``` ``` PASS game-walkthrough:godot-demo - Walkthrough is valid … PASS game-adapter:godot-demo - Adapter 0.1.0 installed (protocol v1–v1, CLI speaks v1). PASS game-engine:godot-demo - Engine binary … reports version 4.7.stable… PASS game-determinism:godot-demo - Deterministic run enforced: seed 0, 60 physics ticks/s … ``` ## 3. Capture the baseline ```sh npm run game:update ``` The run is **fully headless** — no window, no Xvfb — because the default `semantic` mode snapshots game state, not pixels. It takes about 3 seconds: the engine boots, the adapter holds the scene behind a deterministic gate, the walkthrough drives it tick by tick, and each marker records an allowlisted scene-tree state. ## 4. Re-run and compare — green ```sh npm run game:ci ``` ``` ✅ PASSED game:godot-demo ``` ## 5. Break the game, read the diff Move the third collectible 40px farther, in `examples/game/godot/main.gd`: ```diff -const ITEM_POSITIONS := [220.0, 320.0, 420.0] +const ITEM_POSITIONS := [220.0, 320.0, 460.0] ``` ```sh npm run game:ci ``` ``` ❌ FAILED game:godot-demo ~ $.markers.game-over.state.Player.position[0]: 398 → 438 ~ $.markers.game-over.tick: 174 → 194 ``` That's the point of semantic-first: the player travelled farther and the run ended 20 ticks later. A pixel tool would show you a red smear; this reads as **a gameplay change**. Revert the change and the run is green again. ## 6. Prove it's deterministic ```sh npm run game:flake ``` ``` ✅ game:godot-demo — 5/5 runs identical ``` `dungbeetle flake --repeat N` captures the target N times with no baseline and reports any run-to-run divergence per marker — the canary to wire into CI before you trust a new walkthrough. ## 7. Add screenshots (optional) Set `"mode": "visual"` on the target and re-baseline. Each marker now also captures a screenshot (locally a window flashes briefly; on Linux CI use `xvfb-run`). Visual changes are **advisory by default** — reported next to the semantic diff, rendered as per-marker side-by-side / onion-skin comparisons in the [cloud review UI](/cloud), but they only gate the run if you set `"screenshotMode": "strict"`. ## 8. Review in the cloud Push a run to a Dungbeetle server and the walkthrough's semantic gameplay diff — marker states, positions, the pickup tick — is reviewable in a browser. In visual mode each marker (menu, mid-run, game-over) also carries a screenshot, so the review pairs the state diff with per-marker before/after/diff images — advisory by default: semantic state stays the gate. _Screencast: sign in → open the failing run → the marker-state diff plus per-marker screenshot comparisons → approve and promote the new baselines._ ## Next steps - The full config, step, and determinism reference: [game snapshots](/capture-types/game). - Push runs to the cloud for per-marker visual review — see [cloud](/cloud). - Installing the adapter into your own project: [the adapter](/capture-types/game#the-adapter). --- # Terminal snapshots A `terminal` target runs a command and snapshots its output — exit code, signal, and the `stdout`/`stderr` streams — with ANSI styling normalized into structured segments. ## Configure a target ```json { "kind": "terminal", "name": "cli-help", "command": "my-app --help" } ``` Options: - **`command`** — the command to run. - **`cwd`** — working directory, resolved relative to the config (default: the config directory). - **`timeoutMs`** — per-target timeout; falls back to `lifecycle.wait.timeoutMs`. ## ANSI normalization The [`normalization.ansi`](/configuration/normalization) setting controls how styling is captured: - `"semantic"` (default) keeps styling as **structured segments**, so a color or bold change is a reviewable diff rather than a stream of raw escape codes. - `"strip"` keeps text only. Either way, [mask rules](/configuration/normalization) are applied so dynamic content (timestamps, ids) doesn't cause false diffs. ## How it's compared Terminal streams use a **word-level inline text diff** — insertions render as `{+added+}` and deletions as `[-removed-]`. Exit code and signal changes are reported on their own lines, and a styling-only change (same text, different segments) is called out explicitly: ```diff ~ stdout: - build passed in 10s + build passed in {+12s+} ~ exitCode: 0 → 1 ~ signal: null → SIGTERM ``` When the text matches but styling differs, you'll see `~ stdout styling changed`. ## See also - [Configuration](/configuration/) — masks and tolerances. - [CLI reference](/cli/) — running `update` / `test` / `ci`. --- # Web snapshots A `web` target captures a **structured DOM snapshot**. By default it uses a zero-dependency `fetch` (no browser required); an optional Playwright driver adds browser-rendered DOM, accessibility snapshots, and screenshots. ## Configure a target Fetch a live URL: ```json { "kind": "web", "name": "home", "url": "http://localhost:3000" } ``` Or snapshot a static HTML fixture: ```json { "kind": "web", "name": "home", "html": "fixtures/home.html" } ``` Options: - **`url`** — fetch and snapshot a live page (default driver, no browser needed). - **`html`** — path to a local HTML fixture to snapshot instead of fetching. - **`driver`** — set to `"playwright"` for browser-backed capture (below). - **`timeoutMs`** — per-target timeout; falls back to `lifecycle.wait.timeoutMs`. The default `fetch` driver does not require a browser install. ## How it's compared DOM uses a **structural tree diff** that aligns nodes by tag/id and reports node-level changes — added, removed, moved, tag-changed, text-changed, and per-attribute changes — without cascading on a single insertion. A semantic text change reads as `"Stable" → "Changed"` rather than a line diff. ## Playwright driver Use Playwright mode when you need browser-rendered DOM, accessibility snapshots, or screenshot fallback data: ```json { "kind": "web", "name": "browser-home", "driver": "playwright", "url": "http://127.0.0.1:4173", "accessibility": true, "screenshot": true, "browser": { "channel": "chrome" }, "viewport": { "width": 1280, "height": 720 } } ``` The resulting baseline stores the structured DOM; when enabled, accessibility and screenshot fallback data are included in the captured snapshot artifact. ### Browser setup Dungbeetle depends on `playwright-core`, so it does **not** download browsers during install. Provide a Chromium browser in one of three ways: 1. A system browser channel: ```json { "browser": { "channel": "chrome" } } ``` 2. An explicit executable path: ```json { "browser": { "executablePath": "/path/to/chromium" } } ``` 3. An environment variable: ```sh export DUNGBEETLE_CHROMIUM_EXECUTABLE_PATH=/path/to/chromium ``` Run [`dungbeetle doctor`](/cli/local#doctor) before updating baselines. If it reports a warning for `browser.channel`, make sure that channel is installed on the machine running Dungbeetle. If it reports a missing executable, correct the path or remove it and use a supported channel instead. ### Screenshots Screenshots are stored as a content digest in the baseline (with the PNG written alongside) and compared with a **tolerant pixel diff** when the digests differ. Tune `comparison.pixelTolerance` — see [Comparison tolerances](/configuration/comparison). A failing web test embeds before/after screenshots in the HTML report. ## Visual comparison Two per-target knobs control how screenshot changes are judged: - **`pixelTolerance`** — this target's own tolerance (`{ "maxChangedRatio": …, "perChannelThreshold": … }`), overriding the global `comparison.pixelTolerance`. Useful when one page is noisier than the rest. - **`screenshotMode`** — `"strict"` (default): a pixel diff beyond tolerance **fails the run** like any other change. `"advisory"`: the DOM is the gate — screenshot changes are reported in the diff but don't fail. ```json { "kind": "web", "name": "home", "driver": "playwright", "url": "http://127.0.0.1:8000", "screenshot": true, "pixelTolerance": { "maxChangedRatio": 0.01 }, "screenshotMode": "strict" } ``` ### Ingest existing screenshots (Dusk, browser tests) A web target can also snapshot a PNG your browser tests already produce — no page capture, the image is the whole snapshot. Laravel Dusk writes screenshots to `tests/Browser/screenshots/`; baseline them directly: ```json { "kind": "web", "name": "dusk-dashboard", "screenshotFile": "tests/Browser/screenshots/dashboard.png", "pixelTolerance": { "maxChangedRatio": 0.005 } } ``` Run your Dusk suite first, then `dungbeetle test` — a visual regression fails with a pixel summary (`~ screenshot: 312/16384 pixels changed (1.90%)`) and the before/after pair lands in the HTML report and the cloud review UI. `doctor` warns (rather than fails) when the screenshot file hasn't been produced yet. ## See also - The runnable example at [`examples/p1/playwright`](https://github.com/DungbeetleTech/client/tree/main/examples/p1/playwright). - [Configuration](/configuration/) and the [configuration reference](/configuration/schema). --- # Check snapshots A `check` target runs a development tool that reports on the **shape of your application** — routes, scheduled jobs, database schema, test results, static analysis, code style — and snapshots the normalized result. Drift then shows up as a semantic diff naming exactly what changed: ``` ~ $.data.GET|HEAD /users: {"middleware":["web","auth"],…} → undefined ~ $.data.users.nickname: undefined → "varchar" ~ $.data.Tests\Unit\BillingTest.refunds are idempotent: {"status":"passed"} → {"status":"failed",…} ``` Every parser normalizes its tool's output into a **keyed record** — entries keyed by identity (route, table, test name) rather than array position — so a removed route diffs as one named entry, not an index shift. Run-varying noise (timings, next-run dates, absolute paths, line numbers) is masked at capture time, and tools that emit different output shapes in different environments (several do, depending on who runs them) normalize to one snapshot, so a baseline captured in CI compares clean against a capture from anywhere else. ## Configure a target ```json { "kind": "check", "name": "routes", "tool": "laravel-routes" } ``` Options: - **`tool`** *(required)* — which parser to use (see the table below). - **`command`** — override the tool's default command. - **`output`** — read an existing report file instead of capturing stdout. Set **both** `command` and `output` to run a command and then read the file it writes (test runners work this way by default). - **`cwd`** — directory to run in, relative to the project root. - **`timeoutMs`** — command timeout (default: the lifecycle wait timeout). Tools whose non-zero exit means "findings exist" (test runners, static analysis) don't fail the capture — the findings **are** the snapshot. The capture fails only when the tool produces no usable output. ## Tools | `tool` | Default command | Snapshots | | --- | --- | --- | | `laravel-routes` | `php artisan route:list --json` | route → name, action, middleware | | `laravel-about` | `php artisan about --json` | environment, cache, driver sections | | `laravel-schedule` | `php artisan schedule:list --json` | cron entry → command, timezone, mutex | | `laravel-schema` | `php artisan schema:dump` (reads the dump file) | table → columns, indexes, constraints; migration names | | `pest` / `phpunit` | `vendor/bin/pest --log-junit …` | suite → test → status (+ failure message) | | `phpstan` | `vendor/bin/phpstan analyse --error-format=json --no-progress` | file → message → identifier, count | | `pint` | `vendor/bin/pint --test --format=json -v` | file → the style rules it violates | Per-tool notes: - **`laravel-routes`** — keyed `"GET|HEAD /users"`. Middleware changes and removed routes are the headline risky changes. Source file is kept; its line number is dropped (it churns on unrelated edits). - **`laravel-about`** — version fields are kept on purpose (that drift is signal); absolute project paths are replaced with `` so snapshots are machine-portable. The `views` cache flag is dropped — running the test suite compiles Blade views as a side effect, which would flake this target. - **`laravel-schedule`** — needs Laravel ≥ 12 for `--json`. `next_due_date` fields are dropped (they vary with the wall clock); a cron expression change reads as remove + add, deliberately loud. - **`laravel-schema`** — the default reads `database/schema/sqlite-schema.sql`; on another connection set `output` to its dump path. Migration **names** are snapshotted, ids/batches are not. - **`pest` / `phpunit`** — one JUnit normalizer covers both runners. Timing and assertion counts are dropped; failure messages keep the assertion text with project paths relativized. - **`phpstan`** — Larastan included (same binary and output). Keyed by message per file with a count — the same identity PHPStan's own baseline uses — so line-number churn never dirties a snapshot. - **`pint`** — snapshots each drifting file's **fixer names** (the style rules that would rewrite it), not diff text, so the baseline is identical whatever environment captured it. ## Zero-config for Laravel `dungbeetle init` detects a Laravel app via `composer.json` and scaffolds check targets automatically: `routes`, `about`, `schedule`, and `schema` always; `tests` (Pest preferred, PHPUnit fallback), `style` (Pint), and `static-analysis` (PHPStan/Larastan) when the tool is installed. `dungbeetle doctor` then verifies each wrapped binary exists — a missing `vendor/bin/pint` fails with "run `composer install`?" before any capture runs. See the [Laravel example](/examples/laravel) for the full walkthrough. ## Ingesting reports from CI Every tool also works in ingest mode — point `output` at a report your CI already produces and no command runs: ```json { "kind": "check", "name": "tests", "tool": "phpunit", "output": "reports/junit.xml" } ``` --- # Performance baselines A `performance` target snapshots load-test metrics and compares them with Dungbeetle's numeric-tolerance engine — the same structured-snapshot-first approach applied to terminal and DOM output, now for performance. Three tool parsers ship today: [k6](https://k6.io) (the default), Apache Benchmark (`ab`), and [autocannon](https://github.com/mcollina/autocannon); all normalize into the same metrics shape, so tolerances and diffs work identically. ::: tip Requirement k6 must be installed and on `PATH` (it is not bundled, like the optional Playwright browser) — unless you snapshot a pre-exported summary with `summary`. ::: ## Configure a target ```json { "kind": "performance", "name": "api-load", "script": "perf/script.js", "metrics": ["http_req_duration", "http_reqs", "checks"] } ``` Options: - **`script`** — a k6 script. Dungbeetle runs `k6 run --summary-export …` and snapshots the result. - **`summary`** — alternatively, point at an existing k6 `--summary-export` JSON file to snapshot **without running k6** (useful in CI where k6 ran separately). - **`metrics`** — restrict the snapshot to selected metric names (default: all). ## Snapshot model A performance snapshot normalizes a k6 summary into a stable, diffable shape: ```json { "kind": "performance", "tool": "k6", "metrics": { "http_req_duration": { "avg": 12.346, "min": 5.1, "med": 11, "max": 45.2, "p90": 20.1, "p95": 28.4 }, "http_reqs": { "count": 1500, "rate": 249.8 }, "checks": { "passes": 1500, "fails": 0, "value": 1 } } } ``` - k6's percentile keys (`p(95)`) are renamed to path-friendly names (`p95`). - Values are rounded to 3 decimals for readable baselines. - Non-numeric stats are dropped. ## Tolerances Performance numbers vary run to run, so comparison relies on [`comparison.numericTolerance`](/configuration/comparison) — set a **relative** tolerance rather than expecting exact equality: ```json { "comparison": { "numericTolerance": { "absolute": 0, "relative": 0.2 } } } ``` A metric that moves within tolerance passes; one that regresses beyond it fails with a percentage-delta diff: ```diff ~ http_req_duration.p95: 28.4 → 60 (+111.3%) ``` ## Apache Benchmark (ab) Set `tool: "ab"` and give the target the benchmark command to run (or a saved output file via `summary`): ```json { "kind": "performance", "name": "homepage-bench", "tool": "ab", "command": "ab -n 100 -c 5 http://127.0.0.1:8000/", "metrics": ["requests", "total_ms", "percentiles_ms", "document"] } ``` The plain-text summary normalizes into the same shape: `requests` (complete/failed/per_second), `duration_ms`, the connection-times table (`connect_ms` / `processing_ms` / `waiting_ms` / `total_ms`), `percentiles_ms` (`p50`–`p100`), and `document.length_bytes` — kept on purpose, because a changed response size means the page itself changed: ```diff ~ document.length_bytes: 58 → 414 (+613.8%) ``` `ab` ships with macOS and the Apache `httpd-tools` package on most Linux distributions, so this is often the zero-install way to put a latency baseline on an endpoint. ## autocannon Set `tool: "autocannon"` with a `--json` command (or a saved output via `summary`): ```json { "kind": "performance", "name": "home-load", "tool": "autocannon", "command": "npx autocannon -d 5 -c 10 --json http://127.0.0.1:8000/", "metrics": ["latency", "counts"] } ``` The JSON summary normalizes into `latency` / `requests` / `throughput` stat groups (mean, stddev, min/max, full percentiles), `counts` (errors, timeouts, status classes — a regression here is signal even when timing holds), and `run` (duration, connections, pipelining — so a changed benchmark shape is itself a named diff, not silently different numbers). Timestamps never land in the snapshot. ## Try it The [`examples/perf`](https://github.com/DungbeetleTech/client/tree/main/examples/perf) example uses a committed `summary.json`, so it needs no k6 install: ```sh dungbeetle update --config examples/perf/dungbeetle.config.json # write the baseline dungbeetle test --config examples/perf/dungbeetle.config.json # compare ``` --- # Desktop snapshots A `desktop` target snapshots a structured **accessibility tree** (role + name + state) rather than pixels — completing the web / desktop / terminal triad. Following the project's no-native-deps ethos, the raw tree is produced externally (an OS accessibility helper, a UI test harness, or a saved fixture) and ingested; native capture drivers can be layered on later. ## Configure a target ```json { "kind": "desktop", "name": "settings-window", "tree": "desktop/tree.json" } ``` Options: - **`tree`** — a saved accessibility-tree JSON file (`{ tool?, root }` or a bare node). - **`command`** — alternatively, a command that prints an accessibility tree as JSON on stdout (plug in your own OS capture helper for the app under test). - **`driver: "macos-ax"`** — capture a running macOS app's tree natively ([below](#native-macos-driver)). - **`driver: "ocr"`** / **`ocrFallback`** — for apps without reliable accessibility access, snapshot a screenshot through OCR instead ([below](#screenshot-ocr-fallback)). ## Snapshot model Each node is normalized to a stable, diffable shape: ```json { "kind": "desktop", "root": { "role": "window", "name": "Settings", "children": [ { "role": "button", "name": "Sign out", "state": ["enabled", "focusable"] } ] } } ``` - Known fields (`role`, `name`/`title`/`label`, `value`, `description`, `state`) are selected; everything else (bounds, coordinates, pids, handles) is dropped as volatile. - `state` is normalized to a sorted flag list (from an array or a boolean map). - `name`/`value`/`description` text is run through the configured [mask rules](/configuration/normalization). ## How it's compared Desktop trees reuse the structural DOM tree diff: nodes align by **role**, so a renamed control surfaces as a changed `@name` rather than a remove + add, and insertions don't cascade: ```diff ~ button[1] @name: "Sign out" → "Log out" ``` ## Try it The [`examples/desktop`](https://github.com/DungbeetleTech/client/tree/main/examples/desktop) example uses a committed `tree.json`, so it needs no native capture: ```sh dungbeetle update --config examples/desktop/dungbeetle.config.json dungbeetle test --config examples/desktop/dungbeetle.config.json ``` ## Native macOS driver The `macos-ax` driver captures a running macOS app's accessibility tree natively, with no native dependency: it walks the tree through System Events via JXA (`osascript -l JavaScript`) and feeds the result through the same normalization and diff as ingested trees. ```json { "kind": "desktop", "name": "calculator", "driver": "macos-ax", "app": "Calculator", "maxDepth": 40 } ``` ::: warning Experimental — requirements and limitations - **macOS only.** Off macOS the driver fails fast with a clear message; `doctor` reports it as a warning rather than a hard failure. - **Accessibility permission.** The terminal / Node process must be granted Accessibility access in System Settings → Privacy & Security → Accessibility (and approve the System Events Automation prompt on first run). - **Local / interactive only.** It drives a live UI, so it can't run headless in CI — capture and commit baselines locally, then `dungbeetle test` compares them. - The target **app must already be running**; the driver reports if it isn't. ::: Windows (UIA) and Linux (AT-SPI) drivers are not yet implemented; for those platforms today, use a `command` that emits the tree as JSON. ## Screenshot + OCR fallback Some apps expose no usable accessibility tree (custom-drawn UIs, games, canvas apps, or platforms where access is denied). For these, Dungbeetle can snapshot a **screenshot run through OCR** instead: the recognized text becomes one `AXStaticText` node per line under the app root, so it's masked, normalized, and diffed exactly like a structured tree — no new snapshot type, no pixel comparison. True to the no-native-deps ethos, the screenshot and OCR steps **shell out** to tools you choose, so nothing heavy is bundled: - **`screenshotCommand`** — captures an image to the `{out}` path (e.g. macOS `screencapture`). Or point **`screenshot`** at a pre-captured image file. - **`ocrCommand`** — turns the `{image}` into text on stdout. Defaults to [`tesseract {image} stdout`](https://github.com/tesseract-ocr/tesseract); if Tesseract isn't installed you get a clear message to install it or set your own. Use it two ways: ```json { "kind": "desktop", "name": "legacy-app", "driver": "macos-ax", "app": "LegacyApp", "ocrFallback": true, "screenshotCommand": "screencapture -x {out}" } ``` `ocrFallback` keeps the structured tree as the primary source and **only** drops to OCR when that capture comes back empty or fails — so apps gain a safety net without losing fidelity where accessibility works. ```json { "kind": "desktop", "name": "canvas-app", "driver": "ocr", "app": "CanvasApp", "screenshotCommand": "screencapture -x {out}" } ``` `driver: "ocr"` goes straight to pixels + OCR for UIs that never expose a tree. ::: tip Keep OCR snapshots stable OCR text is noisier than a structured tree (recognition wobble, reordering). Use [mask rules](/configuration/normalization) for dynamic text, capture at a consistent resolution, and prefer the structured drivers whenever an app supports them. `screenshotCommand` / `ocrCommand` run via a POSIX shell. ::: --- # API snapshots An `api` target snapshots an HTTP endpoint's response — REST or GraphQL — as structured data: the status code, an allow-list of response headers, and the body. JSON bodies are parsed before snapshotting, so a change shows up as a **semantic diff** (`~ $.body.total: 42 → 58`), not a wall of re-serialized text, and numeric tolerance applies to number fields just like performance metrics. ## Configure a target ```json { "kind": "api", "name": "checkout-total", "url": "http://localhost:3000/api/cart/42", "headers": { "authorization": "Bearer test-token" } } ``` Options: - **`url`** *(required)* — the endpoint to call. - **`method`** — HTTP method. Defaults to `GET`, or `POST` when a `body` or `query` is set. - **`headers`** — request headers, sent as-is. - **`body`** — a raw request body, sent verbatim (set your own `content-type` header). - **`query`** / **`variables`** — GraphQL sugar: posts the standard `{ "query": …, "variables": … }` JSON envelope with `content-type: application/json`. Use `query` *or* `body`, not both. - **`includeHeaders`** — response headers to keep in the snapshot (default: `["content-type"]`). Headers are volatile by default — dates, request ids, rate-limit counters — so only this allow-list is stored. - **`timeoutMs`** — request timeout (default: the lifecycle wait timeout). A GraphQL target: ```json { "kind": "api", "name": "orders-query", "url": "http://localhost:3000/graphql", "query": "{ orders(last: 3) { id total } }" } ``` ## Snapshot model ```json { "kind": "api", "status": 200, "headers": { "content-type": "application/json; charset=utf-8" }, "bodyType": "json", "body": { "total": 42, "updatedAt": "" } } ``` - A response that declares JSON (`content-type` contains `json`) is parsed and snapshotted structurally; anything else — and JSON that fails to parse, which is itself a regression worth seeing — is stored as text (`"bodyType": "text"`). - [Mask rules](/configuration/schema) run over **every string**, in headers and body alike, so dynamic values (ids, timestamps) stay stable across runs. - The status code is part of the snapshot, **not** an error: an endpoint whose contract is `404` can be baselined, and a drift to `200` fails the run like any other diff. ## Comparing `dungbeetle test` reports a status change first, then structural changes with their JSON paths: ``` ~ status: 200 → 500 ~ $.body.total: 42 → 58 ``` Number fields respect the config's [`comparison.numericTolerance`](/configuration/schema), so noisy metrics can drift within bounds without failing the run. --- # Game snapshots A `game` target scripts a **walkthrough** of your game — inputs, waits, assertions — and snapshots **semantic game state** at named markers, with optional screenshots at the same markers. The state diff is the gate, so a gameplay change reads like one: ``` ~ $.markers.game-over.state.Player.position[0]: 398 → 438 ~ $.markers.game-over.tick: 174 → 194 ``` The CLI is engine-agnostic: it launches the engine and speaks a small versioned protocol (stdio JSON-lines) to a thin engine-side **adapter**. Godot 4.x is the first supported engine. ## Configure a target ```json { "kind": "game", "name": "godot-demo", "engine": "godot", "project": "examples/game/godot", "walkthrough": "examples/game/walkthrough.json" } ``` Options: - **`engine`** *(required)* — adapter id; `"godot"` is the only engine today. - **`project`** *(required)* — the engine project directory (contains `project.godot`). - **`walkthrough`** *(required)* — path to the walkthrough script (JSON, below). - **`mode`** — `"semantic"` (default) runs fully headless and captures state only; `"visual"` adds one screenshot per marker and needs a display (a brief window locally, `xvfb-run` on Linux CI). - **`enginePath`** — engine binary. Falls back to `DUNGBEETLE_GODOT_PATH`, then `godot` on `PATH`. - **`seed`** — RNG seed applied before the run (default `0`). - **`physicsFps`** — fixed physics tick rate (default `60`). - **`screenshotMode`** — `"advisory"` (default): visual changes are reported but don't fail the run; `"strict"`: visual changes gate like any other diff. - **`pixelTolerance`** — visual tolerance for this target (`maxChangedRatio`, `perChannelThreshold`). Defaults to the game kind's non-zero tolerance (`0.002` / `3`), which absorbs GPU/driver rasterization drift, not gameplay changes. - **`markers`** — per-marker overrides: `{ "game-over": { "pixelTolerance": { "maxChangedRatio": 0 } } }`. - **`timeoutMs`** — whole-run watchdog (default: the lifecycle wait timeout). A broken game script can never hang CI: silence is fatal. ## Walkthrough scripts A walkthrough is a JSON file with a `steps` array. Five step types: ```json { "description": "Menu → collect three squares → game over.", "steps": [ { "wait": 10 }, { "screenshot": "menu" }, { "input": "ui_accept" }, { "waitFor": "Main:started == true", "timeoutTicks": 60 }, { "input": "move_right", "mode": "down" }, { "waitFor": "Player:collected >= 3", "timeoutTicks": 600 }, { "input": "move_right", "mode": "up" }, { "screenshot": "game-over" }, { "assert": "Player:collected >= 3" } ] } ``` - **`input`** — press an **input-map action** (not a raw key: action-level injection at physics-tick boundaries is measurably deterministic; raw event injection is not). `mode` is `"tap"` (default; `ticks` sets the hold duration), `"down"`, or `"up"`. - **`wait`** — advance N physics ticks. - **`waitFor`** — poll a predicate each tick until true; `timeoutTicks` is **mandatory**, so a walkthrough can never wait forever. - **`screenshot`** — a named **marker**: captures semantic state always, plus a screenshot in visual mode. Names are kebab-case and unique; they key the snapshot, the baseline PNGs, and the review UI sections. - **`assert`** — a predicate that must hold; fails the step (with its index) otherwise. Predicates read node properties by scene path: `"NodePath:property value"` with `==`, `!=`, `>=`, `<=`, `>`, `<`. `"Main"` (the scene root's name) and `"."` both address the root. ## Exposing game state Semantic state is an **allowlist**: only nodes in the engine-side `dungbeetle` group are captured (class + position), and a node can contribute game-specific fields via `get_dungbeetle_state()`: ```gdscript func _ready() -> void: add_to_group("dungbeetle") func get_dungbeetle_state() -> Dictionary: return {"collected": collected, "lives": lives} ``` ## Snapshot model ```json { "kind": "game", "engine": "godot", "markers": { "menu": { "tick": 6, "state": { "Player": { "position": [100, 160] } } }, "game-over": { "tick": 174, "state": { "Player": { "position": [398, 160] } } } }, "screenshots": { "menu": { "sha256": "…" }, "game-over": { "sha256": "…" } } } ``` - Engine and adapter **versions are runtime metadata** — an engine patch release never diffs a baseline. Version compatibility is `doctor`'s job. - Screenshots are stored as digests in the baseline; the PNGs live alongside it as `..png`. - Marker `tick` is part of the snapshot: the run ending 20 ticks later is a gameplay change worth seeing. ## Determinism Enforced by the adapter on every run — there is no opt-out: seeded RNG, fixed physics timestep, vsync off, action-level input injection. On identical hardware this measures **byte-identical** across runs (state *and* pixels). What that means across platforms: | scope | semantic state | pixels | | --- | --- | --- | | same machine | byte-identical | byte-identical | | same OS + GPU class | identical | default tolerance absorbs driver drift | | across OS / GPU | **identical — promised** | not promised — generate baselines where you compare; screenshots stay advisory | Verify it empirically with the flake harness: ```sh dungbeetle flake --config dungbeetle.config.json --repeat 5 ``` which captures each target N times with no baseline and reports run-to-run divergence per marker (non-zero exit on any flake — wire it into CI). ## Doctor checks `dungbeetle doctor` reports, per target: project exists, walkthrough valid (with step indices on every issue), **adapter installed + protocol compatibility** (with an upgrade hint naming which side to update), **engine version** (runs `--version`), the enforced determinism knobs, marker-override typos, and a size warning for visual mode on anonymous pushes. ## The adapter The Godot addon lives at `adapters/godot` in the CLI repository: copy `addons/dungbeetle/` into your project and enable the plugin (or register the `Dungbeetle` autoload by hand). It is **inert outside CLI-launched runs** — shipping it in your game costs nothing. Engine breakage is fixed in adapter patch releases, never as CLI churn; see the adapter README for the supported Godot versions and compatibility policy. ## Try it The [Godot example](/examples/godot) walks the whole loop — green run, deliberate gameplay change, semantic diff — in about two minutes. --- # Raw snapshots A `raw` target snapshots arbitrary files by content digest — point it at paths, directories, or glob patterns and any added, removed, or changed file becomes a reviewable diff. It is the zero-effort way to put files another tool already produces (Playwright snapshots, build artifacts, generated fixtures) under dungbeetle review. ## Configure a target ```json { "kind": "raw", "name": "playwright-snapshots", "paths": ["tests/**/*-snapshots/**"], "reportingDir": "playwright/snapshots" } ``` Options: - **`paths`** — files to capture: relative paths, absolute paths, whole directories, or glob patterns (`**` supported). Resolved against `cwd`. Directory targets capture everything inside, dotfiles included; glob patterns follow standard glob semantics (dotfiles need a literal leading dot to match). - **`cwd`** — working directory, resolved relative to the config (default: the config directory). - **`reportingDir`** — the [reporting directory](/configuration/schema) the snapshots file under (default `"reporting"`). ## What's captured Each matching file is recorded as `path → { digest, bytes }` (sha-256 of the contents), plus the captured directory path. File contents are not stored — the digest record is the snapshot, so captures stay small no matter how big the artifacts are. ## How it's compared The file record is a keyed structure, so the diff names exactly what happened: - a **new file** appears as an added entry, - a **deleted file** as a removed entry, - a **changed file** as its digest changing. For now the capture records the directory path and per-file digests; linking matching file names with different extensions (e.g. `shot.png` + `shot.json`) into one review item is planned as part of the review revamp. --- # Migration guides Moving from another visual-regression or snapshot tool? Dungbeetle is the **actively-maintained, self-hostable home** for teams that already chose open tooling — several of the OSS incumbents are now archived, sunsetting, or drifting. These guides map each tool's concepts onto Dungbeetle and walk you through a clean switch. (Coming from a live commercial tool instead? See the honest comparisons: [vs Percy](/compare/percy), [vs Chromatic](/compare/chromatic), [vs Playwright's built-in](/compare/playwright).) | Coming from | Status today | Guide | | --- | --- | --- | | **Lost Pixel** | Sunsetting — repo archived Apr 2026 (team joined Figma); SaaS winding down | [Migrate from Lost Pixel](/migrate/lost-pixel) | | **Wraith** (BBC) | Archived / read-only since Jan 2026; PhantomJS-era engine | [Migrate from Wraith](/migrate/wraith) | | **BackstopJS** | Drifting — no npm release in ~12 months (maintenance "inactive") | [Migrate from BackstopJS](/migrate/backstopjs) | | **Loki** | Alive but pre-1.0, single-maintainer, **Storybook-only** | [Migrate from Loki](/migrate/loki) | ## Why teams switch All four tools above share the same two gaps — the same two things Dungbeetle was built to fix: - **Pixel-only diffs are flaky.** They re-render the whole page to a PNG and compare bytes, so a font hint, an anti-alias edge, or a 1px reflow trips a failure. Dungbeetle's primary snapshot is a **structured tree** (DOM / accessibility / terminal / performance) diffed **node-by-node** — a real text or attribute change reads as `"$42.00" → "$58.00"`, not a wall of red pixels. Pixel screenshots are still available as a *tolerant* fallback when you want them. - **No central review.** Their baselines are PNGs committed to git and approved from a local CLI; there's no shared, audited place to review changes across repos. Dungbeetle's [cloud server](/cloud) adds hosted baselines, a **review / approve / promote UI**, an append-only audit trail, and flakiness analytics — self-hostable, or managed. (Lost Pixel had this, but only in the SaaS that's shutting down.) Plus the obvious: Dungbeetle is **maintained**, uses a **modern Playwright/Chromium** capture path (no PhantomJS), and the managed cloud is **flat and unmetered** — no per-screenshot bill. See [Pricing](/pricing). ## The shape of every migration The steps are the same regardless of which tool you're leaving: 1. **Install Dungbeetle** and scaffold a config — see [Installation](/guide/installation). ```sh npm install -g dungbeetle dungbeetle init ``` 2. **Translate your config** — each guide maps your tool's fields (URLs, viewports, thresholds, ignored regions) to [`dungbeetle.config.json`](/configuration/). 3. **Re-baseline.** Capture fresh baselines with `dungbeetle update` and commit them. ```sh dungbeetle update # writes baselines under dungbeetle.snapshots/ dungbeetle test # compare against them ``` 4. **Wire CI** — swap your old test step for [`dungbeetle ci`](/cli/local#ci). 5. **(Optional) Go hosted** — push runs and baselines to a [Dungbeetle cloud server](/cloud) for central review and analytics. ::: info Why re-baseline instead of importing old references? Your existing baselines are **PNG images**. Dungbeetle's primary baseline is a **structured snapshot**, not a pixel buffer — so there's nothing to import 1:1; a fresh `dungbeetle update` is both required and the better starting point (you get structural diffs from day one). For screenshot-only parity you can enable Dungbeetle's [screenshot fallback](/capture-types/web#screenshots). ::: ## Not listed? The same pattern works for any URL- or component-screenshot tool (reg-suit, Playwright's built-in snapshots, Percy/Chromatic exports). Start with [Quick start](/guide/quick-start) and the [configuration reference](/configuration/), or [open an issue](https://github.com/DungbeetleTech/client/issues) and tell us what you're coming from. --- # Migrate from Lost Pixel [Lost Pixel](https://github.com/lost-pixel/lost-pixel) is a now-sunsetting visual-regression tool — an OSS engine (MIT) plus a hosted platform that added a review/approval UI — and this guide migrates its config and workflow to Dungbeetle. ::: warning Lost Pixel is sunsetting The team announced they are [joining Figma and sunsetting the product](https://www.lost-pixel.com/blog/lost-pixel-team-is-joining-figma); the GitHub repo was **archived (read-only) in April 2026**, and the OSS engine's last release (v3.22.0) was **November 2024**. The SaaS — where the central review UI and approval flow lived — is winding down. If you're on Lost Pixel, the hosted review experience is the thing you most need to replace. ::: ## Why Dungbeetle | | Lost Pixel | Dungbeetle | | --- | --- | --- | | **Maintained** | No — archived Apr 2026 | **Yes** | | **Diff** | Pixel | **Structured tree** (+ tolerant pixel fallback) | | **Capture** | Storybook stories, page shots | Web (DOM/a11y), terminal, desktop, API, performance, games | | **Central review UI** | Yes — but in the sunsetting SaaS | **Yes** — [self-host or managed](/cloud) | | **Pricing** | Was $100–$670/mo (going away) | Free CLI · [flat, unmetered cloud](/pricing) | | **Self-host** | OSS engine only (no UI) | Full server, incl. review UI | Dungbeetle is the closest replacement for what Lost Pixel's **platform** gave you — hosted baselines plus a review/approve/promote UI — except it's maintained and self-hostable. ## Translate your config Lost Pixel uses `lostpixel.config.ts`. The two common modes map directly: **Page shots** — `pageShots.pages` become `web` capture targets: ```ts // lostpixel.config.ts export const config = { pageShots: { baseUrl: 'http://localhost:3000', pages: [ { path: '/', name: 'home' }, { path: '/pricing', name: 'pricing' }, ], }, failOnDifference: true, }; ``` ```json // dungbeetle.config.json { "version": 1, "project": { "name": "my-app" }, "lifecycle": { "capture": [ { "kind": "web", "name": "home", "url": "http://localhost:3000/" }, { "kind": "web", "name": "pricing", "url": "http://localhost:3000/pricing" } ] } } ``` By default these are **structured DOM** snapshots (no browser needed). To match Lost Pixel's pixel screenshots as well, switch a target to the Playwright driver and enable `screenshot` — see [Web snapshots](/capture-types/web#playwright-driver): ```json { "kind": "web", "name": "home", "driver": "playwright", "url": "http://localhost:3000/", "screenshot": true } ``` **Storybook shots** — point a `web` target at each story's iframe URL (`?path=/story/` → `/iframe.html?id=`), or serve `storybook-static` and capture the stories you care about. (Dungbeetle has no Storybook auto-discovery yet; list the stories explicitly, the same way you'd list pages.) Field mapping: | Lost Pixel | Dungbeetle | | --- | --- | | `pageShots.baseUrl` + `pages[].path` | `capture[].url` (full URL) | | `pages[].name` | `capture[].name` | | `storybookShots.storybookUrl` | a `web` target per story iframe URL | | `threshold` (pixel) | [`comparison.pixelTolerance.maxChangedRatio`](/configuration/comparison) | | `failOnDifference` | default — `dungbeetle test`/`ci` exit non-zero on any diff | | `lostPixelProjectId` / `apiKey` (platform) | [Dungbeetle cloud](/cloud) repo client credentials | ## Re-baseline ```sh dungbeetle update # capture fresh baselines → dungbeetle.snapshots/ dungbeetle test # compare ``` Commit `dungbeetle.snapshots/`. Your old `.lostpixel/baseline` PNGs don't carry over (Dungbeetle's baseline is structural, not a pixel buffer) — re-baselining gives you structural diffs from the first run. ## In CI Replace the Lost Pixel GitHub Action / CLI step with: ```sh dungbeetle ci --json report.json --html report.html ``` It exits non-zero on any difference, so it gates a PR the same way. ## Replace the platform's review UI This is the important one — the Lost Pixel platform's approval flow is going away. Stand up a [Dungbeetle cloud server](/cloud) (self-host or managed) and push runs to it: ```sh dungbeetle push --report report.json \ --client-id "$DUNGBEETLE_CLIENT_ID" --client-secret "$DUNGBEETLE_CLIENT_SECRET" ``` Pushes go to the hosted cloud by default. Self-hosting? Point the CLI at your server with `--server ` or `DUNGBEETLE_SERVER_URL`. You get hosted baselines, a **review → approve → promote** UI, an append-only audit trail, and [flakiness analytics](/cloud) — the platform experience, maintained and yours. ## Next - [Configuration reference](/configuration/) · [Web snapshots](/capture-types/web) - [Cloud server](/cloud) · [Pricing](/pricing) --- # Migrate from Wraith [Wraith](https://github.com/bbc/wraith) is the BBC's now-archived Ruby screenshot-comparison tool — capture URLs across widths, diff the pixels with ImageMagick, publish a gallery — and this guide migrates its config to Dungbeetle. ::: warning Wraith is archived The Wraith repository has been **archived / read-only since January 2026**, and its last release (v4.2.4) was **June 2019**. Its default capture engines — PhantomJS, CasperJS, SlimerJS — are themselves abandoned, which makes a clean install increasingly fragile. It needs replacing, not patching. ::: ## Why Dungbeetle | | Wraith | Dungbeetle | | --- | --- | --- | | **Maintained** | No — archived Jan 2026 | **Yes** | | **Engine** | PhantomJS / SlimerJS / Selenium (Ruby + ImageMagick) | **Playwright/Chromium** (Node) | | **Diff** | Pixel (ImageMagick) | **Structured tree** (+ tolerant pixel fallback) | | **Baselines** | `shots/` PNGs; `wraith history` | `dungbeetle.snapshots/`, versioned in [cloud](/cloud) | | **Review UI** | No — static `gallery.html` | **Yes** — [self-host or managed](/cloud) | | **Runtime** | Ruby + ImageMagick + PhantomJS | Node 22+ | ## Two capture models — pick the baseline one Wraith has two modes; map them deliberately: - **History mode** (`wraith history` → `wraith latest`) is the baseline model and maps cleanly onto Dungbeetle: `wraith history` ≈ `dungbeetle update`, `wraith latest` ≈ `dungbeetle test`. - **Two-domain compare** (capturing `english` vs `live` every run, no stored baseline) is an *environment diff*, not regression-vs-baseline. In Dungbeetle you'd capture one environment as the committed baseline and compare the other against it with `dungbeetle test` — or run two targets and compare reports. Most teams actually want the baseline model; this is a good moment to switch to it. ## Translate your config Wraith uses YAML under `configs/`. Paths × widths become Dungbeetle `web` targets: ```yaml # configs/capture.yaml browser: "phantomjs" domains: live: "https://example.com" paths: home: / news: /news screen_widths: - 320 - 1280 threshold: 5 directory: 'shots' ``` ```json // dungbeetle.config.json { "version": 1, "project": { "name": "example" }, "lifecycle": { "capture": [ { "kind": "web", "name": "home-320", "driver": "playwright", "url": "https://example.com/", "screenshot": true, "viewport": { "width": 320, "height": 720 } }, { "kind": "web", "name": "home-1280", "driver": "playwright", "url": "https://example.com/", "screenshot": true, "viewport": { "width": 1280, "height": 720 } }, { "kind": "web", "name": "news-320", "driver": "playwright", "url": "https://example.com/news", "screenshot": true, "viewport": { "width": 320, "height": 720 } }, { "kind": "web", "name": "news-1280", "driver": "playwright", "url": "https://example.com/news", "screenshot": true, "viewport": { "width": 1280, "height": 720 } } ] }, "comparison": { "pixelTolerance": { "maxChangedRatio": 0.05 } } } ``` Field mapping: | Wraith | Dungbeetle | | --- | --- | | `domains` + `paths` | one `web` target per URL | | `screen_widths` | `capture[].viewport.width` (one target per width) | | `browser` | `driver: "playwright"` + [`browser.channel`](/capture-types/web#browser-setup) | | `threshold` (% changed) | [`comparison.pixelTolerance.maxChangedRatio`](/configuration/comparison) (ratio, so `5%` → `0.05`) | | `fuzz` (anti-alias tolerance) | covered by `pixelTolerance`; or drop pixels and rely on the structured DOM diff | | `directory` / `history_dir` | `dungbeetle.snapshots/` (the baselines dir) | ::: tip Consider dropping pixels entirely Wraith is pixel-only. If your pages render server-side HTML, Dungbeetle's default **structured DOM** capture (no `screenshot`, no browser) is far less flaky than ImageMagick pixel diffs — try a target without `driver`/`screenshot` first and only add screenshots where you truly need pixels. ::: ## Re-baseline, then CI ```sh dungbeetle update # ≈ wraith history → dungbeetle.snapshots/ dungbeetle test # ≈ wraith latest dungbeetle ci --json report.json --html report.html # in CI ``` Commit `dungbeetle.snapshots/`. The old `shots/` PNGs don't carry over. ## Replace `gallery.html` with real review Wraith's output is a static gallery you publish as an artifact. Push to a [Dungbeetle cloud server](/cloud) instead for hosted baselines, a review/approve/promote UI, history, and flakiness analytics: ```sh dungbeetle push --report report.json \ --client-id "$DUNGBEETLE_CLIENT_ID" --client-secret "$DUNGBEETLE_CLIENT_SECRET" ``` Pushes go to the hosted cloud by default. Self-hosting? Point the CLI at your server with `--server ` or `DUNGBEETLE_SERVER_URL`. ## Next - [Configuration reference](/configuration/) · [Web snapshots](/capture-types/web) - [Comparison tolerances](/configuration/comparison) · [Cloud server](/cloud) --- # Migrate from BackstopJS [BackstopJS](https://github.com/garris/BackstopJS) is a visual-regression tool — still widely used, but drifting — that captures URL/element screenshots with Puppeteer (or Playwright), diffs them with Resemble.js, and shows an interactive HTML report; this guide migrates its config to Dungbeetle. ::: warning BackstopJS maintenance is stalling There's been **no new npm release in roughly a year** and Snyk classifies the package's maintenance as **inactive**. It isn't archived, but dependency and security drift is the trajectory — and it has **no central, hosted review UI** (baselines are git-committed PNGs, approvals are a local CLI). A good time to move to a maintained tool with real review. ::: ## Why Dungbeetle | | BackstopJS | Dungbeetle | | --- | --- | --- | | **Maintenance** | Inactive (no release ~12 mo) | **Active** | | **Diff** | Pixel (Resemble.js) | **Structured tree** (+ tolerant pixel fallback) | | **Engine** | Puppeteer / Playwright | Playwright/Chromium | | **Approve flow** | `backstop approve` (local CLI) | local re-baseline **or** [hosted promote in the review UI](/cloud) | | **Central review** | No (local HTML report) | **Yes** — self-host or managed | | **Agent access** | None | [**MCP server**](/mcp/) — an agent discovers recent runs (`dungbeetle_list_runs`), reads semantic diffs, and acts under a [scoped, revocable token](/mcp/auth); promoting a baseline stays human-owned approval | | **Cross-machine flakiness** | High without the Docker image | Lower — structured diffs don't depend on render pixels | ## Translate your config BackstopJS uses `backstop.json`. `scenarios` × `viewports` become `web` targets: ```json // backstop.json { "id": "my_project", "viewports": [ { "label": "phone", "width": 320, "height": 480 }, { "label": "desktop", "width": 1920, "height": 1080 } ], "scenarios": [ { "label": "Homepage", "url": "https://example.com", "selectors": ["document"], "delay": 500, "misMatchThreshold": 0.1 } ], "engine": "puppeteer" } ``` ```json // dungbeetle.config.json { "version": 1, "project": { "name": "my_project" }, "lifecycle": { "capture": [ { "kind": "web", "name": "Homepage-phone", "driver": "playwright", "url": "https://example.com", "screenshot": true, "viewport": { "width": 320, "height": 480 } }, { "kind": "web", "name": "Homepage-desktop", "driver": "playwright", "url": "https://example.com", "screenshot": true, "viewport": { "width": 1920, "height": 1080 } } ] }, "comparison": { "pixelTolerance": { "maxChangedRatio": 0.001 } } } ``` Field mapping: | BackstopJS | Dungbeetle | | --- | --- | | `scenarios[].url` | `capture[].url` | | `scenarios[].label` × `viewports[].label` | `capture[].name` (one target per scenario × viewport) | | `viewports[]` | `capture[].viewport` | | `misMatchThreshold` (%) | [`comparison.pixelTolerance.maxChangedRatio`](/configuration/comparison) (ratio: `0.1%` → `0.001`) | | `engine` (puppeteer/playwright) | `driver: "playwright"` + [`browser.channel`](/capture-types/web#browser-setup) | | `delay` / `readySelector` | [`lifecycle.wait`](/configuration/lifecycle) (e.g. wait on a URL/selector before capture) | | `selectors` / `hideSelectors` | see below | | `paths.bitmaps_reference` | `dungbeetle.snapshots/` (baselines dir) | ::: tip `hideSelectors` → masking, or just use the DOM diff BackstopJS hides dynamic regions (`hideSelectors`) to stop pixel flakiness. In Dungbeetle, two better options: (1) capture the **structured DOM** (drop `screenshot`) so a single changed node reads as one node change instead of a repaint; (2) for dynamic *text* (timestamps, IDs), add a [normalization mask](/configuration/normalization) that replaces the value everywhere before comparison. You usually need far fewer "ignore" rules than with pixels. ::: ## Map the workflow | BackstopJS | Dungbeetle | | --- | --- | | `backstop reference` | `dungbeetle update` | | `backstop test` | `dungbeetle test` (or `dungbeetle ci` for JSON/HTML) | | `backstop approve` | re-run `dungbeetle update`, **or** promote in the [cloud review UI](/cloud) | ```sh dungbeetle update # ≈ backstop reference dungbeetle test # ≈ backstop test dungbeetle ci --json report.json --html report.html # in CI (replaces JUnit + HTML report) ``` Commit `dungbeetle.snapshots/`. Your `backstop_data/bitmaps_reference` PNGs don't carry over — re-baseline once. ## Add central review (what BackstopJS lacks) BackstopJS approvals are local. Push to a [Dungbeetle cloud server](/cloud) for a shared, audited **review → approve → promote** flow across machines and repos: ```sh dungbeetle push --report report.json \ --client-id "$DUNGBEETLE_CLIENT_ID" --client-secret "$DUNGBEETLE_CLIENT_SECRET" ``` Pushes go to the hosted cloud by default. Self-hosting? Point the CLI at your server with `--server ` or `DUNGBEETLE_SERVER_URL`. ## Next - [Configuration reference](/configuration/) · [Web snapshots](/capture-types/web) - [Normalization (masking)](/configuration/normalization) · [Cloud server](/cloud) --- # Migrate from Loki [Loki](https://github.com/oblador/loki) is a Storybook-only visual-regression tool that renders each story in headless Chrome (Docker, local, or Lambda — plus iOS/Android simulators) and pixel-diffs the screenshots; this guide migrates its config to Dungbeetle. ::: info Loki is alive, but pre-1.0 and Storybook-locked Loki is still maintained (latest v0.35.1, Aug 2025), but it's **single-maintainer, pre-1.0, and slow to track Storybook majors** — and it's **Storybook-only**: it can't test full application pages or live URLs, and there's no central review UI (file-based baselines + a local `loki approve`). If you've outgrown Storybook-only testing, or want hosted review, Dungbeetle is a maintained step up. ::: ## Why Dungbeetle | | Loki | Dungbeetle | | --- | --- | --- | | **Scope** | Storybook stories only | **Any URL** — stories *and* full pages, terminal, desktop, API, perf, games | | **Diff** | Pixel | **Structured tree** (+ tolerant pixel fallback) | | **Engine** | Chrome (Docker/Lambda/local) + mobile sims | Playwright/Chromium | | **Central review** | No (`.loki/` PNGs + CLI approve) | **Yes** — [self-host or managed](/cloud) | | **Release cadence** | Pre-1.0, slow | Active | ::: warning Mobile simulators Loki can screenshot stories in iOS/Android simulators (React Native). Dungbeetle's web capture is **Chromium-based** and doesn't drive device simulators — if simulator screenshots are core to your suite, that part won't map. Web/Storybook stories migrate cleanly. ::: ## Point Dungbeetle at your stories Loki discovers stories from Storybook automatically; Dungbeetle captures **URLs**, so you target each story's static iframe URL. Build Storybook, then list the stories you want as `web` targets: ```sh npm run build-storybook # outputs storybook-static/ npx http-server storybook-static -p 6006 # or any static server ``` A story's iframe URL is `/iframe.html?id=` (the `id` from the Storybook URL's `?path=/story/`): ```json // dungbeetle.config.json { "version": 1, "project": { "name": "design-system" }, "lifecycle": { "start": ["npx http-server storybook-static -p 6006"], "wait": { "url": "http://localhost:6006", "timeoutMs": 30000 }, "capture": [ { "kind": "web", "name": "button--primary", "driver": "playwright", "screenshot": true, "url": "http://localhost:6006/iframe.html?id=button--primary", "viewport": { "width": 1366, "height": 768 } }, { "kind": "web", "name": "button--disabled", "driver": "playwright", "screenshot": true, "url": "http://localhost:6006/iframe.html?id=button--disabled", "viewport": { "width": 1366, "height": 768 } } ] } } ``` ::: tip Stories also have structure With `driver: "playwright"` and `screenshot: true` you match Loki's pixel behaviour. But the story iframe is also DOM — drop `screenshot` and Dungbeetle diffs the **structured component markup**, which is far less flaky than pixel diffs for most components. Use pixels where rendering fidelity matters, structure elsewhere. ::: Field mapping (Loki config lives under a `loki` key in `package.json`): | Loki | Dungbeetle | | --- | --- | | story (auto-discovered) | one `web` target per story iframe URL | | `configurations[].width` / `height` | `capture[].viewport` | | `configurations[].preset` (e.g. "iPhone 7") | `capture[].viewport` (set width/height) | | `configurations[].target` (`chrome.docker`, …) | `driver: "playwright"` + [`browser`](/capture-types/web#browser-setup) | | `chromeSelector` | capture the iframe (the story root); use [`lifecycle.wait`](/configuration/lifecycle) for readiness | | `diffingEngine` / threshold | [`comparison.pixelTolerance`](/configuration/comparison) | | `.loki/reference/` | `dungbeetle.snapshots/` | ## Map the workflow | Loki | Dungbeetle | | --- | --- | | `loki update` | `dungbeetle update` | | `loki test` | `dungbeetle test` (or `dungbeetle ci`) | | `loki approve` | re-run `dungbeetle update`, **or** promote in the [cloud review UI](/cloud) | ```sh dungbeetle update # ≈ loki update → dungbeetle.snapshots/ dungbeetle test # ≈ loki test dungbeetle ci --json report.json --html report.html # in CI ``` Commit `dungbeetle.snapshots/`; the `.loki/reference` PNGs don't carry over. ## Add hosted review Loki has no shared review surface. Push to a [Dungbeetle cloud server](/cloud) for hosted baselines, a review/approve/promote UI, and flakiness analytics: ```sh dungbeetle push --report report.json \ --client-id "$DUNGBEETLE_CLIENT_ID" --client-secret "$DUNGBEETLE_CLIENT_SECRET" ``` Pushes go to the hosted cloud by default. Self-hosting? Point the CLI at your server with `--server ` or `DUNGBEETLE_SERVER_URL`. ## Next - [Configuration reference](/configuration/) · [Web snapshots](/capture-types/web) - [Lifecycle (start/wait)](/configuration/lifecycle) · [Cloud server](/cloud) --- # Dungbeetle vs Playwright's toHaveScreenshot Playwright's built-in `toHaveScreenshot()` is a free, local visual regression assertion — and for a small suite on a single platform it is genuinely the right place to start. Dungbeetle is a snapshot and visual regression testing tool — a free CLI plus self-hostable cloud — built for AI agents and the humans they work for. The honest verdict up front: **start with `toHaveScreenshot`; add Dungbeetle when reviewing baseline changes, managing them across a team, or letting coding agents run the loop starts to hurt.** The two compose — Dungbeetle can ingest the very screenshots your Playwright suite already takes. ## toHaveScreenshot, done right If you're on Playwright, this is the built-in: ```ts test("home page looks right", async ({ page }) => { await page.goto("/"); await expect(page).toHaveScreenshot("home.png", { fullPage: true, animations: "disabled", maxDiffPixelRatio: 0.001 }); }); ``` - First run fails with *"snapshot doesn't exist"* — run `npx playwright test --update-snapshots` to mint baselines, commit them. - `animations: "disabled"` and a small `maxDiffPixelRatio` kill most flake. - **Pin the rendering platform.** Screenshots minted on macOS fail on Linux CI over font antialiasing alone. Either generate baselines in a container/CI and download them, or accept per-platform snapshot suffixes (`home-darwin.png`, `home-linux.png`) and double the maintenance. That last bullet is not a Playwright flaw — it's physics; every pixel tool has it (Dungbeetle included — our own baselines are CI-minted for exactly this reason). ## Where it stops scaling Real limits we hit ourselves, in rough order of when they bite: 1. **Reviewing a baseline change is reviewing a binary.** A PR that updates `home.png` shows two images at best. No overlay, no onion-skin slider, no per-region diff — the reviewer eyeballs two full-page screenshots and hopes. 2. **Approving means re-running.** "This change is intentional" = `--update-snapshots` locally (wrong platform, see above) or downloading CI artifacts and committing them by hand. There's no approve button. 3. **Pixels are the only language.** A renamed button, a copy tweak, and a broken layout all look the same: a red blob. There's no structural diff to tell you *what* changed. 4. **No memory.** No history of who approved what, no flakiness analytics, no "this target failed 4 of the last 20 runs" — just the current PNG. 5. **Agents have no identity.** A coding agent that can run `--update-snapshots` can approve its own visual changes. There is no scope model to give an agent "run and report, but never touch baselines". ## Keep Playwright — add a review workflow Dungbeetle doesn't replace your Playwright suite; it sits after it. Point targets at the screenshots Playwright already writes (`screenshotFile`), or let Dungbeetle's own `playwright` driver capture structured DOM alongside pixels: ```json { "version": 1, "project": { "name": "my-app-ui" }, "baselinesDir": "dungbeetle.snapshots", "lifecycle": { "capture": [ { "kind": "web", "name": "home", "screenshotFile": "e2e/screenshots/home.png" }, { "kind": "web", "name": "pricing", "screenshotFile": "e2e/screenshots/pricing.png" } ] }, "comparison": { "pixelTolerance": { "maxChangedRatio": 0.001, "perChannelThreshold": 32 } } } ``` ```sh npx playwright test # your suite writes e2e/screenshots/*.png npx dungbeetle update # mint baselines from them (commit dungbeetle.snapshots/) npx dungbeetle test # exits non-zero on any visual change ``` What that buys over raw `toHaveScreenshot`: - **A review UI** — onion-skin/side-by-side diffs, an approve/reject button, and promoted baselines, instead of eyeballing PNGs in a PR. - **Semantic diffs where they exist** — DOM captures diff as structure ("one attribute changed on `