doxa-maps/documentation/HOWTO-which-gates-need-a-browser.md
Doxa Maps a02458a171 documentation: which gates need a browser, and what exit 75 means
238 checks; 148 run anywhere, 87 need a real browser, 3 shell out. A newcomer
cannot tell from the name -- check:packet-render sounds like a file check and
drives a browser; check:composites-mount sounds like it needs one and does not.
The test is one grep for playwright/chromium, and the doc says so.

The part worth writing down is exit 75. run-parallel.mjs:262 defines three
outcomes, not two: 75 (EX_TEMPFAIL) is NOT MEASURED -- the gate could not look --
and a gate that exits 0 while announcing SKIP is also NOT MEASURED. Only another
non-zero is a FAIL. Two traps follow: exit 0 alone does not mean a gate looked at
anything, and your own `timeout` produces 124, which says you stopped it, not
that the code failed.

Also states the rule the split exists for -- one machine, one browser seat, so
one person runs them -- and why it is not just logistics: a source read and a
render are different measurements. Four catalogue gates can pass while a page
still paints English; and of 16 cards parked as "needs a browser", 7 were about
markup, a path, a manifest field or a gate, and settled headlessly.

Every number measured on main d8baaefd2 rather than estimated, including
correcting gates:pre from 13 to 15 after re-running it.
2026-09-28 08:31:18 +00:00

5.2 KiB

Which gates need a browser

There are 238 checks in this repo. 148 run anywhere. 87 need a real browser. If you are not the person who owns the browser seat, that second group is not yours to run — and knowing which is which saves you from a whole class of wrong answer.

The short version. A gate that reads files is headless. A gate that asks what the page actually painted needs a browser. Run the first kind freely; hand the second kind to whoever owns the browser.


Telling them apart

Open the gate's file and look for one import:

grep -l 'playwright\|chromium' internal/check-*.mjs

If it imports playwright or launches chromium, it needs a browser. That is the whole test — the name does not tell you. check:packet-render sounds like a file check and drives a browser; check:composites-mount sounds like it needs one and does not (it mounts every composite through Vite's SSR loader, no browser at all).

Measured on main (2026-09-28)

Count
headless — run these yourself 148
browser — not yours unless you own the seat 87
neither (shells out to bun/php) 3 — check:mobile, check:wp-embed, smoke:playground

The browser group is, broadly: everything that renders a map (check:map-paints, check:map-data-painted, check:auto-engine, check:maplibre-drops), everything about layout (check:fits-screen, check:shell-scroll, check:filter-width, check:scrim-viewport), everything that prints (check:poster-prints, check:print-geometry, check:packet-render), everything about right-to-left shaping (check:rtl-shaping, check:embedded-rtl), the English-leak family (check:english-leaks, check:leaf-english-leaks, check:host-locale-contract), and every smoke:*.

Why the split exists, and why it is not negotiable

One machine, one browser seat. Two agents running Playwright at once fight over it, and the loser does not fail — it reports that it could not look, which is a third outcome most people's mental model does not have (see below). So the rule is: one person runs the browser gates. In this repo that is the QA agent; ask them rather than starting a second browser.

This is not a limitation to work around. A source read and a render are different measurements, and confusing them is the most common way to report something false:

  • check:leaf-english-leaks can only find a leak by comparing what the catalogue says with what the page shows. Four catalogue gates can all pass — keys exist, differ per locale, are reachable, are in scope — while a page still paints English. Catalogue-green is not render-green.
  • Equally, a headless check often settles a question people assume needs pixels. Of 16 cards parked as "needs a browser", 7 turned out to be about markup, a path, a manifest field or a gate — all readable without one.

Ask what property the claim is about, then pick the tool. Not the other way round.


★ Exit 75 means NOT MEASURED. It is not a pass and it is not a failure.

This is the one that bites hardest, because it looks like success.

internal/run-parallel.mjs:262 states the contract:

THREE OUTCOMES, never two. Exit 75 (EX_TEMPFAIL) from ANY step is NOT MEASURED: the gate could not look (no browser seat, no slot, no built tree), which is neither a pass nor a failure. A gate that exits 0 while announcing SKIP is also NOT MEASURED. Only any other non-zero exit is a FAIL.

Two traps follow from that, and both have caught people here:

  1. Exit 0 is not enough. A gate can exit 0 and print SOMETHING: SKIP — it looked at nothing. The runner marks that NOT MEASURED; a human reading only $? records a pass.
  2. Your own timeout produces exit 124, which is also not a failure. Killing a gate at 180 s because you were impatient tells you nothing about the code. If you must bound a run, bound it generously and read exit 124 as "I stopped it", never as "it failed".

The runner prints all three: ✓ pass, ✗ fail, ⊘ not measured. Read the symbol, not the tail of the log — a truncated log with a green last line looks exactly like a pass.


What to do, depending on who you are

Landing a change: run bun run gates:pre (all headless, 15 checks, ~30 s on a fresh clone) plus whichever headless gates cover what you touched. Do not run the browser group.

You think a browser gate would catch your bug: say so, name the gate, and say what result would change your mind. A prediction someone else can falsify is worth more than a run you did yourself.

You own the browser seat: you are the only one. When a gate comes back NOT MEASURED, say "not measured" — resist reporting it as either colour.

A worked example of getting this wrong

check:packets-all reads generated packets from a gitignored scratch directory. It went red on 43 files left over from a run three days earlier, reporting "0 prayer tick boxes" about packets generated before tick boxes existed. Nothing was broken. The gate was grading leftovers.

The fix was not to loosen it: it now skips any packet older than the renderer that produces it, and says so. A stale input is NOT MEASURED, not FAIL — the same three-outcome rule, one level down.