Test Tiers
The commands in this section are the local edit-loop tiers. CI does not run
any of them: the CI gate is cargo test, for the reasons in
Process-per-test isolation. Read
that section before assuming a local nextest pass predicts a CI pass.
The default Rust tier is the bounded unit suite:
cargo nextest run --profile default --libThe non-fail-fast profile reports the full failure set in one run:
cargo nextest run --profile ci --libFor local edit loops, use the quick profile with the module or test filter you are changing:
cargo nextest run --profile quick --lib <filter>The profile timeouts are guardrails for hangs, not performance targets. When doing test-suite speed work, capture before/after wall time and the slowest tests from the nextest summary so lock contention and fixture setup costs stay visible.
Full-pipeline audit/refactor regressions that intentionally run broad audit machinery live in the explicit slow tier:
cargo test --lib --features slow-tests code_audit
cargo test --lib --features slow-tests collect_refactor_sources_audit_write_uses_audit_refactor_engineUse the slow tier when changing audit detector orchestration, audit fixability planning, or audit-driven refactor planning. These tests remain runnable, but they are not part of the default unit gate because they scan real fixture/checkouts and dominated local suite wall-clock time.
Process-Global Environment and Test Threads
homeboy.json sets the Rust extension’s rust_cargo_test_threads to 1, so the
gate runs cargo test --workspace -- --test-threads=1.
This is not a performance choice, it is a correctness one. HomeGuard
(homeboy_core::test_support) isolates a test by mutating process-global
environment — HOME, XDG_DATA_HOME, HOMEBOY_ARTIFACT_ROOT,
HOMEBOY_RUNTIME_TMPDIR, the invocation runtime directory — and restoring it on
Drop. The home_lock mutex it holds serializes writers. It cannot serialize
readers: any test on another thread that reads HOME, directly or through
paths::homeboy() and friends, can observe a foreign or missing value while a
guard’s window is open. libtest shares one process across its threads, so there
is no thread-local escape hatch. (Rust made std::env::set_var unsafe in the
2024 edition for this reason.)
One thread per test binary closes that window. Cargo already runs test binaries sequentially, so the cost is confined to intra-binary parallelism, and the guard-holding tests — the slow ones — were already serialized by the mutex.
This is a workaround, not a fix. It buys correctness with every bit of
intra-binary parallelism the gate had, and it does nothing about the underlying
design: the tests still mutate process-global state. The structural fix is
injectable config and path roots so tests never touch process-global environment
at all (#7505), across roughly 2,072 with_isolated_home call sites. The
mechanical fix that retires the same defect class without touching those call
sites is process-per-test execution, below.
Until one of those lands, do not raise this setting, and do not add new ad-hoc
std::env::set_var helpers in test modules: use
HomeGuard/with_isolated_home so the mutation is at least covered by the
shared lock.
Process-per-test isolation (cargo-nextest)
cargo nextest runs one process per test. A process-global set_var is
then global to a single test, so the reader/writer window described above cannot
open at all: there is no shared HOME for a second test to read. That retires
the whole defect class by construction rather than working around it, and it
does so without giving up intra-binary parallelism, which is the entire cost of
rust_cargo_test_threads = 1.
CI does not run nextest, and cannot be switched to it from this repository
alone. Selecting it here today would produce a red Test gate on a fully
passing suite. Three gaps must close first, and two of them live in repositories
this one does not control. They are listed in the order they must land.
1. Result parsing — homeboy-extensions
This is the gap that makes a premature flip red rather than merely useless.
test_run_status (crates/homeboy-extension/src/test/run.rs) treats absent
counts as a failure, deliberately: test_measurement maps None to
Measurement::unreported(), which assesses to
Unmeasured(NoStructuredCounts), and Unknown collapses to "failed". An
unmeasured gate has never been allowed to render green (#10685).
Counts reach that function from homeboy-extensions:
rust/scripts/parse-test-results.sh invokes the shared adapter set with exactly
one adapter, cargo-test, and that adapter matches only lines beginning
test result:. Core’s fallback text parser
(crates/homeboy-extension/src/test/parsing.rs) is PHPUnit-shaped —
Tests:/Failures:/Errors: — with a passed=<n> failed=<n> key-value
fallback. cargo nextest run emits neither; its summary is
Summary [ 1.234s] N tests run: N passed, M skipped. Nothing matches, so
test_counts is None and the phase reports failed regardless of outcome.
rust.json declares no test.result_parse spec, and a component cannot supply
one — result_parse is read from the extension manifest via load_extension,
not from homeboy.json. So there is no in-repo lever. homeboy-extensions
needs a nextest result adapter (and matching failure-name extraction in
rust/scripts/parse-test-failures.py, which is also test result:-shaped)
before the runner can be flipped anywhere.
Note that core already parses nextest durations
(crates/homeboy-extension/src/test/durations/mod.rs reads the nextest
Summary line). Durations are advisory and do not feed test_counts, so that
existing support does not satisfy the measurement invariant.
2. Install — homeboy-action for the PR gate, this repo for the release gate
The two Test gates have different shapes:
- Release (
.github/workflows/release.yml,gate-test) is a normal step-based job in this repository. An install step —taiki-e/install-actionwithtool: cargo-nextest, which fetches a prebuilt binary in seconds rather than paying acargo installbuild — can be added here directly. - PR (
.github/workflows/ci.yml, jobhomeboy) calls the reusable workflowExtra-Chill/homeboy-action/.github/workflows/ci.yml@v2. A caller cannot inject steps into a reusable workflow, and that workflow exposes no pre-command hook.homeboy-actioncontains no reference tonextestand itsauto-setupdoes not install it. The PR gate therefore cannot get nextest without a change inhomeboy-action.
Homeboy has no pre:test hook either — homeboy.json hooks are limited to
pre:version:bump, post:version:bump, post:release, and post:deploy
(crates/homeboy-core/src/engine/hooks.rs) — so there is no runner-driven
install path that would cover both gates.
Until an install exists on a given gate, leave rust_nextest_fallback at its
default true for that gate. The setting is a trap only when it is the whole
story: a silent fallback to cargo test with no thread cap would leave the race
unguarded. It is safe here precisely because rust_cargo_test_threads = 1 is
still set, and that setting applies only when the resolved runner is
cargo, which is exactly the fallback path. Where nextest is installed, set
HOMEBOY_RUST_NEXTEST_FALLBACK=0 on that job so a missing binary exits 127 with
the install diagnostic instead of quietly reverting — otherwise a no-op is
indistinguishable from success.
3. Profile selection — this repo
.config/nextest.toml already defines tuned default, ci, and quick
profiles, but the extension runner builds cargo nextest run --manifest-path …
plus scope args and never passes --profile. Nextest defaults to the default
profile unless NEXTEST_PROFILE is set, so the ci profile is not selected
today and would not be selected by an install alone.
That matters: ci sets fail-fast = false. Without it, nextest inherits
fail-fast and reproduces the exact complaint that motivated #11242 — cargo
aborting the remainder of the workspace so most of the suite never executes. Any
job that runs nextest should set NEXTEST_PROFILE: ci.
The timeout interaction is favourable and needs no budget change. default is
period = 30s, terminate-after = 4 and ci is period = 60s, terminate-after = 2; both kill a test at 120s. Against the 2700s phase
budget (HOMEBOY_TEST_TIMEOUT_SECONDS) and the action’s 3000s outer backstop,
that converts a hung test from a 2700s phase stall into a bounded, named,
120s test failure — the same win #11234 and #11235 delivered by hand, but
automatic. Do not change the 2700s budget to accommodate it.
The one behavioural risk to measure on the first real run: a test that
legitimately takes over 120s becomes a failure rather than a slow pass. The
deliberately slow tier is already excluded from the gate behind
--features slow-tests, so the exposure should be small, but it is unverified.
Landing order
homeboy-extensions: add anextestresult adapter and nextest failure-name parsing, and release the extension. Homeboy resolves extensions at the latest published release, so this propagates to CI on its own.homeboy-action: installcargo-nextestin the reusable CI workflow’scandidate/baselinejobs, so the PR gate has the binary.- This repo: add the install to
gate-testinrelease.yml, setNEXTEST_PROFILE: ciandHOMEBOY_RUST_NEXTEST_FALLBACK: '0'on every job that has the binary, then setextensions.rust.settings.rust_test_runnertonextestinhomeboy.json. - Only once a run demonstrably reports
Running cargo nextest...with parsed, non-null test counts, droprust_cargo_test_threads. Removing it earlier would leave the race unguarded on any path that fell back tocargo test.
Steps 1 and 2 are independent and can land in either order; step 3 must not precede step 1.
Hermetic CLI Fixtures
Ordinary Rust tests must use homeboy::test_support::HermeticTestContext for
Homeboy subprocesses. It supplies owned HOME, config, data, artifact, runtime,
temporary, daemon, and runner locations, and requires an explicit binary choice:
TestBinary::HomeboyFixture for Cargo’s fixture binary or
TestBinary::CurrentTest for the running test executable. This prevents tests
from reading operator configuration or resolving an installed homeboy through
PATH.
Host integration tests are opt-in: place them behind an explicit Cargo feature or an explicit command-line opt-in and document the required host service, credentials, and cleanup contract beside the test. They may use host state only when that contract is the behavior under test; they are excluded from the ordinary Rust gate.