Testing policy
Status: Implemented for the rules marked lint; the rest is review
(decision). Tests prefer real boundaries over mocks of
owned code, because a mock can keep a test green while the real path is
broken. The maia-test ESLint rules
cover tests and testing/ support in every workspace src/.
Boundary
Test observable behavior through the owning public interface, at the lowest
boundary that can fail for the real reason: pure logic as a unit, storage on
real SQLite (:memory: or a temporary file with real migrations), transport
over real authenticated loopback, a process contract against the built
executable, and a user journey in e2e/ on the exported artifact.
Doubles
Prefer, in order: the real thing, a faithful fake, a stub, a mock. Doubles are
allowed only for third-party APIs behind an owned adapter, OS and native
facilities (procfs, signals, ChildProcess), and fault injection through a
narrow typed port the production code already declares.
- lint
owned-module-mock: novi.mock,vi.doMockormock.moduleof a relative,@/or@maiaos/*module. - lint
owned-double-cast: noas unknown asinto an owned type (LocalNodeClient,CliDependencies,NodeDatabaseResourceand the like): one from a relative,@/or@maiaos/*module, or a local type built from one. Build the real client withcreateLocalNodeClientover a scripted transport, and pass invalid input to the decoder asunknown. Casting a vendor value passes;vi.fn()and Effect values count. OS facility aliases are listed in the policy object. The rule does not seeas never; reviewers do.
A fake shared by several test files lives in the owning scope's testing/
and passes the same contract cases as the live implementation.
Time
Do not sleep to sequence a test: wait on the event (Deferred, Latch, a
stream line, child IPC). Effect code runs under TestClock
(it.effect/it.layer in Vitest, TestClock.layer() with node:test) and
advances it with TestClock.adjust. A wall-clock deadline is only a hang
guard, at least ten times the typical duration, never the assertion. A speed
budget counts the CPU time of the work (process.cpuUsage, median of warm
runs), which machine load does not stretch like wall time. A test that does
real I/O (real SQLite files, the built Node and CLI) sets its own timeout as
the hang guard, never a longer global default, and splits a loop of independent
cases into one test per case so each gets a fresh timeout. Vitest's 5 s default
guards only a test under 0.5 s idle, and a gate at load 25 to 40 stretches a
1 s test to 5 s or more: size the guard from the idle duration, with
vi.setConfig({ testTimeout }) in a file whose tests all do such work.
- lint
test-sleep: nosetTimeout/setIntervalcall (global or fromnode:timers) unless its handle or delay is named for a hang guard (deadline = setTimeout(…),HANG_GUARD_MS). Inside anew Promiseexecutor, a timer that resolves is a sleep and one that rejects is a deadline; elsewhere the function that owns the timer must be named for a guard (withDeadline). Nonode:timers/promises, and novi.useFakeTimersin a file that imports Effect.
Hermetic runs
The node:test preload (scripts/lib/test-environment.mjs) points
MAIA_HOME at a throwaway directory and drops every GIT_* variable. Tests
own their temporary directories and never touch ~/.maia, the developer's
repository or a non-loopback network. pnpm test puts a relative
./node_modules/.bin first on PATH; code that fails closed on a relative
PATH entry (the vendor CLI detector) needs a test that passes it absolute
entries only.
Contracts and assertions
Every callable operation needs a protocol schema test, an SDK request test
over a transport, a Node handler test on real SQLite and, where the CLI or SDK
exposes it, a built-boundary test; scripts/quality/registry-parity.mjs
checks that the registry, SDK and CLI agree. Assert outcomes (values, rows,
receipts, events, bytes); keep call assertions for "must not happen" guards.
Fixtures, snapshots and flakes
Third-party fixtures are recorded from the real API, redacted, committed with the vendor version and date, and typed or decoded without casts. Avoid snapshots; CI never writes them. No global retries: fix a flaky test within a working day, or record it with its cause, owner and exit date. A fixture that needs a kernel condition it cannot force (a loopback socket the peer blocks) may detect that the condition was lost and rerun that one scenario on fresh fixtures; product assertions never retry.
Coverage
pnpm coverage measures line and branch coverage per package: Node's built-in
coverage for the node:test suites and @vitest/coverage-v8 for protocol and
Node. The metrics coverage suite records it; no floor
gates it yet. A bug fix includes a test that fails without the fix.
Baseline
The testQuality section of scripts/quality/lint-policy-baseline.json
holds the counts that predate these rules, per (rule, path), and only
shrinks, like the Effect baseline. After a migration,
run node scripts/eslint/test-quality/audit/audit.mjs --prune. The rules read
syntax: a stub typed some other way or a sleep behind a helper passes them;
reviewers still check those.
Imported from Maia OS docs at a2175bb