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 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.
Nothing here is fabricated
Every snippet, transcript, and screenshot on this page is emitted by 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.
import { serve } from "@hono/node-server";
import { Hono } from "hono";
const app = new Hono();
app.get("/healthz", (c) => c.json({ status: "ok" }));
const port = Number(process.env.PORT ?? 8642);
serve({ fetch: app.fetch, port }, () => console.log(`Wildmatch ready on http://localhost:${port}`));Initialize dungbeetle in the project — the wizard detects what it can and scaffolds a config:
npx dungbeetle init --yesWrote dungbeetle.config.json (template: basic)
Scaffolded 1 target: hello.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:
{
"version": 1,
"project": { "name": "wildmatch" },
"baselinesDir": "dungbeetle.snapshots",
"artifactsDir": ".dungbeetle/artifacts",
"lifecycle": {
"start": ["PORT=8642 node server.js"],
"wait": { "url": "http://127.0.0.1:8642/healthz", "timeoutMs": 30000 },
"capture": [
{ "kind": "api", "name": "healthz", "url": "http://127.0.0.1:8642/healthz" }
]
}
}✅ PASS › wildmatch › update
Snapshots updated=1
Lifecycle start:signal, wait:0
📝 UPDATED api:healthz
dungbeetle.snapshots/healthz.json
✅ PASS ⏱ 329ms
Wrote JSON report at .dungbeetle/artifacts/update-report.json
Wrote HTML report at .dungbeetle/artifacts/update-report.htmlAnd 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:
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 2 — First screenshots
The deck arrives: eight profiles, a server-rendered swipe card, and a Playwright suite using toHaveScreenshot(). 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:
{
"version": 1,
"project": {
"name": "wildmatch"
},
"baselinesDir": "dungbeetle.snapshots",
"artifactsDir": ".dungbeetle/artifacts",
"lifecycle": {
"start": [
"PORT=8642 node server.js"
],
"wait": {
"url": "http://127.0.0.1:8642/healthz",
"timeoutMs": 30000
},
"capture": [
{
"kind": "api",
"name": "healthz",
"url": "http://127.0.0.1:8642/healthz"
},
{
"kind": "raw",
"name": "playwright-snapshots",
"paths": [
"tests/**/*-snapshots/**"
]
}
]
}
}Then design asks for a warmer like button. One CSS colour changes — this is the whole drift, teal to red:


npx playwright test fails locally, and the developer — as developers do — accepts the new look with --update-snapshots:
Running 2 tests using 1 worker
✘ 1 tests/wildmatch.spec.js:3:1 › the deck opens on the first profile (342ms)
✘ 2 tests/wildmatch.spec.js:9:1 › liking a profile advances the deck (404ms)
1) tests/wildmatch.spec.js:3:1 › the deck opens on the first profile ─────────────────────────────
Error: expect(page).toHaveScreenshot(expected) failed
2665 pixels (ratio 0.01 of all image pixels) are different.
Snapshot: deck-first-card.png
… (full failure output trimmed)Playwright is now green again. But the accepted image drift still has to get past review — dungbeetle test names the changed snapshot by digest:
❌ FAIL › wildmatch › test
Snapshots passed=1 failed=1
Lifecycle start:signal, wait:0
✅ PASSED api:healthz
dungbeetle.snapshots/healthz.json
❌ 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"
❌ FAIL ⏱ 319ms
Wrote JSON report at .dungbeetle/artifacts/test-report.json
Wrote HTML report at .dungbeetle/artifacts/test-report.htmlThe raw capture's review panel, live — the digest diff pins exactly which snapshot images moved:

