mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
006c4cadc9
* test: nightly parser fuzz lane with typed-AppError invariant (#1414) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(fuzz): run envelope, artifact promotion, and harness self-check tests (#1414) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(fuzz): shared scheduled-lane envelope on every terminal path, watchdog after ready (#1414) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(fuzz): envelope for malformed options; add scheduled-lane health consumer (#1414, #1430) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(lanes): actions:read scope, terminal error envelope, first-due grace (#1414, #1430) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(lanes): anchor first-run grace to schedule registration, use exec helper in tests (#1414, #1430) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(lanes): portable POSIX pickaxe pattern for schedule registration (#1414, #1430) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(fuzz): fast-check generators over the shared hazard list, drop the bundled lane-health work (#1414) - Strip scripts/scheduled-lane/* and scheduled-lane-health.yml: that watcher is #1430's own deliverable and collides with PR #1439's implementation of the same lane. What this lane owes (a per-run envelope) moves into scripts/fuzz/envelope.ts. - Rebase onto #1437 and rebuild the generator layer on fast-check: cases come from arbitraries sharing SELECTOR_VALUE_HAZARDS with the property suite, and counterexamples are shrunk, so a failure names a minimal input plus fast-check's seed/path instead of a 20k-char random string. - Route harness.test.ts into the serialized subprocess-stub project. - Drop the AGENT_DEVICE_FUZZ_STARTUP_DELAY_MS test seam: the ready handshake is now proven by a case budget far below real worker startup. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(fuzz): replay the regression corpus through the worker watchdog (#1414) A promoted hang case used to wedge the unit job until the CI timeout, because corpus replay called checkCase in-process. It now goes through the same worker-backed watchdog the nightly lane uses, so such a case fails against a 5s per-case budget; the file moves to the serialized subprocess-stub project with the rest of the worker-driven fuzz tests. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(fuzz): let the watchdog outlive vitest's default case timeout (#1414) A wedged parser was surfacing as a bare 'Test timed out in 5000ms' instead of the named hang: failure that says which input wedged, because the file's vitest timeout was shorter than the watchdog budget times the number of replayed cases. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(fuzz): complete drift provenance in the lane envelope (#1414) configHash now covers every input that decides what a seed generates (generate.ts and the shared property arbitraries, not just the arbitraries/targets/invariant), and tool records fast-check's installed version. A generation-loop edit or a fast-check upgrade previously changed the case set while the envelope looked unchanged. A test recomputes the hash with each input omitted so a future omission fails. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Michał Pierzchała <thymikee@gmail.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
325 lines
23 KiB
Markdown
325 lines
23 KiB
Markdown
# Testing Notes
|
||
|
||
## Which gates a change needs
|
||
|
||
Default for code changes: `pnpm check:affected --base origin/main --run`. It derives the gate set
|
||
from repository sources of truth, so prefer it over interpreting the table below by hand. GitHub CI
|
||
stays authoritative.
|
||
|
||
The mapping it encodes, for when you need to run a gate directly or reason about coverage:
|
||
|
||
| Change | Gate |
|
||
| --- | --- |
|
||
| Any TypeScript | `pnpm typecheck` or `pnpm check:quick` |
|
||
| Daemon handler / shared module | `pnpm check:unit` |
|
||
| Tooling/config (`package.json`, `tsconfig*.json`, `.oxlintrc.json`, `.oxfmtrc.json`) | `pnpm check:tooling` |
|
||
| Platform/device response — anything emitting `platform`/`appleOs` on the wire, or shaping a daemon response | `pnpm test:integration:provider` **and** `pnpm test:coverage` |
|
||
| Cross-platform behavior | `pnpm test:integration` |
|
||
| iOS runner / Swift | `pnpm build:xcuitest` |
|
||
| CLI help/guidance (`src/cli/parser/cli-help.ts`, `src/cli-schema/`) | `pnpm exec vitest run src/cli/parser/__tests__ src/cli-schema/command-schema-guards.test.ts scripts/__tests__` — the `scripts/__tests__` gates enforce help-topic benchmark coverage and pin the bench's quoted CLI samples to the real renderers |
|
||
| Help benchmark cases (`scripts/help-conformance-*.mjs`) | `pnpm exec vitest run scripts/__tests__` (deterministic gates); model-backed: `pnpm bench:help-conformance` (paid LLM calls, local only) |
|
||
| SkillGym prompts/assertions | `pnpm test:skillgym:case <case-id>` (broad: `pnpm test:skillgym`, filter with `-- --tag fixture-smoke` or `-- --tag skill-guidance`) — agentic routing + local-help-consumption proof only; command-planning knowledge checks belong in the help bench |
|
||
| `.ad` grammar (`src/replay/script.ts`, gesture arity, replay vars) | `pnpm exec vitest run --project unit-core test/replay-compat` — the frozen replay-compat corpus asserts which released script surfaces still parse; a flipped verdict is edited in `test/replay-compat/manifest.ts`, never in the script. Adding or re-pinning a corpus entry also runs `pnpm check:replay-compat`, which re-derives each entry from its release tag in git history |
|
||
| Anything in `src/`, `test/`, `skills/` | `pnpm format` |
|
||
| A decision kernel or its tests (`src/kernel/errors.ts`, `src/daemon/ref-frame.ts`, `src/commands/interaction/runtime/settle.ts`, `src/utils/scroll-edge-state.ts`, `src/selectors/`) | `pnpm mutation:affected --base origin/main` (minutes; GitHub runs it per PR — see the mutation ratchet section) |
|
||
|
||
Two traps worth naming:
|
||
|
||
- The platform/device-response row is the one agents miss. `pnpm check:unit` does **not** exercise the
|
||
`provider-integration` project, and that project holds the apple-platform-output leak guard.
|
||
Internal `apple` must never reach a command response — project through `publicPlatformString`.
|
||
- Fallow CI failures reproduce with `pnpm check:fallow --base origin/main`. Do not estimate
|
||
complexity or dead-code impact by hand.
|
||
|
||
Docs/skills-only and non-TS changes with no behavior impact need no tests. Test-only DI seam CI
|
||
failures are enforced by the workflow — do not add optional `typeof` DI params to production code to
|
||
satisfy a test.
|
||
|
||
## Shared test utilities
|
||
|
||
Before writing a new test, inspect `src/__tests__/test-utils/index.ts`:
|
||
`rg -n "export .*make|export .*DEVICE|withMocked" src/__tests__/test-utils`. Import through the
|
||
barrel and prefer named shared fixtures over inlining new `DeviceInfo`, `SessionState`, snapshot,
|
||
store, or mocked-binary objects. If a helper is missing, add it near the concept it serves and export
|
||
it through the barrel.
|
||
|
||
Keep tests behavioral. Do not assert shapes or cases TypeScript already proves.
|
||
|
||
Test through public interfaces where practical, and do not add unrelated production exports solely
|
||
to make a test easier — widening the public surface for a test is a product change, and the exports
|
||
outlive the test that motivated them. If a seam is genuinely missing, add it as a real one rather
|
||
than as a test affordance (the workflow separately forbids test-only `typeof` DI params).
|
||
|
||
## Properties over examples (pure parsers and geometry)
|
||
|
||
**Pure parser or geometry change → extend a property, not another example.** The parse/print and
|
||
geometry kernels (selectors, `@eN~sM` refs, `.ad` script lines, gesture planning, snapshot diff) are
|
||
covered by fast-check properties living in each owning module's test file. Their generators are
|
||
shared in `src/__tests__/test-utils/property-arbitraries.ts` and exported through the test-utils
|
||
barrel, so a new hazard (another quote shape, a new gesture kind, another `.ad` command) belongs in
|
||
the generator, where every property inherits it — not in a new hand-pinned case.
|
||
|
||
- Keep examples that document a specific decision or a real past bug; add the general guarantee as a
|
||
property alongside them.
|
||
- Bound `numRuns` with the shared `PROPERTY_RUNS` / `PROPERTY_RUNS_SMALL` constants: properties run
|
||
in `unit-core` under the same slow-test budget as everything else.
|
||
- A failing property prints the shrunk counterexample plus the seed and path to replay it; paste
|
||
that seed into `fc.assert(..., { seed, path })` to re-run exactly that case.
|
||
|
||
## Affected-check selector (`pnpm check:affected`)
|
||
|
||
`pnpm check:affected --base <ref>` derives which local checks a diff needs, so
|
||
agents stop interpreting the testing matrix by hand. It is a **fail-open
|
||
advisory**: existing GitHub CI stays authoritative and required, and this only
|
||
narrows the *local* feedback loop.
|
||
|
||
```sh
|
||
pnpm check:affected --base origin/main --run # default agent loop: plan + run
|
||
pnpm check:affected --base origin/main # human-readable plan only
|
||
pnpm check:affected --base origin/main --json # machine-readable plan only
|
||
```
|
||
|
||
The selection is derived from repository sources of truth rather than a
|
||
hand-maintained path map:
|
||
|
||
- **Affected Vitest tests** are delegated to `vitest related --run`, using
|
||
Vitest's own project configuration and static module graph. The selector
|
||
passes its complete changed-file set instead of reproducing Vitest globs or
|
||
import ownership. Dynamic-import relationships remain outside Vitest's
|
||
analysis; GitHub's authoritative full suites still cover that boundary.
|
||
- **Non-Vitest suites** retain explicit ownership. Root
|
||
`test/integration/*.ts` files use the Node integration lane, SkillGym owns its
|
||
harness and skill guidance, and platform/build tools keep their native gates.
|
||
- **Always-on gates** (`lint`, `typecheck`, `layering`, `fallow`, `format`) fire
|
||
for their input categories and are never silently skipped. Platform source
|
||
also selects the provider-integration and coverage gates required by the
|
||
Testing Matrix.
|
||
- **Commands** are resolved from real `package.json` scripts, so a renamed
|
||
script fails loudly instead of dropping a gate.
|
||
- A **small explicit build-ownership layer** covers the paths whose owning build
|
||
cannot be derived: Swift runner, Android helpers, macOS helper, MCP metadata,
|
||
and the public package surface (itself derived from `package.json` `exports`).
|
||
- **SkillGym ownership** covers skill guidance (`skills/`) and the SkillGym
|
||
harness (`test/skillgym/`) — those changes select the (local-only) SkillGym
|
||
suite, and their Markdown is treated as skill/harness input, not inert docs.
|
||
|
||
Changed-file discovery folds working-tree state into the local plan: in the
|
||
default local mode (`--head HEAD`) it unions the committed `base..HEAD` diff with
|
||
staged, unstaged, and untracked files, and disables rename detection so **both**
|
||
sides of a rename are classified (a moved file cannot look docs-only by its
|
||
destination alone).
|
||
|
||
Anything the selector cannot classify — unknown, ambiguous, workflow/tooling, or
|
||
a change to the selector's own sources — **fails open to the full check set**.
|
||
That includes this file: the Testing Matrix above is the prose the ownership
|
||
rules mirror, so `docs/agents/testing.md` is selector-owning
|
||
(`SELECTOR_OWNING_DOCS` in `scripts/check-affected/model.ts`) and outranks the
|
||
docs-only short-circuit its path would otherwise take. If the matrix moves
|
||
again, move that entry with it.
|
||
The plan documents the rule and changed path behind every selected check.
|
||
|
||
Model and catalog live under `scripts/check-affected/`; the derivation is guarded
|
||
by `pnpm check:affected:test` (the `Affected-check Selector` CI job).
|
||
|
||
## Mutation ratchet over decision kernels
|
||
|
||
Mutation score is the mechanical answer to "is this test load-bearing or decorative". A full-suite
|
||
sweep is unaffordable, so the scope is an enumerated list of pure decision kernels — modules where a
|
||
surviving mutant means a silently wrong agent-facing decision. The registry
|
||
(`scripts/mutation/modules.ts`) is the single source of truth: `stryker.config.json`'s `mutate` globs
|
||
are asserted against it, and PR-affected selection maps changed files through it. Modules that spawn
|
||
subprocesses or wait real time stay out by construction.
|
||
|
||
```sh
|
||
pnpm mutation:test # ratchet self-test (fast, no Stryker)
|
||
pnpm mutation:run --modules selectors # one module locally (~7 min for selectors)
|
||
pnpm mutation:check # ratchet an existing .tmp/mutation/mutation.json
|
||
pnpm mutation:baseline # full sweep, then record it (reviewed commit)
|
||
```
|
||
|
||
- **Weekly full sweep** (`.github/workflows/mutation-weekly.yml`) runs `shardMatrix()` from the
|
||
registry: one job per module, except modules that declare a `shards` count and are sliced with
|
||
`--shard i/n` (selectors is ~1,280 mutants, well past the 30-minute budget in one job). The ratchet
|
||
merges the shard reports (`--report-dir`) for one verdict and requires the full set
|
||
(`--expect-shards`), so a dead shard fails the lane instead of scoring its module as 0. Results are
|
||
reported as a job summary plus an artifact. It never commits: the proposed baseline rides in the
|
||
artifact, and applying it is a reviewed `pnpm mutation:baseline` commit, so a score cannot lower
|
||
itself.
|
||
- **PR lane** (`.github/workflows/mutation-affected.yml`) derives the affected shard matrix
|
||
(`--list-affected`) and merges the shards into one verdict. Before graduation the matrix is empty —
|
||
a report nobody acts on is not worth the runner minutes — unless the diff touches the lane's own
|
||
tooling, the one pre-graduation run that buys something: the gate has to be proven before it bites.
|
||
Lane sources own no kernel, so that exception adds `LANE_CANARY` (`kernel-errors`, the registry's
|
||
cheapest real sweep) to whatever the diff derives; otherwise it would select zero mutants and prove
|
||
nothing. `scripts/mutation/selection.test.ts` drives both halves of the rule through the real CLI
|
||
against a throwaway worktree commit.
|
||
- **Ratchet**: scores may only rise. `mutation-baselines/decision-kernels.json` records the
|
||
high-water score per module plus the Stryker version and config content hash that produced it, so a
|
||
score change caused by a tool/config change is reported as provenance drift, never as a
|
||
test-strength regression.
|
||
- **Graduation, not a flag day**: gating is off until two consecutive comparable weekly sweeps pass
|
||
(`stableRuns`/`requiredStableRuns` in the baseline); the PR job starts selecting modules — and
|
||
failing on them — once the committed baseline says `gating: true`. A regression or provenance drift
|
||
resets the counter.
|
||
- **Test scope** is derived from Vitest's module graph (`vitest related` over the mutated files), the
|
||
same delegation `pnpm check:affected` uses; see `scripts/mutation/test-scope.ts` for the three
|
||
groups it drops and why dropping them cannot hide a surviving mutant.
|
||
- **Test ownership is derived, never listed** (`scripts/mutation/ownership.ts`): a test owns every
|
||
kernel its imports reach, so `src/__tests__/daemon-error.test.ts` selects `kernel-errors` through
|
||
`src/daemon.ts` without naming it. A listed set of test files would silently omit exactly those
|
||
indirect tests and rot as tests are added — weakening one would skip the ratchet. Reaching a kernel
|
||
is a superset of killing its mutants, so the PR lane over-selects on purpose and shards the
|
||
selected modules; a false positive costs runner minutes, a false negative costs the gate. Non-kernel
|
||
*sources* are not owned: they can only move a score through those tests, and the weekly sweep
|
||
re-measures the whole surface.
|
||
- **Lane envelope** (`scripts/lib/lane-envelope.ts`, issue #1430): every run writes
|
||
`.tmp/mutation/lane-envelope.json` — schema version, commit, Stryker version, config hash, seed
|
||
(`null`; the input is enumerated, not randomized), duration, result, stage, per-module scores — and
|
||
both workflows upload it, so lane freshness and tool drift are readable without parsing logs. It is
|
||
written on every exit path, including a crash before any mutant runs: an absent envelope would be
|
||
indistinguishable from a lane that never ran.
|
||
|
||
## Parser fuzz lane
|
||
|
||
`pnpm fuzz:parsers` feeds generated hostile input to `parseArgs`, selector parsing,
|
||
`parseReplayScriptDetailed`, `batch --steps` JSON, and the Maestro compat parser, and enforces one
|
||
invariant: every rejection is a typed `AppError` whose normalized `hint` is non-empty, and no case
|
||
hangs (a worker-thread watchdog attributes a stall to the exact input).
|
||
|
||
```sh
|
||
pnpm fuzz:parsers # all targets, 2,000 cases each, seed 1
|
||
pnpm fuzz:parsers --target selector --iterations 50000 --seed 7
|
||
pnpm fuzz:parsers --input-file .tmp/fuzz/<case>.json # repro a saved case
|
||
pnpm fuzz:parsers --input-file .tmp/fuzz/<case>.json --append-corpus # …and pin it
|
||
pnpm fuzz:parsers --self-check # require the harness to still fail
|
||
```
|
||
|
||
The generating run is nightly (`Parser Fuzz Lane` in `.github/workflows/replays-nightly.yml`, seeded
|
||
by the run number). Every terminal path — pass, fail, `--self-check`, or a crash in the harness
|
||
itself — writes `<artifact-dir>/run-envelope.json` on the shared lane contract
|
||
(`scripts/lib/lane-envelope.ts`, #1430), with the lane's own facts under `data`: mode, per-target
|
||
cases/failures/durations, failures, repro commands, and `stage` (`error` marks a run that could not
|
||
complete itself, since the shared `result` is only `pass`/`fail`). `configHash` hashes the modules
|
||
that decide a case set, so "the same seed means different inputs now" is distinguishable from "the
|
||
parsers changed". The self-check and fuzz steps write to separate artifact subdirectories and both
|
||
run unconditionally; the step summary prints each envelope it finds and never fails on a missing
|
||
file.
|
||
|
||
Cases come from fast-check arbitraries (`scripts/fuzz/arbitraries.ts`) built on the hazard vocabulary
|
||
shared with `src/__tests__/test-utils/property-arbitraries.ts`, so a hazard added for the property
|
||
suite reaches the fuzz lane too — and a counterexample is reported **shrunk**, with fast-check's seed
|
||
and replay path printed alongside the saved artifact.
|
||
|
||
A nightly discovery reaches the unit lane by promotion, not hand-editing: the printed
|
||
`promote:` command re-runs the downloaded artifact and appends it to
|
||
`scripts/fuzz/corpus/regressions.json`, which `scripts/fuzz/corpus-replay.test.ts` replays on every
|
||
PR — through the same worker watchdog, so a promoted hang case fails against its per-case budget
|
||
instead of wedging the unit job. `scripts/fuzz/harness.test.ts` covers the harness itself — an
|
||
untyped throw, an empty hint, and a wedged worker must each be reported, startup time is never charged against the per-case budget, and
|
||
every mode writes an envelope — using the broken-on-purpose targets in
|
||
`scripts/fuzz/self-check-targets.ts` (also what `--self-check` runs in CI), so a regressed classifier
|
||
or watchdog cannot pass silently. Adding a parser to the lane means adding a target to
|
||
`scripts/fuzz/targets.ts` — nothing else.
|
||
|
||
## Live web smoke
|
||
|
||
The live web platform smoke runs the public built CLI against a local fixture page through the managed web backend:
|
||
|
||
```bash
|
||
AGENT_DEVICE_WEB_E2E=1 pnpm test:smoke:web
|
||
```
|
||
|
||
The test is skipped unless `AGENT_DEVICE_WEB_E2E=1` is set. The test runs `agent-device web setup` and `agent-device web doctor` with an isolated state directory before opening the fixture URL, so it verifies the public managed-backend setup path instead of relying on a global `agent-browser`. CI runs the lane on Node 24 because the managed backend requires Node >= 24. Failure artifacts, daemon state, and browser config are written under `test/artifacts/web/`.
|
||
|
||
## Concurrency torture lane
|
||
|
||
`test/integration/nightly/concurrency-torture.test.ts` (#1416, umbrella #1412 Track A) runs N concurrent
|
||
clients through randomized-but-**seeded** interleavings of open/mutate/close/takeover/kill against
|
||
the real `SessionStore` + `LeaseRegistry` (plus an in-memory device-claim model). After every run it
|
||
asserts: no leaked leases or claims, no cross-session state bleed, every lock released after owner
|
||
death, the session store stays consistent, and same-device critical sections never overlap (this
|
||
pins the router's same-device open serialization under 100+ interleavings).
|
||
|
||
A seed alone cannot reproduce Promise/event-loop interleavings, so **all** concurrency is routed
|
||
through a deterministic scheduler (`nightly/concurrency-torture/deterministic-scheduler.ts`) — an
|
||
instrumented dispatcher that is the sole source of ordering (which fiber steps next, and which waiter
|
||
wins a contended lock). A seed therefore fully determines execution order.
|
||
|
||
Each operation's lock plan is **not** hand-written: it is built exactly as the daemon builds it in
|
||
`createRequestExecutionScope` — gate on the production decision `shouldLockSessionExecution(command)`
|
||
(`src/daemon/daemon-command-registry.ts`), and only then resolve keys via the production router
|
||
primitive `resolveRequestExecutionLockKeys` (`src/daemon/request-binding.ts`), driven with a fake
|
||
device inventory through the production `withDeviceInventoryProvider` seam
|
||
(`nightly/concurrency-torture/bindings.ts`). Only the mutex *grant* is modeled by the scheduler, because
|
||
`withKeyedLock`'s native microtask hand-off cannot be reproduced from a seed. Consequently reverting
|
||
*either* production decision — exempting a command from execution locking, or dropping the `device:`
|
||
key — changes the derived plan and trips the overlap invariant, so the lane is genuinely coupled to
|
||
production lock resolution, not a duplicate of it. **Real:**
|
||
`SessionStore` and `LeaseRegistry`. **Modeled:** the advisory device claim (`InMemoryClaimRegistry`)
|
||
and process "kill" — the production claim is a filesystem/OS lock and real process death, both out of
|
||
scope for this scheduling lane and covered by their own unit tests. The full real-vs-modeled boundary
|
||
is documented at the top of `nightly/concurrency-torture/harness.ts`.
|
||
|
||
Because the seeded sweep *models* the mutex grant, a separate **real-scope guard**
|
||
(`nightly/concurrency-torture/real-scope-serialization.ts`) drives concurrent same-device opens through the
|
||
actual `createRequestExecutionScope().runLocked()` → `withRequestExecutionLocks` → `withKeyedLock`
|
||
and asserts the critical sections never overlap. This is intentionally not seeded (it exercises real
|
||
event-loop scheduling); its job is to fail if the production lock *application* path regresses, which
|
||
the modeled sweep alone could not catch.
|
||
|
||
```bash
|
||
pnpm test:concurrency-torture # default sweep (TORTURE_RUNS=128 seeds from 0)
|
||
TORTURE_SEED=1234 pnpm test:concurrency-torture # replay ONE seed's exact interleaving (seed-replay flag)
|
||
TORTURE_RUNS=5000 TORTURE_SEED_START=0 pnpm test:concurrency-torture # widen the sweep
|
||
```
|
||
|
||
Replay is exact: a given seed reproduces the whole scheduler trace (`traceSignature`), the terminal
|
||
invariant outcome, and the contention profile — equality on all three is asserted not just under
|
||
`TORTURE_SEED` but for **every seed in the normal sweep** (each seed is re-run and compared), so
|
||
non-determinism is caught on the ordinary CI/nightly path. The sweep also asserts real same-device
|
||
lock *contention* occurred (two clients parked on one `device:` lock), and a dedicated forced
|
||
two-client same-device test pins both clients to one device via `pinnedDevice` so they cannot land on
|
||
different devices, driving that contention deterministically.
|
||
|
||
Every failure prints the offending seed and the exact `TORTURE_SEED=<n> pnpm test:concurrency-torture`
|
||
replay command. The lane lives under `test/integration/nightly/`, deliberately **out** of the
|
||
`test:integration:node` glob so it is not an accidental PR-time run: the PR gate runs a fast default
|
||
sweep via an explicit `Run seeded concurrency torture lane` step in the Integration job, and the
|
||
`Concurrency Torture Nightly` workflow sweeps a much larger seed range on schedule. The nightly run
|
||
emits the shared scheduled-lane envelope (`scripts/lib/lane-envelope.ts`, #1430 — commit,
|
||
tool/`configHash` from the lane source hash, `seed` range, duration, result, with the seed sweep in
|
||
the typed `data` payload) via `TORTURE_ENVELOPE=<path>`, uploaded as the `concurrency-torture-envelope`
|
||
artifact. The
|
||
envelope is written once, after **all** lane tests settle, and reports `fail` if any of them (sweep,
|
||
replay self-check, or forced-contention guardrail) failed — a later-failing guardrail can never be
|
||
published as a passing envelope. Optional knobs: `TORTURE_CLIENTS`, `TORTURE_OPS`.
|
||
|
||
## Speed rules (experiment-backed, 2026-07-04)
|
||
|
||
Measured on the full unit suite (340 files, 3,210 tests, 48s wall at ~7x parallelism):
|
||
|
||
- **Wall clock equals the slowest file.** The 44.6s android monolith bounded the whole 48s run
|
||
(Amdahl at file granularity: vitest parallelizes per file). Splitting monolith test files is a
|
||
wall-clock optimization, not just a navigation one — see the AGENTS.md test-topology mirror rule.
|
||
- **Unit tests must not wait real time.** The suite's worst tests slept through production budgets:
|
||
10.8s to prove "times out" by waiting out the full constant, 8s emulator-boot polls at 1Hz, real
|
||
retry backoffs. Conversion patterns, in preference order (tracking issue #1098):
|
||
1. *Budget-derived cadence* (production-legit): poll intervals scale with the caller's timeout —
|
||
this took `devices.test.ts` from 25.6s to 2.8s (9x) while making short-budget production calls
|
||
more responsive.
|
||
2. *Budget-wiring assertion*: don't re-prove the exec layer's timeout per call site; mock the tool
|
||
layer and assert the right `timeoutMs` constant is passed. Exec-layer timeout semantics are
|
||
proven once, in exec's own tests.
|
||
3. *Fake clocks* where the code accepts an injected clock.
|
||
Never add a test-only DI seam for this — the CI gate forbids it; patterns 1–2 are production
|
||
improvements and test restructurings respectively.
|
||
- **The slow-test ratchet** (`scripts/vitest-slow-test-reporter.ts`) enforces this: unit budget
|
||
2.5s, integration 15s, failure at 2x budget (the band between reports without failing — host
|
||
load legitimately stretches borderline tests, and a flaky gate trains people to ignore it).
|
||
The pin list only shrinks, or grows in the same PR with a justification.
|
||
- **Isolation stays ON; pool stays forks — both measured.** `--no-isolate`: 205s wall vs 48s
|
||
(module state — timers, memos, singletons — thrashes across files sharing a worker).
|
||
`--pool=threads`: no change (50.4s). The ~100s aggregate import overhead is the price of
|
||
isolation and is paid in parallel; reduce it per file by importing the module under test, not
|
||
platform barrels.
|