Skip to content

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.

js
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:

sh
npx dungbeetle init --yes
Wrote 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:

json
{
  "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.html

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.

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:

json
{
  "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:

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:

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.html

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.

Stage 3 — Every capture type

Wildmatch grows into full coverage — the same app, watched five ways:

TargetKindWatches
healthz, animalsapiresponse contracts
playwright-snapshotsrawPlaywright's visual baselines
swipe-pagewebDOM structure + a screenshot
deck-lintterminalthe deck lint's stdout
swipe-loadperformancea k6 summary export
json
{
  "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/s

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 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.html

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.

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.

Production hygiene closes the loop: every target routes into a reporting directory, so reviews and history group by area:

json
{
  "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:

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.

Where to go from here

Source-available: CLI under FSL-1.1-ALv2, cloud server under BUSL-1.1. See Licensing.