Skip to content

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
CTRFPrimary. 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 XMLThe lossy compatibility floor — native ingestion in GitLab, Jenkins, Azure DevOps, CircleCI.
NDJSONThe 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:RetentionDaysBounds 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:

bash
BOBCAT_RUN_TAG=critter-parity/fisher-1-10 dotnet test

GET /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 ​

Source-available under the Business Source License 1.1.