The run board
Every Bobcat suite publishing on the box, live, at http://localhost:5526/runs.
A run announces itself: it probes GET /api/ping once at startup with a tight timeout, and if nothing answers it goes no-op for the whole run. Nothing about this can slow or fail a test suite — that invariant outranks every other consideration on both sides of the wire.
What you see
- A card per run, newest first, carrying an age — relative, absolute in the tooltip, anchored on the finish for a finished run and the start for a live one; the label says which.
- Steps streaming for the scenario in flight — step n of N with elapsed, row k of M for a table grammar, and the interim message of a poll loop.
- Retries and flakiness reported honestly. A pass-on-retry is never folded into a clean pass.
- Supervisor topology for a parallel run: lanes and what each was handed, what is running in each right now, recycles, worker faults with their exit code and standard-error tail, stalled tests, and peak worker memory when sampling is on.
- Orphans as orphans. A run whose publisher died — no terminal
run_finished— renders as orphaned rather than "running" forever. Any later event un-orphans it.
Grouping
run_started carries the repository root path and branch, which is the board's grouping key. That matters on a box running several suites at once: without it, a supervised run across four worker processes was four cards, because each worker minted its own run id.
Grouping applies when a run is driven through a Supervisor with publishing on. Workers launched by a supervisor that does not publish keep one card each, deliberately — a grouped run with no bracket owner would render as an unnamed orphan.
Foreign suites
A supervised run of a non-Bobcat suite gets per-test progress too. Per-scenario events come from each worker's own publisher, so a plain xUnit worker published nothing and its card sat at 0 / 1627 for five minutes — nearly indistinguishable from a wedged run. The supervisor already had the facts and now forwards them.
Two things worth knowing about that:
- The framework's own word travels verbatim — Passed / Failed / Error / Skipped / Timeout / Cancelled, never re-labelled. Skipped counts as a clean pass because the supervisor's own outcome already does, so the progress bar and the terminal counts cannot disagree about the same test.
- Indeterminate never reaches the wire. Silence is not a verdict: a test a crashed worker never answered for publishes nothing at all, so a crashed run cannot read as a complete one.
Exports
GET /api/runs/{runId}/export?format=…, or the button on the card.
| Format | |
|---|---|
| CTRF | Primary. The only CI format with first-class retries, retryAttempts[], flaky and steps[], so attempt history, the pass-on-retry ledger and Gherkin steps land on schema-blessed fields. Richer data rides extra. |
| JUnit XML | The lossy compatibility floor — native ingestion in GitLab, Jenkins, Azure DevOps, CircleCI. |
| NDJSON | The raw ingested event stream. Export and replay-for-debugging both fall out of it. |
The board is not the archive
Two knobs bound two different things, and the split is the design.
Monitor:RetentionRuns (default 10) | Bounds the board by count, per job — repository plus suite. A global cap on a shared multi-repo console lets the busiest repository starve the quiet ones. |
Monitor:RetentionDays | Bounds the archive by age. The only thing that ever deletes a file. |
Eviction is ejection: the archive moves to ejected/, never away. That is what makes an automatic eviction reasonable to ship at all, and it is the same promise the manual Eject button has always made. A live run is never taken — not caution, it does not work, since the publisher's next event recreates the entry — and an orphan is fair game, because its publisher is gone by definition.
Correlating a run to work
BOBCAT_RUN_TAG is an opaque string Bobcat stamps on a run and never interprets — a plan node, a ticket id, a build number:
BOBCAT_RUN_TAG=critter-parity/fisher-1-10 dotnet testGET /api/runs?tag=… filters on it, and a plan's test-run-gate node reads through it, so the evidence that closes a node is the run itself.
Run evidence joins back to the model
scenario_finished carries the types the scenario actually touched — observed at the point a type crossed the scenario (the aggregate arranged, the command dispatched, the events the stream gained, the messages actually sent, the read model actually loaded), never what a Then merely names. A sad path records its rejected command and no event type.
That is the runtime half of slice↔spec binding. Nothing recorded travels as an empty list — it is null, because absence of evidence is not evidence of nothing — and it is what lets slice_coverage tell "never specified" apart from "specified but never exercised".
See also
- The Event Model canvas
- Ports and wire contracts
- What a run publishes — the publisher's side, in Bobcat's docs