Brick Work

0024. The check runs its tests in parallel, its screen gate on several Chrome processes, and a docs-only change skips the app

Context

The check job took about 22 minutes (25 with setup) on every pull request, and almost all of it was scripts/check.sh. Measured on main, on a Mac with 8 cores, pytest tests/ took 844 s for 4,092 tests run one at a time:

Options considered

  1. A faster runner or a split into several jobs. Costs money or changes the job names that branch protection would require; the tests still run serially inside each job.
  2. Parallel tests (pytest-xdist), a docs-only fast path, a parallel screen gate (the maintainer's choice). Uses the cores the runner already has, keeps the single check job and its name, and each part is independent of the others.
  3. Trim the suite. Rules out nothing on its own, but means removing or weakening tests; refused.

Decision

Option 2, as three parts.

Parallel tests. scripts/check.sh runs pytest -n auto --dist loadgroup (pytest-xdist 3.8.0, pinned in requirements-dev.txt with its one dependency execnet). Tests that share Chrome or the screen gate's world folder go in the xdist group rendered (tests/rendered.py: @rendered.group, or pytestmark = rendered.group for tests/test_screen_gate.py); --dist loadgroup runs a group on one worker, one test at a time. rendered.chrome() fails a test that is not in the group, so a new Chrome test cannot forget it. Three things the move to workers showed:

The tests that only run a Node harness need no group. BW_RENDERED_SKIPS works with workers: the controller writes each skip once, without the group suffix, and check.sh lists them. The summary file BW_GATE_SUMMARY is written by the one worker that runs the gate. The separate smoke step is gone: tests/ includes tests/test_standalone_smoke.py, and since every step of check.sh runs whatever the earlier ones did, an early smoke step brought no earlier failure and only ran the tests twice.

Docs-only fast path. The workflow's Which check step asks scripts/check_scope.py BASE HEAD for a pull request: the files changed since the merge base (git diff --name-only --no-renames BASE...HEAD; the full history is fetched). When every one is documentation, check.sh runs in BW_CHECK_MODE=docs: the name check, the docs and site tests (tests/test_docs_*.py, test_site_pages.py, test_install_docs.py, test_changelog_order.py; they build the site and the demo it contains), the secret scan and the docs-range report. Documentation is docs/, scripts/site/, mkdocs.yml, CHANGELOG.md and README.md, except docs/brickwork/ and docs/data-model-4.0/: the API and MCP contract, the cutover table and the schema design, which app tests and check_api_contract.py read. design/ (the screen baselines among it) always runs the full gate. Pushes to main, a new branch (its before is all zeros, and a push event runs everything anyway), a force push, an empty or failing diff and any unlisted file run the full gate. The job is still check in the workflow check.

The screen gate: one Chrome session, as its own step. The maintainer chose a parallel screen gate too, and it was built: the capture jobs went out in batches of 12 to up to four capture_screens.cjs processes, each a fresh Chrome. On the Mac its captures stayed within the noise of two serial runs, but the deciding Linux run (PR #261) failed: captures differed in drop-down (<select>) text, about 1 px lower. Under full load several came and went (9, then 3); 52-Place at 1440 px, light, failed with the same 3,145 pixels in every run, also with the gate as its own step and one process. The cause is the session: the approved baselines were rendered in ONE Chrome session of all 198 captures, and what a session carries from page to page (the app's sessionStorage and localStorage, cached fonts and styles) shows in some captures; 52-Place came seventh in a fresh session instead of 151st. A leak between tests was ruled out by a diagnostic run (the worker's environment unchanged; on the Mac the captures were byte-identical alone and inside the parallel run). So, with the maintainer's choice ("One session by default"): - the gate renders in one Chrome session again, exactly as the baselines were made; BW_GATE_WORKERS > 1 renders in parallel batches for quick local review images only, never for a baseline or a deciding run; - scripts/check.sh runs tests/test_screen_gate.py as its own step (screen-gate, without xdist) after the parallel tests, so the busy cores of the test workers never share a run with it; - BW_GATE_KEEP=<folder> keeps the gate's actual captures for an inspection like this one. A gate whose captures do not depend on the session (clean storage and warmed fonts per capture, all baselines rendered again once) would let the captures run in parallel; that is a follow-up issue.

Consequences

Amended: the gate is parallel again (#267, 2026-11-11)

The follow-up named above was done. scripts/capture_screens.cjs, in the gate's deterministic mode, now starts every capture from an empty browser (it clears the cache, the origin's stored data and the web storage), loads every declared font face, and lays the page out again once the fonts are in. The cause of the 1 px was that last point: a form control laid out before its webfont arrived kept the fallback font's metrics in a fresh Chrome process, while a warm session had the font already; clearing storage alone did not change it (gate-agree on CI, 14 captures differed with and without it). scripts/screen_gate.py agree (and the gate-agree workflow) renders every approved state on one process and on four and compares them: on Linux, 198 captures, 0 differ. The gate's default is up to four processes again; BW_GATE_WORKERS=1 gives the single session back. Six states changed against the old baselines by a few hundred to a few thousand pixels (text 1 px lower in a drop-down); their baselines were rendered again in CI and approved by the maintainer.

Issue #254; ADR 0022 (rendered tests never skip silently, the screen gate); CONTRIBUTING.md (running the gate, the group rule, the docs-only path); design/DESIGN-CHECK.md.