How bmad-loop is tested: the layer model, the doctrines behind the suite's shape, and the policies a new test is expected to follow. AGENTS.md owns the terse testing invariants agents must not violate; this document is the strategy behind them: why each rule exists, where a new test belongs, and which absences are decisions rather than gaps. When the two disagree, fix this document first and re-derive the terse rule (see Doc-sync rules).
The one-line version: deterministic tests over a real repo sandbox, zero LLM tokens anywhere, and every negative assertion proven by ablation.
- Flat
tests/:test_*.pyfiles mirroringsrc/bmad_loopmodules by name, no package nesting, onetests/conftest.py, and a smalltests/fixtures/data dir (stories.yaml, the stories-mode contract, plus the captured claude trust-prompt pane log thattest_generic_tmux.pyreplays). - Configuration is four settings under
[tool.pytest.ini_options]inpyproject.toml:testpaths = ["tests"],asyncio_mode = "auto",addopts = "--dist loadgroup", andfilterwarnings. --dist loadgroupis a scheduling declaration. It makes thexdist_groupmarks on the real-tmux E2Es bite; under xdist's defaultloadscheduler those marks are silently ignored. It lives inaddoptsso a local-n logicalrun schedules exactly as CI does. It makes pytest-xdist (adev-group dependency) a prerequisite: apytestfrom outside the uv environment aborts withunrecognized arguments: --dist.- Deprecations are errors.
filterwarningscarrieserror::DeprecationWarninganderror::PendingDeprecationWarning, so a new deprecation fails the test that triggers it. A test that expects one usespytest.warns(DeprecationWarning). A dependency warning we cannot fix gets a targetedignore:<message regex>:DeprecationWarning:<module>entry after the twoerror::lines, never a blanket category ignore. The filter covers only the pytest process. uv run pytest -qruns everything runnable on the host.-n logical(pytest-xdist) is supported because every test builds its state from per-test fixtures. Preferlogicaloverauto: withpsutilinstalled,autoresolves to the physical core count, half the vCPUs on hosted runners. A test that fails only under xdist load is a flake, which is a bug. Live/E2E modules skip themselves when their host requirements are absent.- No custom markers, deliberately. Only builtin marks appear (
parametrize,skipif,usefixtures,xfail(strict=True)to pin a known defect) plusxdist_group, which pytest-xdist registers itself. A marker registry is a second selection mechanism that can drift from the tests it classifies; the filename suffix and the module-levelskipifencode the same fact.xdist_groupclassifies scheduling, not a test category, and appears only on the real-tmux E2Es, all through the singlereal_mux_e2ealias intests/conftest.py.
New behavior lands with a test at the lowest layer that can catch its regression. The layers, from cheapest to most expensive:
| Layer | What it is | Machinery |
|---|---|---|
| L1 — pure core | Unit tests over the pyright-strict modules: model, statemachine, policy, documents, machine, checks, sanitize, plugins/digest, plugins/grants. No process, git, or mux I/O; fixtures limited to tmp_path/monkeypatch. |
Plain asserts; exhaustive parametrize grids |
| L2 — seam | Tests that drive one injection seam with a scripted double. | MockAdapter, TOML adapter profiles, the force_tmux_backend fixture, the injectable make_adapters and engine-class parameters of runsetup.compose_run (engine_cls, stories_engine_cls), compose_sweep (sweep_engine_cls), and compose_resume (all three) |
| L3 — sandbox integration | Engine/CLI flows over a real git repo, no multiplexer. | The project fixture (a copied template repo) plus the conftest effect helpers |
| L4 — real-mux integration | A real tmux server drives a scripted shell script standing in for the CLI. | The HAVE_TMUX-gated tests in tests/test_generic_tmux.py |
| L5 — live gates | Real external binaries, still zero tokens. | tests/test_stories_e2e.py, tests/test_opencode_live.py, tests/test_opencode_live_turn.py, tests/test_psmux_live.py |
Placement rules:
- Mirror-file rule. A test for
src/bmad_loop/<module>.pylives intests/test_<module>.py; subpackage modules flatten into prefixed names (tui/app.py→test_tui_app.py,plugins/loader.py→test_plugin_loader.py), and adapter internals are covered through their transport suites. Cross-module flows live with the module that owns the behavior under assertion (engine flows intest_engine.py, CLI contract intest_cli.py). A module without a mirror file is a smell; see Gaps and deviations. - Suffix convention is the selection mechanism.
*_e2e.pyand*_live.pyname the modules with host requirements; each carries a module-levelpytestmark = pytest.mark.skipif(...)naming its requirement (HAVE_TMUX,HAVE_OPENCODE,HAVE_OPENCODE_V2,HAVE_PSMUX).HAVE_OPENCODEruns the binary's--version, so a resolvable but dead shim skips instead of failing;HAVE_OPENCODE_V2also requires that version to be 2.x. Ordinary runs collect and skip them; a capable host runs them with no extra flags. Do not add a marker, an env-var opt-in, or a separate pytest invocation for these. - Prefer a lower layer over a broader one. If a defect is expressible as a pure-core case, do not pin it in a sandbox flow: L3+ tests are slower, and their failures triangulate worse. The inverse also holds: a seam contract (say, adapter timeout instrumentation) cannot be proven by an L1 test and belongs at L2 with a parity twin (see Duplicate tests and contract parity).
tests/conftest.py holds fixtures for lifecycle and isolation only; everything else is a
plain function or constant a test imports by name. The fixtures:
- Sandboxes.
projectis the workhorse: a disposable copy of a session-scoped template repo (_project_template, built once per xdist worker) with BMAD-shaped artifact dirs and an initial commit. Never hand-roll a temp repo and never touch the template: a mutation there poisons every later test in the worker.project_treeis the same file tree with no Git, opted into only by tests whose code path never consults a repository. - Backend pins.
force_tmux_backendandforce_psmux_backendpin the transport backend by name and clear the selection cache, so tests asserting backend argv cannot be hijacked by the host's default or an installed external backend. - Autouse isolation.
_isolate_ambient_git_ignores(session-scoped) shadows the developer's global gitignore sources (GIT_CONFIG_GLOBAL,XDG_CONFIG_HOME), so a dev box that globally ignores.claude/cannot make shield tests measure the wrong thing._isolate_state_rootpointsBMAD_LOOP_STATE_DIRat a per-test directory (allocated by_state_root_allocator), becauseruns.state_root()creates directories in the real state location._isolate_mux_registrykeeps the psmux registry root and the inside-a-pane marker out of the cross-test environment. - Per-test helpers that need teardown.
emulate_windows_newlinesmakesPath.write_texttranslate newlines as Windows does, so a "no CRLF" assertion can fail on Linux.reap_leftoversforce-kills pids a failing row may have leaked.
Everything else is a plain helper (from conftest import write_spec, dev_effect, machine_json, ...). A fixture is ambient, invisible at the call site and to grep, so it is
reserved for setup and teardown that must wrap the test. Construction and simulation helpers
are explicit imports, so each test names its dependencies. Local fixtures inside a single test
file are fine for file-scoped concerns.
Two helpers carry suite-wide contracts:
machine_json(argv, capsys, *, rc=0, err_contains=None)runs a--jsoncommand and parses the whole of stdout. Parsing the full stream is itself the assertion that nothing but the document was printed (themachine.pypurity contract);err == ""is the default, anderr_containsopts into a different assertion, never a waiver.dev_effect/review_effectand their bundle twins simulate what a real skill session does to disk, and just as deliberately what it does not do:dev_effectnever writes sprint-status, because the orchestrator is the single sprint-status writer at dev time.review_effectadvances the board when it finalizes, as real review sessions do; the bundle twins write none, because bundles have no sprint-status entry.
Divergent-root tests choose a topology for the distinction they need to prove:
- Default (
project) keepsproject == repo_root. Use it for ordinary sandbox behavior, never for an assertion that claims to distinguish those roots. - Sibling places
repo_rootoutside the BMAD project (write_repo_root_override, reloaded throughload_paths). Use it to prove that code commands run in the configured root while artifact reads stay in the project. It cannot grade pathspec selection by outcome alone. - Nested monorepo (
nested_repo_root_paths) puts the BMAD project at<repo>/app. Use it when both right- and wrong-root pathspecs must be non-empty and separable. - Isolated worktree adds a third live root. Use a real relative command against a marker created only in the mounted unit worktree, and assert the main checkout lacks it.
MockAdapter is production code (src/bmad_loop/adapters/mock.py, shipped in the wheel),
scripted with a list of SessionResults or callable(spec) -> SessionResult effects. No
configuration reaches it, and it exercises the generic adapter path only: adapter-specific
behavior needs an L2 profile test or an L4/L5 gate. It carries no profile attribute on
purpose; attach_profile in conftest opts in where the resolved skill name is under test.
A test asserts its premise before its outcome: before checking the result, it proves the setup is the situation the test claims to exercise. A negative outcome ("nothing was published", "the gate refused") passes for every reason the value could be absent, including a setup that never reached the code under test.
- Assert the planted state is really on disk (the entry is
done, the cache is valid, the tree starts clean) before acting on it. - For divergent-root tests, compare resolved roots, assert the nesting when it matters, and
plant markers in both directions (
plant_root_markers,REPO_ROOT_MARKER_CMD,PROJECT_MARKER_CMD): one identifies the intended root, the other rules out the tempting alternative. - When a wrong answer could still name a real path, create that decoy and assert the selected value, rather than asserting only that some path is absent.
A slice of the suite tests the repo rather than the product. Each guard's test docstrings carry its full rationale and blind spots.
| Guard | Test file | What it prevents |
|---|---|---|
| Portability guard | tests/test_portability_guard.py |
tmux argv, git spawns, POSIX-only calls, BMAD_LOOP_* env reads, spec-path anchoring and encoding-less text I/O escaping their sanctioned seams |
| Kind & refusal inventories | tests/test_portability_guard.py |
an undeclared journal kind, patch carrier, splat hole, _refuse_*/_reject_* helper or isolation-refusal call site landing without a PR-time decision |
| Settings-schema sync | tests/test_settings_schema.py |
core.toml drifting from the policy.py dataclasses: a spec without a live field, a mismatched default, or an unclassified field |
| Exit-code allocation | tests/test_entry_point.py |
renumbering an ExitCode or quietly allocating one outside {0, 1, 2, 130} |
| Extra-less core CLI | tests/test_entry_point.py |
the core CLI crashing without the [tui] packages; tui must degrade to an install hint instead of a traceback |
| State-machine table | tests/test_statemachine.py |
a Phase without a transition row, terminal phases out of step with the table's dead ends, or an illegal move that lands |
| Check-id registry | checks.py + tests/test_cli.py |
a validate check id emitted on an executed path without being in VALIDATE_CHECKS (exercised paths only; no static scan) |
| Skill-drift guard | tests/test_module_skills_sync.py |
seeded .claude/skills and .agents/skills forks drifting from the canonical skills (dev boxes only; CI has no forks) |
| Schema-version parity | tests/test_tui_app.py |
the TUI rendering a newer validate document schema as the old one after a CLI bump |
| Installed-copy drift | tests/test_hook_script.py, tests/test_probe_hook.py |
the packaged hook relays drifting from their source (wheel-installed runs only), or init writing a workspace relay copy |
| Version sync | tests/test_release.py + CI |
version fields disagreeing, or a release leaving ## [Unreleased] and its compare link unreopened |
| Real-mux xdist grouping | tests/test_conftest.py |
real-tmux E2Es scheduled onto concurrent workers: each must carry exactly the real_mux_e2e group, honored by --dist loadgroup |
| Tmux SessionSpec + reap walls | tests/test_conftest.py |
load-sensitive literal timeouts in real-tmux waits; they must use REAL_MUX_HANG_CEILING_S |
| Deprecation posture | tests/test_conftest.py |
deprecation warnings passing silently, including through a later blanket ignore that leaves the error:: entries inert |
| Docs hygiene | tests/test_docs_hygiene.py |
broken relative links and anchors in the docs, and a stale generated policy table in the configuration reference |
Rules for adding or touching a guard:
- Fix the source, not the allowlist. A new finding means routing the call through the seam; widening an allowlist is the last resort and needs the same scrutiny as a production change.
- Exemptions are scoped and acknowledged. Most file-keyed allowlists (paths, detach,
kill-probe,
shell=True) also require a per-line# portability:ack. Whole-file passes exist only where the file is the sanctioned spot (the tmux backends;process_host.pyforos.kill), andverify.py's git exemption is narrowed to the_run_gitargv position. Env-read exemptions are scoped by variable name or family, never by file. - The detector itself gets executable coverage. Source detectors are split (
_scan_sourceand its siblings) so probe fixtures run the same code path as the real scan, with a must-flag row per claimed access form and a must-stay-silent row per lookalike. When a new form turns up, add the failing probe row first, then fix the detector. A green "no findings today" assertion cannot grade a detector: it is equally green when the scan silently stopped scanning. - The guard's boundary is
src/bmad_loop, enforced by its scan root rather than an allowlist. Guards whose subject is the suite's own scheduling and waits are rooted attests/and live intests/test_conftest.py, so the portability guard's scan root stays the boundary. Tests,scripts/, and CI workflows deliberately spawn their own git: a harness must not depend on the artifact it validates.
No test, at any layer, ever spends an LLM token. AGENTS.md states the hard invariant for the live/E2E gates; in practice it holds everywhere, and the mechanism differs per gate:
| Gate | Real component | Zero-token mechanism | Runs where |
|---|---|---|---|
test_generic_tmux.py (HAVE_TMUX) |
tmux server | The spawned "CLI" is a tiny shell script written by the test | CI Linux job + any POSIX dev box with tmux |
test_stories_e2e.py |
tmux + the real bmad-loop run/resolve/resume CLI |
Scripted fake claude variants (bash string constants) wired in as a custom TOML profile; the fake writes its own SessionStart/Stop hook events |
CI Linux job (the fakes use GNU coreutils + setsid) |
test_opencode_live.py |
A real opencode serve HTTP server |
Never sends a prompt: only spawn/teardown and the health/OpenAPI/session/event endpoints of the detected major (1.x or 2.x); the prompt endpoint is checked against the OpenAPI schema, never called | Manual: a POSIX box with a runnable opencode |
test_opencode_live_turn.py |
A real OpenCode 2.x server running one full turn | Prompts reach only a local fake: the one test that prompts sends every LLM request to an in-process stdlib OpenAI-compatible server on 127.0.0.1, under a scrubbed env (scratch HOME/XDG, no credentials), a mock-only provider config and a deny on the hosted provider, and asserts the fake saw every request | Manual: a POSIX box with a runnable OpenCode 2.x opencode |
test_psmux_live.py |
A real psmux on Windows | Parked windows only: every window runs pwsh -NoProfile -Command exit 0. test_premise_* probes are inverted (red means a workaround became droppable); test_adopted_* probes are ordinary |
CI Windows job + any Windows dev box with psmux 3.4.0+ and pwsh |
Never "fix" a gate to call a real CLI, and never add a completion path that trusts LLM output: sessions complete only on hook Stop events or window death, and the fakes exercise exactly those paths.
Manual-gate cadence: the two test_opencode_live*.py modules are the gates CI cannot run.
Run them on a POSIX box before every release and after any change to the adapter they cover,
once with an OpenCode 1.x and once with a 2.x opencode first on PATH
(PATH=<dir>:$PATH uv run pytest tests/test_opencode_live.py tests/test_opencode_live_turn.py -q).
The rule (AGENTS.md): for any test asserting "X is refused/absent", delete the gating code and confirm the test fails before trusting it. The suite records performed ablations in-file, in one house grammar:
-
The canonical form is a prescriptive, undated sentence in the test's docstring (or a nearby comment), naming the exact mutation and the expected failure:
Ablation target: delete the
if raw is None: return Noneearly-out and this test fails alone, on the TypeErrorfloat(None)raises — which the reader'sexcept ValueErrordeliberately does not catch.Accepted spellings:
Ablation:(the most common),Ablation target:/Ablation targets:,Ablation guard:,Ablations:,ablation pin:, and letteredABLATION A1,A2, … where a test carries several. -
INVERSE ablation:is used when deleting the gate would not reproduce the bug (the gate is an absence, or the bug needs the old behavior restored). The record states the inverse mutation: restore the old code, or add what the rule forbids. -
Green-ablation records note that a mutation deliberately does not redden this test, naming the test that does pin it where one exists: a warning against miscounting coverage.
Two disciplines around the grammar:
- Records are evidence, not contracts. A record certifies the ablation run when the test
was written or last audited. Touching the gate re-opens the question: re-run the ablation
against a
cpbackup of the source (nevergit checkout, which can destroy in-flight fixes), and restore byte-identical. Dates, pasted assertion output, and run logs belong in the commit message, not the file. - Ablate singly. Two gates deleted together redden everything and grade neither.
A known defect is pinned with xfail(strict=True), its reason naming the defect.
strict=True is the point: the day the defect is fixed, the test fails and forces the note to
be removed with it.
- Staged pyright, deliberate.
typeCheckingMode = "basic"oversrc/bmad_loop, with the strict list covering the pure/leaf modules (model,statemachine,policy,documents,machine,checks,sanitize,plugins/digest,plugins/grants). Strictness lives where the code is pure.src/bmad_loop/datais excluded: data-shipped scripts are standalone templates outside the import graph. - Three
ossurfaces. CI also runs pyright with--pythonplatform WindowsandDarwin, whoseosstubs lack POSIX/Linux-only names (O_DIRECTORY,setxattr,sysconf). Code that needs such a name fetches it with a default-freegetattrinside its runtime check. tests/andscripts/are outside pyright, deliberately. Tests get their rigor from ablation, parity, and the guards. Do not add them to the include list as a drive-by.- Pyright is pinned exactly in the dev group: it is a gate, and a newer pyright adds checks
that fail the build on untouched code. Bump the
==pin deliberately. - Ruff rule families are pinned (
E4,E7,E9,F), so CI (trunk's pinned ruff) and a bare newer ruff share rule families. Do not ratchet stricter than CI without moving CI first; the[tool.ruff.lint]comment in pyproject.toml is the authority. - Formatting is trunk's (black/isort/prettier/markdownlint);
trunk checkbefore every push is the contract, and the pre-push hook enforces it.
Eight jobs (.github/workflows/ci.yml):
- test: ubuntu, Python 3.11–3.14, tmux installed so L4 and
stories_e2erun. - Three Windows jobs (
PYTHONUTF8=1). Pull requests run the 3.11/3.14 boundary only; pushes tomainandrelease/*run the full spread. - version-sync:
scripts/release.py check. - lint: trunk, including actionlint and zizmor over the workflows themselves.
- typecheck: the same pinned pyright a contributor runs, plus the Windows and Darwin target checks.
- build: packaging smoke. Builds the sdist and wheel, runs the console script from the
installed wheel, and checks the wheel's data files against
git ls-files; every other job runs from the source tree.
The Windows jobs split one leg across machines without dropping a test:
- test-windows-shard runs the suite minus
test_psmux_live.pyas two shards per Python leg,--ci-shard=1/2and2/2(tests/perf_report.py). A shard receives whole units: a test function with every parameter variant, or a whole xdist group, so a real-mux group can never split. Units are assigned by a SHA-256 digest of the unit's nodeid, never Python's randomizedhash()or a file list, so a new test is assigned automatically and existing tests do not move. The digest balances test counts, not seconds. - test-windows-live installs one pinned psmux release and runs the L5 gate once per leg,
serially, on a machine of its own. The pin, not
latest: the gate'stest_premise_*rows turn red by design when a psmux release fixes what they pin, so an upstream release must never reach a PR's checks. Moving the pin is its own PR, which also moves the version floor and retires the workarounds. The non-required psmux canary workflow (weekly, or on demand) runs the same gate against the latest release and goes red when that PR is due. - test-windows keeps the check name
test (windows, py3.x)and is the leg's verdict.perf_report.py verify-shards --count 2requires both shards present and green, their selections disjoint, and their union equal to the collected set, with no function family straddling shards.verify-executedrequires every live-gate test to have passed; a skip there means the gate did not run.verify-jobsrequires each of the leg's jobs to have concludedsuccessin its latest execution, so a re-run that fails before uploading cannot pass on an earlier attempt's records.
Sharding buys latency, not work: every shard pays its own runner setup and a full collection. It stays only while the extra aggregate job-seconds remain within 15% of the unsharded layout.
Zero-retry flaky policy. There is no retry mechanism anywhere: no --reruns, no retry
plugin, because an installed retry plugin is an invitation to paper over a real flake. A
flake is a bug: it gets a diagnosis and a deterministic fix, never a rerun loop. The main
defense is the frozen-clock pattern, mandatory for time-dependent tests: no unit or seam
test sleeps toward a deadline (the real-process gates use short settle polls, which is
different from waiting out a production timeout). Two sanctioned shapes:
- A scoped
_Clockshim over a mutable dict, monkeypatched in as the module-under-test'stimereference; a scripted watcher callback advances the dict past the deadline (test_generic_tmux.py, with an identically shaped_install_clocktwin intest_opencode_http.py). - Production parameter injection where the seam already exists:
SignalWatcher.wait_fortakesclock/sleepcallables, and the test's fakesleepcredits the fake clock (test_signals.py).
Deliberate absences, decisions rather than gaps:
- No coverage gate. A percentage threshold optimizes for the metric, and this suite's
riskiest surfaces (guards, ablations, parity) are exactly what line coverage cannot grade.
Coverage runs on demand when auditing a module:
uv run --with pytest-cov pytest --cov=bmad_loop --cov-report=term -q. - No macOS runner. POSIX portability is held by the seam quarantine plus the portability
guard; macOS-specific traps found in the field get targeted unit tests (the psutil
create_timetrap is the canonical example). - No WSL2 runner. Hosted runners cannot host WSL2, so run the suite there by hand, from a
clone inside the WSL filesystem: a Windows worktree reached through
/mnt/ccarries aC:/gitdir that WSL git cannot resolve. A test that spawns an interpreter usessys.executable, never a barepython(Ubuntu ships onlypython3). uv's standalone CPython has noos.pidfd_open, so the pidfd reap rows skip viaconftest.needs_pidfd; to run them, build the venv on the distro Python withuv venv -p /usr/bin/python3. - No opencode install in CI, so the
test_opencode_live*.pymodules are the last manual gate (see The zero-token invariant); faking the server would test the fake. psmux, one zip on a GitHub release, is installed by test-windows-live instead.
Default invocation is serial. uv run pytest -q runs one process: correct everywhere,
slowest everywhere. Parallelism is opt-in with -n logical. Compare like with like: a serial
local run against a four-worker hosted run measures the worker count, not the change.
The 2-vCPU Windows comparison (a small VM or WinBoat guest) is two commands, in this order, never alongside another full-suite run on the same guest:
$env:PYTHONUTF8 = '1'
uv run pytest -q -n 2 --ignore=tests/test_psmux_live.py
uv run pytest -q tests/test_psmux_live.pyKeep the checkout and TMP/TEMP on the guest's own disk; a host-shared or network folder
measures the share. The live gate runs separately and serially because its tests drive real
psmux servers, a single-machine resource.
Metrics (tests/perf_report.py, inert unless asked for) answer where the time went,
including for a run that never finished:
uv run python scripts/run_test_benchmark.py --metrics-dir <fresh-dir> -- -q -n logical
uv run python tests/perf_report.py summarize <fresh-dir>The wrapper owns the pytest process lifetime, preserves pytest's exit status, and with
--timeout/--deadline-epoch stops the whole tree (exit 124) while leaving every flushed
record behind. Pytest alone takes --test-metrics-dir=PATH, spelled with =. The summary
keeps wall-clock elapsed apart from worker-seconds summed across xdist workers, and reports
fixture setup totals, per-file cost, the cleanup tail, and tests started but never finished.
--test-metrics-subprocesses adds per-test, per-command and per-call-site launch counts; it
has real overhead and stays off in CI. Recorded data carries no environment values, output, or
argv beyond a program name and git verb. Metrics directories are run artifacts: CI uploads
them (test-metrics-*) and they never go into Git.
Budgets. Each test job has a hard timeout-minutes cap, and its test steps stop a few
minutes short of it through the wrapper's deadline, so a wedged run ends as a red step with its
metrics uploaded rather than as a cancelled job. Change a cap and its deadline arithmetic
together (the workflow comments name both). The engineering targets for the Windows legs are
an ordinary-suite median of at most ten minutes and a completed Windows check within fifteen,
judged on at least three hosted samples per boundary Python version. These are targets, not
guarantees. A red sample is recorded as a result, never rerun into a green one.
The TUI suite is split by what can regress, across four files:
| File | Covers | Textual required? |
|---|---|---|
test_tui_data.py |
The pure filesystem observation layer: data correctness | No (one test enforces that tui/data.py imports no textual), but needs the tui extra |
test_tui_launch.py |
Exact tmux/CLI argv built by tui.launch, against monkeypatched subprocess; module-wide force_tmux_backend |
No |
test_tui_app.py |
Coarse Pilot smoke over the dashboard: mount, run table, pane polling, keybinding → modal → tui.launch wiring (no real tmux) |
Yes |
test_tui_settings.py |
PolicyDoc edit-model semantics plus Pilot tests of the settings screen (minimal diff on save, invalid values blocked) |
Yes |
Doctrine:
- Correctness lives below the widget layer. Data assertions go in
test_tui_data.py, argv assertions intest_tui_launch.py;test_tui_app.pyproves wiring, coarsely, with the shareduntil(pilot, condition)poll helper. A fine-grained assertion inside a Pilot test is usually in the wrong file. - Without the
tuiextra, the TUI files fail at collection with ImportError, not skip. Deliberate. The extra is a dev prerequisite (uv sync --all-extras), so an environment missing it is a broken dev setup that should fail loudly rather than silently run hundreds fewer tests. - Snapshot testing is rejected, with revisit triggers.
pytest-textual-snapshotis unverified against the textual 8 line this repo pins, and every TUI gap observed so far has been behavioral (data, argv, wiring, liveness gates), which snapshots do not catch, while snapshot churn taxes every layout tweak. Revisit if the plugin proves out on textual 8+, or a real regression escapes because it was purely visual.
- A true duplicate is consolidated at the mirror file of the function it covers, superset assertions kept.
- Identical names across files are otherwise deliberate contract parity. The
timeout-instrumentation and token-budget blocks in
test_generic_tmux.pyandtest_opencode_http.pyhold identically named tests over two transports: thetest_generic_tmux.pyheaders state the contract (a behavior change lands in both or records the divergence) and thetest_opencode_http.pyheaders point back. A cross-link must exist on both sides, because a one-way pointer rots. - Layered coverage is not duplication.
test_envvars.pyandtest_engine.pypin the same rejection rows at two layers ("what does the parse return" vs "does the policy default survive" are different claims), and each states its parity with the other. - A shared parametrized contract module is rejected as over-abstraction. Hoisting the parity blocks into one module parametrized over transport would hide the transport-specific setup that is most of each test's meaning, and the divergence note is the valuable artifact.
The honest register. Each row is a known gap or a documented deviation with its rationale.
| Item | Status |
|---|---|
machine.py has no mirror test file |
Known gap |
checks.py, documents.py, fences.py, workspace.py lack mirror files and are reached only from consumer suites (test_cli.py, the conftest harnesses, the engine/worktree files) |
Known gap; add a mirror file when a regression risk surfaces that consumer coverage cannot catch |
unity_cleanup.py (data-shipped Unity script) has no behavioral tests |
Known gap; test on next touch |
| TUI Attention pane render path (append, truncation reset, toast) is untested | Known gap |
"Legal phase transitions live only in statemachine.py" is comment-enforced, not guard-enforced: direct task.phase = writes outside the state machine each carry a prose ack |
Deviation, accepted: the writes are deliberate resets and latches |
| Skill-drift guard is CI-inert (fork trees are gitignored, so absent in CI) | Documented limitation; drift is caught on dev boxes, where forks exist |
Three dated ablation comments in test_opencode_http.py predate the undated house form |
Trim on next touch |
The rules a reviewer actually enforces, in one place:
- Zero LLM tokens in any test, any layer. Sessions complete on Stop events or window death only.
- New behavior lands at the lowest layer that catches its regression; mirror-file rule for placement.
- Negative assertion ⇒ premise before outcome, and ablation, singly, against a
cpbackup; record the undatedAblation:/Ablation target:sentence in-file, the run evidence in the commit message. - Time-dependent test ⇒ frozen clock. No sleeps toward deadlines, no retries, ever. A flake is diagnosed and fixed deterministically.
- In-process
--jsontest expecting a document on stdout ⇒machine_json(whole-stdout parse is the purity assertion). Error paths that owe an empty stdout, and--json --outruns that validate the written file, sit outside it. Process-boundary tests (test_entry_point.py, andtest_stories_e2e.pywhere it runsvalidate --json) spawn a real subprocess and parse its whole stdout themselves; never convert one tocli.main. - Sandbox test ⇒
projectfixture; never hand-roll a repo, never touch the template. - Cross-transport/cross-layer twins keep identical names and two-way header cross-links.
- Guard finding ⇒ fix the source; allowlist widening is a reviewed exception with a scoped, acknowledged entry, and a new detector shape gets its probe row first.
- New policy field ⇒
core.tomlentry (the schema-sync guard will insist). New check id ⇒VALIDATE_CHECKS. New env var ⇒envvars.pyor the plugin's own family. - Suite invariants that break silently are AGENTS.md material; everything else about testing belongs here.
Doc-sync: edit this file first. AGENTS.md's Testing section stays terse: a bullet changes there only when the underlying rule changes, and it links here rather than duplicating. CONTRIBUTING.md points contributors here from its Tests bullet. Behavior docs (FEATURES.md) never carry testing policy. When a gap in the register closes, delete its row in the same PR.