Stage 3 — Every capture type
Wildmatch grows into full coverage — the same app, watched five ways:
| Target | Kind | Watches |
|---|---|---|
healthz, animals | api | response contracts |
playwright-snapshots | raw | Playwright's visual baselines |
swipe-page | web | DOM structure + a screenshot |
deck-lint | terminal | the deck lint's stdout |
swipe-load | performance | a k6 summary export |
{
"version": 1,
"project": {
"name": "wildmatch"
},
"baselinesDir": "dungbeetle.snapshots",
"artifactsDir": ".dungbeetle/artifacts",
"lifecycle": {
"start": [
"PORT=8642 node server.js"
],
"wait": {
"url": "http://127.0.0.1:8642/healthz",
"timeoutMs": 30000
},
"capture": [
{
"kind": "api",
"name": "healthz",
"url": "http://127.0.0.1:8642/healthz"
},
{
"kind": "raw",
"name": "playwright-snapshots",
"paths": [
"tests/**/*-snapshots/**"
]
},
{
"kind": "api",
"name": "animals",
"url": "http://127.0.0.1:8642/api/animals"
},
{
"kind": "web",
"name": "swipe-page",
"driver": "playwright",
"url": "http://127.0.0.1:8642",
"screenshot": true,
"browser": {
"channel": "chrome"
},
"viewport": {
"width": 480,
"height": 800
}
},
{
"kind": "terminal",
"name": "deck-lint",
"command": "node scripts/deck-lint.js"
},
{
"kind": "performance",
"name": "swipe-load",
"summary": "perf/summary.json",
"metrics": [
"http_req_duration",
"http_reqs",
"checks"
]
}
]
}
}The benchmark behind swipe-load really ran — 4 VUs hammering the swipe endpoint, summary exported for dungbeetle to snapshot:
█ TOTAL RESULTS
checks_total.......: 116 38.399623/s
checks_succeeded...: 100.00% 116 out of 116
checks_failed......: 0.00% 0 out of 116
✓ swipe accepted
HTTP
http_req_duration..............: avg=2.7ms min=531µs med=2.37ms max=9.05ms p(90)=4.13ms p(95)=4.94ms
{ expected_response:true }...: avg=2.7ms min=531µs med=2.37ms max=9.05ms p(90)=4.13ms p(95)=4.94ms
http_req_failed................: 0.00% 0 out of 116
http_reqs......................: 116 38.399623/s
EXECUTION
iteration_duration.............: avg=104.06ms min=101.13ms med=104.17ms max=109.8ms p(90)=105.87ms p(95)=107.09ms
iterations.....................: 116 38.399623/s
vus............................: 4 min=4 max=4
vus_max........................: 4 min=4 max=4
NETWORK
data_received..................: 21 kB 7.0 kB/s
data_sent......................: 20 kB 6.6 kB/sNoted 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 and 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:
❌ FAIL › wildmatch › test
Snapshots passed=3 failed=3
Lifecycle start:signal, wait:0
❌ FAILED api:healthz
dungbeetle.snapshots/healthz.json
~ $.body.animals: 8 → 7
✅ PASSED raw:playwright-snapshots
dungbeetle.snapshots/playwright-snapshots.json
❌ 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
✅ PASSED web:swipe-page
dungbeetle.snapshots/swipe-page.json
❌ FAILED terminal:deck-lint
dungbeetle.snapshots/deck-lint.json
~ stdout: ✓ roxy Roxy · Red fox
✓ bao Bao · Giant panda
✓ otis Otis · Great horned owl
✓ fernando Fernando · Tree frog
✓ tallulah Tallulah · Bengal tiger
✓ pip Pip · Gentoo penguin
✓ ophelia Ophelia · Octopus
[-✓ stanley Stanley · Three-toed sloth
8-]{+7+} profiles, 0 problems
✅ PASSED performance:swipe-load
dungbeetle.snapshots/swipe-load.json
❌ FAIL ⏱ 1.9s
Wrote JSON report at .dungbeetle/artifacts/test-report.json
Wrote HTML report at .dungbeetle/artifacts/test-report.htmlTwo of those targets, live. The deck API's semantic diff:
And the web target's screenshot comparison — flip between side-by-side and the onion-skin blend, exactly like the review page:

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:

Production hygiene closes the loop: every target routes into a reporting directory, so reviews and history group by area:
{
"version": 1,
"project": {
"name": "wildmatch"
},
"baselinesDir": "dungbeetle.snapshots",
"artifactsDir": ".dungbeetle/artifacts",
"lifecycle": {
"start": [
"PORT=8642 node server.js"
],
"wait": {
"url": "http://127.0.0.1:8642/healthz",
"timeoutMs": 30000
},
"capture": [
{
"kind": "api",
"name": "healthz",
"url": "http://127.0.0.1:8642/healthz",
"reportingDir": "regression/api"
},
{
"kind": "raw",
"name": "playwright-snapshots",
"paths": [
"tests/**/*-snapshots/**"
],
"reportingDir": "playwright/snapshots"
},
{
"kind": "api",
"name": "animals",
"url": "http://127.0.0.1:8642/api/animals",
"reportingDir": "regression/api"
},
{
"kind": "web",
"name": "swipe-page",
"driver": "playwright",
"url": "http://127.0.0.1:8642",
"screenshot": true,
"browser": {
"channel": "chrome"
},
"viewport": {
"width": 480,
"height": 800
},
"reportingDir": "regression/web"
},
{
"kind": "terminal",
"name": "deck-lint",
"command": "node scripts/deck-lint.js",
"reportingDir": "regression/terminal"
},
{
"kind": "performance",
"name": "swipe-load",
"summary": "perf/summary.json",
"metrics": [
"http_req_duration",
"http_reqs",
"checks"
],
"reportingDir": "regression/perf"
}
]
}
}In CI, the whole flow is two commands per push — capture + compare, then upload:
# .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:

Where to go from here
- The app itself lives in the repo:
docs/scripts/wildmatch/— run it, break it, watch dungbeetle catch it. - Playwright example — the raw-capture pattern on your own test suite.
- Capture types — everything Wildmatch used, in depth.
- Cloud walkthrough — reviewing, promoting, analytics.


