* refactor: split generic host mechanics into @agent-device/host-kit (#2082 W1) The shared src/utils closure that blocked the platform-family moves lands on declared owners: generic host mechanics form a new private @agent-device/host-kit package between kernel and capture-kit, and capture-kit keeps capture, snapshot, and recording behavior, depending on host-kit for the mechanics it needs. tar-stream and yauzl move with the archive code. Every seam's exported subpaths are pinned in package-boundaries.test.ts, the layering model ranks the new zone, R13's allow-list names it, and each seam carries an exact eager-closure row. ADR-0019's substrate amendment describes the layout. Tests that mocked two of the moved modules separately became duplicate same-seam vi.mock factories, where the second silently replaced the first; those are merged, and the mocks that production code reaches past are pinned at their injection points instead. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * refactor(host-kit): one narrow capability port per export The four technical barrels (exec/fs/values/request) grouped by category rather than by capability, so a consumer needing one mechanic evaluated unrelated ones. Each export is now a single capability over the host machine: command, process, diagnostics, retry, archive, file, request, version. A port re-exports only what a consumer of that capability uses, and every port carries its own eager-closure row. Most of the old values barrel was never host mechanics. Pure record readers, config-source values, result text, memoization, async scoping, coordinate validation, and device-scope parsing touch no process, file, or environment, so they join kernel's other primitives instead. Closures fall accordingly: capture-kit's png-worker-client from 20 to 10, png-resize from 28 to 18, session-teardown from 79 to 68, and the CLI from 386 to 380. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * chore: drop the migration inventories and trim the touched comments Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * docs: trim the touched host-kit and mutation-lane comments Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * docs: keep tool directives only in the touched files Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * docs: keep tool directives only across the touched tree Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * fix: point the Swift parity comment at the real TS twin and test The W1 move rewrote this citation to packages/contracts/src/mobile-snapshot-semantics.ts, which does not exist: the module went to capture-kit while isTapPointInsideViewport itself went to packages/contracts/src/snapshot-visibility.ts. The TS test line was left pointing at the pre-move path. Both now resolve. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH * fix: repoint comment citations at the homes this refactor moved them to The W1 move left ~20 comment citations pointing at src/utils/*.ts and src/request/*.ts paths that no longer exist. Each now names the capability port that owns the symbol, which survives further file moves: exec -> host-kit/command host-process, owner-identity -> host-kit/process diagnostics -> host-kit/diagnostics atomic-file, process-lock -> host-kit/file retry -> host-kit/retry request progress/cancel -> host-kit/request version -> host-kit/version ttl-memo, source-value, parsing, device-isolation, keyed-lock, success-text -> kernel subpaths Comment-only; no closure, budget, or behavior change. ADR citations are left as written, being dated records of the decision rather than live references. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH --------- Co-authored-by: Claude <noreply@anthropic.com>
9.7 KiB
Testing Notes
Repository-specific testing traps you cannot learn from the test runner alone. Executable gate
ownership lives in scripts/check-affected/ and scripts/gate/.
Which gates a change needs
Three tiers:
- While editing: a focused test or
pnpm check:quick. - Before pushing:
pnpm check:affected --run. It derives the relevant local gates and lists the checks that CI or a native toolchain owns. - For a broad refactor, or when the full deterministic gate is requested:
pnpm check.
GitHub stays authoritative for provider integration, full coverage, native builds, device lanes, and history-backed compatibility. To inspect the gate catalog or a plan:
pnpm check:affected
pnpm check:affected --json
pnpm gate --help
check:affected --run reports coverage obligations but never turns coverage instrumentation on. It
runs one capped vitest related command. Run the dedicated coverage scripts only to diagnose a red
CI result.
Two selection traps recur:
- A response that emits
platformorappleOsneeds provider integration and coverage evidence. Unit tests do not run the provider project, which is what catches internalappleleaking onto the wire. - A workspace package manifest or TypeScript config can rewire all consumers, so the affected selector fails open to the full gate set on purpose.
Docs-only changes with no behavior impact need no runtime tests. Structural guidance gates still need a planted violation that shows their failure direction.
Platform and live-device policy
HarmonyOS has no provisioned CI emulator, physical device, DevEco image, or HDC installation. Unit,
provider, and coverage tests mock the typed HDC seam. Real validation is local hardware evidence per
docs/agents/device-verification.md. Do not add a CI lane that assumes a developer host.
Apple runner changes run pnpm check:xctest-selection and build the affected target. The source
#if guard is the XCTest lane classification — never maintain a second test-name list. Pure runner
decisions use the macOS host lane; iOS/XCTest semantics need a simulator lane.
Local host-lane XCTest runs hit two snags CI never does:
- System policy may refuse the unsigned bundle (
library load disallowed by system policy, shown asEarly unexpected exit … crashed with signal kill). Rebuild signed:CODE_SIGN_IDENTITY="Apple Development", or pick an identity fromsecurity find-identity -v -p codesigning. - The first run needs XCUITest automation permission for the host app.
Live smoke commands and their environment contracts live with their harnesses:
- web:
test/integration/smoke-web-platform.test.ts - iOS:
test/integration/smoke-ios-simulator.test.tsandtest/integration/smoke-ios-simulator-coverage.test.ts - concurrency:
test/integration/nightly/concurrency-torture.test.ts
Read the entry file before running a lane. Do not copy its environment matrix here — it changes.
Shared test utilities
Before creating fixtures, look in src/__tests__/test-utils/. Import named builders from the module
that defines them (session-factories.ts, device-fixtures.ts, store-factory.ts). There is no
barrel on purpose: one barrel made every test evaluate every helper's transitive graph. Shared
DeviceInfo, session, snapshot, store, runtime-fact, and mocked-binary values belong in a sibling
fixture module, not in repeated test literals.
Use mkdtempForTest or mkdtempForTestSync. Global setup redirects TMPDIR for the whole run and
removes it after every worker exits — do not add per-test cleanup for those directories. An
interrupted run may leave a directory behind; the next run prunes it once the owner process and
every process using its TMPDIR are gone.
Mock the seam the subject consumes. A daemon handler that binds a runtime gets fake runtime facts and facets, not a mock of generic dispatch. Generic dispatch mocks are migration debt — do not add more. When an ADR 0019 command migrates, its tests move to the runtime seam in the same PR.
Vitest workers may signal only themselves and their direct children; hermetic-signal-setup.ts
rejects other process-table writes. Tests with fabricated PIDs mock signalPidsBestEffort,
signalProcessGroupBestEffort, or the tool-provider seam. A real child the test spawned may be
signalled directly.
Regression evidence
A regression test must be seen failing without the production change: revert the implementation, run the smallest owning test, record the failing count, restore. Apply the same proof to test relocation and structural gates — plant a type error or violation and watch the intended gate find and name it.
A callback-based canary must observe the subject's semantic success, not just lifecycle completion.
Example: React Native Gesture Handler's
onFinalize
also fires when recognition fails or is interrupted — use an activation-dependent callback, or
assert the callback's success state before publishing a pass.
A device replay counts as automatic regression coverage only when an automatic PR or scheduled lane selects and runs it. Name the owning lane and confirm the scenario ran on the exact PR head. A replay in a manual or unselected tier is test material, not automatic evidence.
For structured classifiers, pair the positive case with the closest negative. When an error message can be identical with and without a typed reason, the negative test must prove the message alone cannot activate retry, fallback, or recovery.
Test through public interfaces where practical. Never add production exports or test-only dependency injection just for a test; a missing seam must be a real product seam.
Properties, fuzzing, and mutation
Pure parser and geometry changes extend the shared fast-check arbitraries and properties — not just
another example. Keep examples for a real past bug or a named decision. Reuse PROPERTY_RUNS
budgets so property files stay inside the unit slow-test gate.
Parser fuzz targets live in scripts/fuzz/targets.ts. Validation generators carry the invalid
outcome they planted, so silent acceptance and wrong error codes are failures. Cases run in a
worker process, so the two faults a case cannot report about itself — never returning, and killing
the process it runs in — are reported as hang and crash against the exact input rather than
taking the caller down with them. Promote a discovered case with the command the harness prints —
never hand-copy an unshrunk input.
Mutation is report-only and limited to the registry in scripts/mutation/modules.ts. It measures
whether tests distinguish changed decision logic. Do not infer redundancy from line coverage alone.
Before editing a shared module
Run pnpm depgraph affected before touching a high-fan-in module:
pnpm depgraph affected packages/capture-kit/src/exec.ts
pnpm depgraph affected src/daemon/ref-frame.ts --json --limit 25
It reports value-edge dependents, affected gates, public commands whose handler chains reach the
module, live scenario owners, and interaction-guarantee cells; type-only and dynamic edges are
classified separately. Feed the plan into pnpm check:affected --run — do not keep a parallel gate
list in prose.
Gate ownership
CHECK_CATALOG is the executable check registry. CI owns a check only through the shared run-gate
action with a literal gate id; pnpm check:gate-manifest verifies registration, workflow ownership,
path reachability, and routed device lanes. Raw shell text cannot declare ownership.
New check: update the catalog and its executable model. Changed path ownership: plant a path that would previously be misrouted and watch the selector or manifest fail before fixing it. Workflow limitations (manual-only, opaque owners) belong in the gate declarations, not here.
Concurrency torture lane
The harness uses a deterministic scheduler for modeled lock grants plus a separate real request-scope serialization guard. A seed reproduces the scheduler trace and terminal invariant:
pnpm test:concurrency-torture
TORTURE_SEED=1234 pnpm test:concurrency-torture
Lock plans come from the production request-lock decisions — never hand-author a parallel plan. The modeled boundary is documented in the harness module, and every failure prints its exact replay command.
Real-subprocess-spawn tests
SUBPROCESS_STUB_TESTS enumerates the few files that spawn a real subprocess per case. They ran
serialized in their own Vitest project until #1823's kill criterion: now un-serialized in
unit-core's default forks pool, reverted if a timeout-shaped failure appears within 20 consecutive
CI runs. Still excluded from the mutation lane either way. There is no unit-test retry layer — fix
or remove flakes.
Speed rules
- Unit tests do not wait production time. Prefer budget-derived cadence, assert the caller passes the right timeout to its tool seam, or use an existing clock seam.
- Vitest parallelizes files, so wall clock is bounded by the slowest file. Splitting a monolith along source topology is a performance win, not just a readability win.
- The slow-test reporter enforces unit and integration budgets. Existing pins only shrink; a new pin needs measured justification.
- Test files over 1,000 lines are pinned to their merge-base size and may only shrink. Split the family before adding tests; never raise the pin.
- Keep isolation enabled and the pool on forks — both alternatives were measured and did not help. The useful optimization is importing the module under test, not a platform barrel.
- Local Vitest runs use a four-worker cap. Override it when a run needs a different host share:
AGENT_DEVICE_VITEST_MAX_WORKERS=<n>(clamped to host CPUs, ignored in CI).