Building the Docs & Running the Tests
Many pages on this site contain examples run in Field, with screenshots or videos of their output. You can rebuild the site with existing captures, or ask Field to run examples again. The test suites also drive Field through this machinery. This page covers both workflows.
For every flag and the internals, see AGENTS.md and
dev_docs/testing/TESTING_STRATEGY.md in the
repository.
Prerequisites
- uv — runs the Python tooling. The commands
below use
uv run --project . --group <name>so the right dependencies are fetched automatically; no manual virtualenv needed. If a docs build cannot find Zensical, installuvrather than installing Zensical by hand; thedocsgroup inpyproject.tomlalready pins it. - A JDK + the Gradle wrapper (
./gradlew, already in the repo) — builds and runs the Field JVM../field.shbuilds the app through Gradle and launches it if you just want the app. - Everything runs against a Field server. The docs and test runners start
one for you on a port you choose. Pick a dedicated high port like
9477, not the one your interactive Field is on.
Building the docs
One command builds the site, re-running example captures according to a mode:
:::static
FIELD_DOCS_RUNNER_MODE=incremental FIELD_DOCS_RUNNER_PORT=9477 \
uv run --project . --group docs python tools/build_zensical_docs.py
The mode controls how much Field work happens:
FIELD_DOCS_RUNNER_MODE |
What it does | Use when |
|---|---|---|
never |
Skip all capture execution; reuse whatever screenshots/videos already exist. Doesn't start Field. | You only touched prose/HTML and want the fastest rebuild. |
incremental |
Reuse valid cached captures, run only changed or missing ones. | Normal authoring loop against an already-clean cache. |
strict |
Invalidate captures when the runner code or a transcript changes (via a digest), then re-run those. | You changed capture/runner logic and want stale captures caught. |
always |
Regenerate every screenshot and video, ignoring the cache. | A full recapture, or when you don't trust the cache. |
In every mode except never the runner insists on a Field it started itself. If a
Field it did not start is answering on FIELD_DOCS_RUNNER_PORT, the build stops and
names that Field's PID and workspace rather than shutting it down; unset the
variable and the runner picks a free port.
Handy flags for tools/build_zensical_docs.py:
--page <name>— only run captures for matching page(s) (stem, filename, or glob); other pages still build but with modenever. Use it to iterate on one page:--page fline_examples.--skip-build— regenerate the preprocessed sources without running the final Zensical site build.--clean— wipe the built site first.
The built site lands in public/field4/. Preview it the way Field's own docs
iframe loads it:
:::static
cd public && python3 -m http.server 8000
# then open http://localhost:8000/field4/
Monaco, Iosevka and the offline viewer are vendored under src/ rather than
fetched, and tools/sync_offline_assets.py copies them into the built site so
that the read-only viewer iframe resolves without a running Field.
uv run python tools/check_offline_assets.py checks that every canonical source
is still there and that no synced copy has been committed back into public/ or
dev_docs/. Run it after moving any of those assets around; it needs no Field
and no network.
A red error marker is a real failure. If a rebuilt screenshot shows Field's red error marker, or empty/incorrect output, treat it as a genuine failure — not a flaky capture. Fix the snippet (or the runtime), then rebuild that page.
Optional warm-session mode. Set
FIELD_DOCS_RUNNER_SESSION=1to route eligible captures through one persistent, pre-warmed Field session instead of a fresh process per capture. It is opt-in and falls back to the ordinary capture path after a configured session failure.
Checking whether a published figure is still true
A green docs build does not tell you, because a cache hit and a correct figure look the same from outside. Two tools help.
The capture cache keys each figure on the fence's own code, its options, a digest
of the runner machinery, and a digest of the source files the fence's code text
names (sourceRefsDigest), mapped through the committed
codebase reference. So an incremental build re-captures
the figures a stdlib edit can reach directly. What it cannot see is an
indirect change — a function called by the function you edited.
FIELD_DOCS_SOURCE_REFS=stdlib hashes the whole indexed stdlib for every fence
instead, which catches that at the cost of re-capturing everything; =off drops
the source digest, leaving a fence keyed on its own code and options alone.
For a direct answer, tools/docs_capture_audit.py re-runs captures from the
current code and classifies each result against the measured run-to-run noise
floor, so it can say "stale" rather than merely "different":
:::static
# does this page's committed figures still match the code? (two runs = rigorous)
uv run --group docs python tools/docs_capture_audit.py --page functions_examples --repeat 2
# no Field, instant: rank capture assets by when they were last regenerated
uv run --group docs python tools/docs_capture_audit.py --inventory
# no Field: which source files does each fence reach, and what would touching one invalidate?
uv run --group docs python tools/docs_capture_audit.py --plan --touched src/jvmMain/kotlin/afield/math/functions/Functions.kt
Captures are not byte-reproducible — only a handful are byte-stable run to run — so
the comparison is per pixel against git show HEAD:, with jitter (≤0.02% of pixels
at max channel delta ≤19) reported separately from a genuine re-render, and
--repeat 2 distinguishing a nondeterministic capture from a stale one. The
working tree is restored after every run; the regenerated files are kept under
.field-backdoor/docs-audit/<timestamp>/ so you can look at the before/after
yourself. Full background, including the noise-band measurements, is in
dev_docs/docs_capture_staleness.md.
Watching progress & debugging a build
The runner prints per-capture progress as it goes, so a slow build is distinguishable from a hung one:
:::static
Field docs runner [12] capturing fline_examples.md -> arcs_arcto.png
Field docs runner … still capturing fline_examples.md -> arcs_arcto.png (8s)
Each capture logs a numbered start line; if one runs long (for example while a
graphics-context capture starts Field), a heartbeat prints every 8s so you can
see it is alive. Progress is INFO-level and on by default;
set FIELD_DOCS_LOG_LEVEL=WARNING to quiet it or =DEBUG for more.
The same INFO channel carries one summary line at the end of a build. It
counts regenerated= against skipped= and breaks both down (screenshots=
and videos=; cache_hits=, reused_existing= and missing=), then reports
failures=, warm_session_captures=, server_starts=, server_restarts= and
elapsed=. Read that line first when a build was suspiciously fast or quiet:
a run that regenerated nothing and skipped everything otherwise looks like a
successful one.
To watch the capture browser — a capture that stalls, or one that renders
wrong — run the build with AFIELD_PLAYWRIGHT_VISIBLE=true:
:::static
AFIELD_PLAYWRIGHT_VISIBLE=true FIELD_DOCS_RUNNER_MODE=always FIELD_DOCS_RUNNER_PORT=9477 \
uv run --project . --group docs python tools/build_zensical_docs.py --page fline_examples
That launches a visible, GPU-accelerated Chrome (instead of the headless capture
browser) that you can attach DevTools to for profiling. It's an isolated
Playwright profile, separate from your own Chrome. Server logs for a stuck
capture are under .field-backdoor/logs/.
To profile a piece you are writing rather than the capture browser (the renderer's span timer, the per-resume counters, and the JFR sampler), see Holding a frame budget.
Running the tests
Smoke catalog. Exercises the annotated FIELD_TEST examples through the
backdoor:
:::static
uv run --project . --group backdoor python tools/run_field_tests.py --refresh-catalog
--refresh-catalog rebuilds build/field-tests/catalog.json first, then runs
each discovered test one at a time. The runner prewarms one persistent Field on
--port for the whole run (pass --no-prewarm to reuse an already-running
server instead). Each test's timeout is the timeoutSec in its annotation.
Committed generated assets. Several things in the repo are generated from
source and then committed, so that a change to one shows up as a reviewable diff.
./gradlew check re-derives each and fails if the committed copy has drifted:
| task | guards | fix a failure with |
|---|---|---|
checkCodebaseReference |
public/docs/reference/ and dev_docs/kdoc_worklist.md |
./gradlew generateCodebaseReference |
checkKDocAttachment |
every KDoc block in src/ attaching to the declaration below it |
move the block — see dev_docs/kdoc_reference_contract.md |
checkAgentTranscripts |
public/docs/assets/agent_transcripts/, the welcome screen's starting points |
./gradlew regenerateAgentTranscriptStartingPoints |
checkAgentTranscriptIndex |
public/docs/agent_transcripts.md being a true index of that corpus |
write the missing entry |
checkCompletionCorpusFixtures |
tools/completion-corpus/boxes after real macro expansion |
re-run it with AFIELD_HARVEST_WRITE=1 |
checkAgentDemos |
a dev_docs/agents/demos inline fallback matching its sidecar |
./gradlew regenerateAgentDemos |
citationGates |
every path:line and path#symbol cited by a guarded page |
fix the citation |
checkPythonTools |
the tools/test_*.py suites |
fix the test |
None of them needs a Field server, a GPU or a network, and none of them writes a committed asset while checking: they generate into a temp directory and compare.
Browser UI regressions. Drives Playwright-backed UI workflows and keeps screenshots:
:::static
uv run --project . --group backdoor python tools/run_ui_regressions.py --port 9490 --timeout 45
It prints RUN / PASS / FAIL lines; artifacts are retained under
build/ui-regressions/artifacts/<test-id>/ (including latest-pass.png and
latest-failure.png).
What both runners share
Both pipelines talk to Field through tools/field_backdoor.py. Each puts a
single Field server on your chosen port, creates scratch boxes in it, runs the
example code, and screenshots the resulting output areas — the same ones you see
when you use Field interactively. They differ over who owns that server: the
docs runner always starts its own, as above, while run_field_tests.py prewarms
one by default and attaches to a server already listening on --port if you
pass --no-prewarm. Either way one server serves many captures, so keep runs on
a dedicated port. When something misbehaves, the backdoor's logs under
.field-backdoor/logs/ are the first place to look.
Once a branch passes locally, it goes onto main through the landing queue rather
than a hand merge: see Landing on main.