For LLM agents: Load the
writing-testsskill (.opencode/skills/writing-tests/SKILL.md) before writing or modifying any test file. It contains the full mock isolation rules, CI pipeline structure, and anti-patterns. For agent operational safety and the broader engineering invariants of this repo (especially thetest_runnerbroad-scope restriction and the_internalsDI-seam pattern for mock isolation), readAGENTS.mdat the repo root.
Writing stable tests? See
docs/testing/test-stability.md— the runbook for the four root-cause classes of merge-group flaky tests (time-sensitive, coverage-sensitivity, cross-platform, subprocess) and thefreezeClock/withIsolatedStatehelpers that prevent them. Thebun run check:test-clocklint (CIqualityjob) fails on NEW time-touching tests that don't use the helper.
⚠️ Do NOT use the OpenCodetest_runnertool to validate the full repo. It is for targeted agent validation with explicitfiles: [...]or small targeted scopes.scope: 'all'is gated behind theSWARM_ALLOW_FULL_SUITE=1env var (intended for opt-in CI mirrors only; there is noallow_full_suitearg). Broad scopes can stall or kill OpenCode before theMAX_SAFE_TEST_FILES = 50guard insrc/tools/test-runner.tsfires. For repo validation, use the shell commands below — per-file isolation loops match CI behavior. SeeAGENTS.mdinvariant 6 for the full contract.
All tests use bun:test. No Jest, Vitest, or other frameworks.
import { describe, test, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test';# Single file (always safe)
bun --smol test tests/unit/tools/diff.test.ts --timeout 30000
# Per-file loop (required for tools, services, agents — prevents mock poisoning)
for f in tests/unit/tools/*.test.ts; do bun --smol test "$f" --timeout 30000; done
# Batch run (safe only for directories without mock conflicts)
bun --smol test tests/unit/hooks --timeout 120000
bun --smol test tests/unit/cli --timeout 120000Issue #2675's canonical validation command runs the complete, ordered surface
inventory and writes the bounded JSON report under .swarm/:
# Full validation (all surfaces)
bun run validate:repo -- --mode full --diff-base origin/main
# Pull-request diff validation (no changed work is a distinct no-op result)
bun run validate:repo -- --mode diff --diff-base origin/mainIn diff mode, the selected changes are the committed branch changes in
<diff-base>...HEAD; unstaged and untracked work is excluded. The ten surfaces are
quality, unit, integration, security, coverage,
memory-recall-regression, package-check, smoke, php-validation, and
rust-sandbox-runner. The report is schema-versioned (schemaVersion: 1) and
records the runtime (bunVersion, platform, arch), exact array-form argv
and cwd for every item, the origin/main diff base, per-surface results, and
summary counts. A process must end in one of passed, failed, crashed,
timed_out, missing, or skipped; a crash, timeout, missing item, or skipped
item cannot be reported as a complete pass. The run-level status is
passed, failed, incomplete, or no_op; no_op is reserved for a diff
run with no discovered work and is not equivalent to passed.
Use --surface <name> (repeatable) or comma-separated --surfaces <name,...>
to validate a deliberate subset; omitting both selects the full ten-surface
matrix. If a selected item requires an unavailable runtime, it is recorded as
skipped with a reason and the run is incomplete (nonzero exit), so a full
matrix run never claims a pass while a required runtime is missing.
The default safety bounds are a 120000 ms test timeout, 180000 ms per-item
wall timeout, 900000 ms whole-run timeout, and 65536 output bytes per channel
(after redaction). The runner preloads
scripts/ci/bun-32056-keepalive.ts and preserves each test-file path as a
separate argv element, including spaces and quotes.
The historical 659/3,389 count is an unconfirmed historical count because
its raw source/provenance was not retained; it must not be presented as a
reproducible current validation result. New reports retain the raw per-item
terminal statuses, timing, signal, bounded redacted output, and cleanup
outcome needed to audit a run.
Do not run bun --smol test tests/unit/tools or tests/unit/hooks as a single batch. Mock modules leak across files in Bun's --smol mode, causing false failures. The CI uses per-file isolation loops for the 15 mock.module hook files (step 1a) and steps 4-6 (tools, services, state/agents), while other hook tests remain in batch groups (step 1b).
Bun v1.3.13+: The --isolate flag is available for local development to run each test file in a fresh global environment. However, CI currently uses --smol with per-file isolation loops, which achieves the same mock isolation goal. You may use --isolate locally, but the CI pipeline will continue using --smol with per-file loops for consistency.
Bun's --smol mode shares module cache between test files. A mock.module() call replaces the module globally for all files in the same process.
mock.restore() does NOT reliably restore mock.module mocks in Bun v1.3.11. Three layers of defense are required.
Always spread the real module when mocking Node built-ins:
import * as realChildProcess from 'node:child_process';
const mockExecFileSync = mock(() => '');
mock.module('node:child_process', () => ({
...realChildProcess, // preserve ALL exports — mandatory
execFileSync: mockExecFileSync, // override only what you need
}));Always add afterEach(mock.restore()) for cross-module mocks. Even though unreliable in Bun v1.3.11, it provides best-effort cleanup. Exception — Windows EBUSY: Test files that spawn async child processes (pre-check-batch suite) must NOT call mock.restore() on Windows. Child process handles hold directory locks and trigger EBUSY errors. Skip affected tests with test.skipIf(process.platform === 'win32').
Mock cleanup enforcement: bun run check:mock-cleanup runs in CI (quality job) and enforces two checks:
- All
mock.modulecalls haveafterEach(mock.restore())cleanup or file-scopedmockClear/mockResetpattern - All
mock.module('node:*', ...)calls spread real exports (e.g.,...realFs) to prevent test pollution
Allowlist growth ratchet (issue #1666): bun run check:invariants Check 4 ratchets scripts/mock-allowlist.txt closed against unapproved growth. Adding a new mock.module target requires a matching standalone marker line # APPROVED-NEW: <normalized-target> in the allowlist (preserved across regen by scripts/generate-mock-allowlist.sh). MOCK_ALLOWLIST_ENFORCE=0 soft-warns for a deliberate growth PR. Prefer the _internals DI seam for new code.
Run locally before pushing:
bun run check:mock-cleanup
bun run check:invariants
bun run check:pre-pushcheck:pre-push is the authoritative cross-platform aggregate for enforced
repository drift and retention-registry citation validation. Unlike the plain
drift:check report command, it exits nonzero for blocking drift.
Intentionally skipped on Windows (async child process handles cause EBUSY):
tests/unit/tools/pre-check-batch.test.tstests/unit/tools/pre-check-batch.adversarial.test.tstests/unit/tools/pre-check-batch-cwd.test.tstests/unit/tools/pre-check-batch-cwd.adversarial.test.tstests/unit/tools/pre-check-batch-contextdir-adversarial.test.tstests/unit/tools/pre-check-batch-sast-preexisting.test.tstests/unit/tools/pre-check-batch-secretscan-evidence.test.ts
Use lazy binding in source code so mocks can intercept:
// Good — mockable
import * as child_process from 'node:child_process';
function run() { return child_process.execFileSync('git', ['status']); }
// Bad — binds at load time, mock can't intercept
import { execFileSync } from 'node:child_process';| Step | Directories | Isolation |
|---|---|---|
| 1a | hooks (mock.module files — 15 files) | Per-file isolation (dedicated step) |
| 1b | hooks (remaining groups) | Per-file loop per group |
| 2 | cli | Batch |
| 3 | commands, config | Batch |
| 4 | tools | Per-file loop |
| 5 | services, build, quality, sast, sbom, scripts | Per-file loop |
| 6 | adversarial, agents, background, context, diff, evidence, git, helpers, knowledge, lang, output, parallel, plan, session, skills, types, utils | Per-file loop |
To prevent monolithic test files that cause mock isolation issues and slow CI:
- Maximum 500 lines per test file (enforced in CI by
scripts/check-test-file-cap.tsas a diff-scoped ratchet: new over-cap files and existing over-cap files that grew fail the gate; pre-existing over-cap files untouched by the PR are non-blocking;TEST_CAP_ENFORCE=0soft-warns). Run the same gate locally on any platform, Windows included, withbun run check:test-file-cap. delegation-gate.test.tswas split into 45 focused files (FR-006 SC-006.1) — all under 500 lines- When a test file exceeds 500 lines, split it by behavior/feature into focused files
Phase 3 files:
| File | Tests | Coverage |
|---|---|---|
tests/unit/commands/sync-plan.test.ts |
10 | FR-007 (sync-plan command) |
tests/unit/agents/sme.test.ts |
24 (75% parameterized) | FR-008 (SME delegation) |
tests/unit/parallel/lean-turbo-acquire-locks.test.ts |
14 | FR-009 (Lean Turbo locking) |
tests/unit/parallel/lean-turbo-plan-lanes.test.ts |
16 | FR-009 (lane planning) |
tests/unit/parallel/lean-turbo-review.test.ts |
13 | FR-009 (Lean Turbo review) |
tests/unit/parallel/lean-turbo-runner-status.test.ts |
18 | FR-009 (runner status) |
tests/unit/tools/generate-mutants.test.ts |
11 | FR-009 (mutation testing) |
tests/unit/config/set-qa-gates.test.ts |
19 | FR-009 (QA gate config) |
tests/unit/config/get-qa-gate-profile.test.ts |
9 | FR-009 (QA gate profile) |
Phase 4 files (FR-010/011/012 — previously untested hooks):
| File | Tests | Coverage |
|---|---|---|
tests/unit/hooks/conflict-resolution.test.ts |
— | FR-010 (conflict-resolution hook) |
tests/unit/hooks/curator-types.test.ts |
— | FR-010 (curator types) |
tests/unit/hooks/curator.test.ts |
— | FR-010 (curator consolidated) |
tests/unit/hooks/delegate-ack-collector.test.ts |
— | FR-011 (delegate-ack-collector hook) |
tests/unit/hooks/delegate-directive-injection.test.ts |
— | FR-011 (delegate-directive-injection hook) |
tests/unit/hooks/knowledge-reinforcement.test.ts |
— | FR-011 (knowledge-reinforcement hook) |
tests/unit/hooks/normalize-tool-name.test.ts |
— | FR-011 (normalize-tool-name hook) |
tests/unit/hooks/phase-complete-directive-gate.test.ts |
— | FR-011 (phase-complete-directive-gate hook) |
tests/unit/hooks/phase-directives.test.ts |
— | FR-011 (phase-directives hook) |
tests/unit/hooks/semantic-diff-injection.test.ts |
— | FR-011 (semantic-diff-injection hook) |
Phase 4 also consolidated knowledge-curator tests with shared fixtures (tests/unit/hooks/curator-test-fixtures.ts) and completed the vitest→bun:test migration across all 11 directories (cli, services, session, evidence, commands, build, lang, scripts, config, knowledge, context-map, hooks, tools).
CI enforces a minimum line-coverage threshold of 65.00% (recalibrated in issue #1778 H4 when the measured set grew to src/** + the orphan test trees; measured 73.41% at the time) on the merge queue. Coverage is measured using bun test --coverage with output configured in bunfig.toml:
# bunfig.toml
[test]
coverageReporter = ["lcov", "text"]
coverageDir = "./coverage"The coverage gate is a required status check but only runs on merge_group events (not on every PR, for speed). Since issue #2341 it is sharded: a coverage-shard matrix (6 ubuntu shards, the same round-robin partition as the unit job) each measures its partition per-file under coverage and uploads its merged lcov, and the dependent coverage aggregator job merges all shard reports and enforces the threshold once over the union, failing closed if any shard report is missing — a dropped shard can never silently shrink the measured set. To measure coverage locally (unsharded, full set + inline threshold):
bun test --coverage tests/unit/ --timeout 60000The coverage report is output to ./coverage/ as lcov.info and text summary.
Adversarial tests (tests/adversarial/) verify security boundaries against crafted malicious inputs. They cover:
- FR-003 / SC-003.1–SC-003.7: Subprocess injection — command injection vectors via
shell.safeify, bunSpawn routing, PATH traversal, null-byte injection, argument injection - FR-004 / SC-004.1–SC-004.4: Guardrail bypass attempts — prompt injection, capability escalation, schema override, context capsule exfiltration
- FR-005 / SC-005.1–SC-005.3: Evidence spoofing — plan mutations, phase伪造, retrospective fabrication
Run adversarial tests with the same isolation rules as unit tests:
# Per-file loop (required — adversarial tests may mock global state)
for f in tests/adversarial/*.test.ts; do bun --smol test "$f" --timeout 30000; doneAdversarial tests use _internals DI seams for mocking (avoiding mock.module cross-file leakage):
src/hooks/knowledge-migrator.ts:_internals— exposeswriteSentinel,mkdir,writeFile,existsSync,readFileSync,readFilefor evidence-spoofing testssrc/evidence/manager.ts:_internals— exposesvalidateEvidencefor evidence integrity tests
- Use
path.join(), never string concatenation with/ - Use
os.tmpdir(), never hardcoded/tmp - Mock
validateDirectoryfrompath-security.tswhen tests use Windows temp paths - On Windows, if
%USERPROFILE%(or another ancestor of%TEMP%) already contains both.swarm/and a project indicator such as.opencode/, evidence-writing tests can fail the.swarmcontainment guard before they reach your temp project root. For per-filetests/unit/tools/*.test.tspublication evidence, either clear the parent.swarm/contamination first or run the temp-root setup from a directory tree that is outside that contaminated ancestor chain.
See .opencode/skills/writing-tests/SKILL.md for the complete guide including:
- All mock isolation rules and patterns
- File placement conventions
- Test quality standards (DO and DO NOT)
- Cross-platform process spawning rules
- Pre-submission checklist