-
Notifications
You must be signed in to change notification settings - Fork 3
Tests Contracts
The contract test suite guards Clio's behavioral invariants across four lanes: contract tests (regression tests under tests/contracts/), smoke tests (end-to-end exercises of the built binary under tests/smoke/), extended tests (longer integration tests under tests/extended/ and tests/extended-smoke/, run only under test:full), and boundary tests (static import-rule enforcement under tests/boundaries/). Every lane shares the same state-isolation harness so that no test can leak into the operator's real home or interfere with another test's state.
The single most important invariant of the test suite is that tests and dev runs never touch the operator's real home. The mechanism is a preload script loaded via --import ./tests/harness/tmp-root.ts in every test invocation.
tests/harness/tmp-root.ts performs these steps at load time:
-
Reads the real temp dir before redirecting anything, resolving symlinks via
realpathSync(macOS hands out/var/folders/...which resolves under/private/var; the canonical form is needed for later path comparisons). -
Sweeps stale roots: removes any previous
clio-coder-tests-*directories whose recorded owner PID has exited and whosemtimeis older than 4 hours (STALE_ROOT_MS). -
Creates a per-run scratch root via
mkdtemp(join(systemTmp, "clio-coder-tests-"))and writes.owner.jsoncontaining the current PID. -
Redirects
TMPDIRto the scratch root, so every subsequentmkdtemp()call from any test module lands inside it. -
Sets Clio's state variables: if no test has already chosen its own
CLIO_CODER_*_DIRvalues, the preload setsCLIO_CODER_HOME,CLIO_CODER_CONFIG_DIR,CLIO_CODER_DATA_DIR,CLIO_CODER_STATE_DIR, andCLIO_CODER_CACHE_DIRunder the scratch root, plusCLIO_CODER_REQUIRE_HOME_PREFIX=1. -
Clears color-control env vars (
FORCE_COLOR,NO_COLOR,COLORFGBG,CLIO_CODER_THEME) so every test sees the same default theme. -
Installs the tmp-git guard via
installTmpGitGuardfromtests/harness/tmp-git-guard.ts, which refuses a.gitat either the system temp root or the run's scratch root. -
Removes the scratch root at exit via
process.on("exit"), but only ifisRemovableRoot()confirms it is safe to delete (checks the path starts with the system tmp, the basename starts with the expected prefix, andlstatSyncreports a real directory rather than a symlink).
The tmp-root preload runs in the runner process and again in each test child; a child inherits the root through the environment (CLIO_CODER_TEST_TMP_ROOT) and reuses it rather than creating a new one. Only the process that created the root removes it.
tests/harness/tmp-git-guard.ts enforces issue #205: a .git at the system temp root or at the run's scratch root silently flips isInsideGitRepo() for every mkdtemp scratch directory, breaking tests that rely on accurate repository detection. The guard:
- Wraps every
node:fscreator function (appendFile,mkdirSync,writeFile,symlink, etc.) to throw before the.gitentry is created. - Wraps every
node:child_processentry point (exec,spawn,fork, etc.) to check before and after for a.gitthat appeared. - Uses
syncBuiltinESMExports()to republish the wrappers through the builtin ES module facades so thatimport { mkdirSync } from "node:fs"sees the wrapper. - Reports offenders at exit and sets the exit code to 1.
The guard never deletes anything; a .git at either level that pre-exists the run is reported to stderr but left alone.
For tests that need a separate home within the run, tests/harness/scratch-env.ts provides three flavors:
-
makeScratchHome(): creates a freshmkdtempdirectory and returns its dir, an env object withCLIO_CODER_*vars set (includingCLIO_CODER_REQUIRE_HOME_PREFIX=1), and a cleanup function. Does not touchprocess.env— the child reads env fresh. -
isolateClioEnv(): in-process isolation with a fullprocess.envbackup/restore. It queues behind a process-wide async lock (acquireEnvLock) so that two test files running concurrently under--experimental-test-isolation=nonecannot overlap theirprocess.envmutation windows. This lock was added after issue #84:interop-consent.test.tsandinterop-state.test.tsboth had fully synchronous test bodies yet clobbered each other'sCLIO_CODER_HOMEbecause the Node test runner's scheduling inserted a gap between hook invocations that was enough for interleaving. -
newScratchClioHome()/clearScratchClioHome(): minimal in-process isolation that keeps the scratch dir as a plain string, for use withbeforeEach/afterEach.
All in-process flavors call resetXdgCache() so that src/ code re-resolves the scratch directories.
The package.json scripts define four test lanes:
| Script | What it runs |
|---|---|
pnpm test |
tests/contracts/*.test.ts, tests/smoke/acp-boundary.test.ts, tests/smoke/real-binary-boot.test.ts, tests/smoke/process-lifecycle.test.ts, tests/smoke/boot-handoff.test.ts with --test-concurrency=2
|
pnpm test:full |
tests/contracts/*.test.ts, tests/extended/*.test.ts, tests/smoke/*.test.ts, tests/extended-smoke/*.test.ts
|
pnpm test:file -- <file> |
A single test file (never builds) |
pnpm test:maintenance |
Two keyboard test files under tests/extended/
|
Both test and test:full preload tests/harness/tmp-root.ts via --import. The pretest and pretest:full scripts build dist/ only when it is missing (test -f dist/cli/index.js && test -f dist/metafile-esm.json || pnpm run build).
The ci script runs the full handoff gate: pnpm run typecheck && pnpm run lint && pnpm run build && pnpm run test && pnpm run test:maintenance && pnpm run check:gui && pnpm run test:gui.
tests/contracts/dispatch-routing-deterministic.test.ts proves that dispatch admission never waits on a decision model. The test:
- Creates a slow JEV (decision model) endpoint that answers every request 2.5 seconds late.
- Binds every entry in
DECISION_SITES(imported fromsrc/core/defaults.ts) to a profile using that endpoint. - Calls
bundle.contract.dispatch(REQUEST)and measures the wall-clock time from the dispatch call to thespawnWorkercallback. - Asserts that the JEV endpoint received zero requests (
jev.requests() === 0). - Asserts that the spawn delay is less than 1500 ms (proving it did not wait for the 2.5 s slow endpoint).
The test uses isolateDispatchState() and restoreDispatchState() from tests/harness/dispatch.ts to isolate the dispatch state directory, and dispatchStubContext from tests/harness/dispatch-stub-context.ts to provide a minimal DomainContext with a healthy openai-compat target, the production builtin agent recipes, a permissive safety contract, and an under-budget scheduling gate.
tests/contracts/engine-lifecycle.test.ts locks the ordering Clio's turn runtime relies on after the pi 0.84.4 prepareNextTurn change. The test file covers:
-
prepareNextTurn runs only before another assistant turn: a scripted provider returns a tool-call turn followed by a final turn; the test asserts the timeline is
["llm:1", "tool:one", "prepare", "llm:2"]and thatprepareNextTurnnever runs after the final turn. -
Steering during preparation: a steer queued while
prepareNextTurnis running is delivered before the next assistant call, landing after the tool result and before the continuation. -
Terminating tool batches: preparation is skipped entirely after a terminating tool batch, but the run still closes through
agent_end. -
Preparation failure: when
prepareNextTurnthrows, the run closes throughagent_endwith an assistant message carryingstopReason: "error". -
Transcript resets:
Agent.reset()is refused while a run is active (throws/already processing/); Clio's reset callers cancel and await settlement first, after which the same reset is accepted. -
Tool argument normalization:
validateEngineToolArgumentstreatsnullfor an optional non-nullable argument as omitted (pi-ai 0.84.2 behavior) while still rejectingnullwhere the schema requires a value. -
TUI keybinding table: pi-tui 0.84.4 alt-screen actions are routable through Clio's table without colliding with Clio app defaults; a deliberately scoped overlap (
ctrl+gforsearchNextwhile the search overlay is focused) is recorded. -
Alternate-screen render seams: the three protected seams Clio measures (
overlay,cursor,normalization) still run inside one fullscreen frame after pi 0.84.2 paints full-width rows as direct line references.
tests/contracts/fleet-dock.test.ts tests the Fleet dock's layout behavior:
-
Live Fleet runs reserve normal-flow rows: the test builds a layout with a transcript, Fleet dock, editor, and footer, renders it in both
regularandfullscreenmodes across widths 40–200, and asserts that the transcript sentence is preserved intact, the Fleet dock appears between the transcript and the editor, and every row fits within the terminal width. - A live run is never drawn by a transcript-covering ticker overlay: the test creates an interactive tickers component and asserts that when a dispatch row is active, the overlay does not render any line containing "Fleet runs".
tests/contracts/quota-claude-code-provider.test.ts tests the Claude Code quota provider end-to-end with a stubbed fetch:
- Parses the
limits[]usage shape into ordered windows (session, weekly, scoped weekly). - Falls back to the flat
five_hourandseven_daybuckets whenlimitsis empty. - Maps HTTP 401 and 403 to an expired session.
- Captures
Retry-Afterwhen the usage endpoint rate limits the read. - Reports other failing statuses and malformed JSON as errors.
- Reports missing credentials without spending a network call.
- Detects Claude Code's own credential file and ignores unusable ones.
- Refuses a token the credential record already declares expired, without a request.
- Asks for a fresh sign-in when the refresh token is gone or also expired.
- Still calls the endpoint when the record carries no expiry at all.
- Parses
Retry-Afterand plan strings defensively. - Serves a cached snapshot inside the TTL and refetches after it expires.
- Falls back to the last good snapshot when a refresh fails.
- Reports a failure directly when nothing good was ever cached.
- Registers explicitly connected adapters in display order.
The smoke tests exercise the built dist/cli/index.js binary end-to-end. They use the same isolateClioEnv pattern and the CLIO_CODER_* env vars to redirect all state into a scratch directory.
tests/smoke/acp-boundary.test.ts drives the ACP (Agent Client Protocol) stdio boundary:
-
Terminal authentication:
acp auth loginwithout a terminal exits with code 2 and an error message about--quickneeding a terminal. -
Missing service credentials: a target with
auth.apiKeyEnvVarset but the env var unset is rejected before admission withprompt_not_admitted/authentication-required; after logging in viaauth login, the same session is reopened and the turn completes. -
Text turn and unadmitted prompt: a configured home serves a text turn; an unconfigured home rejects the prompt with
prompt_not_admitted. -
Same-child session close/new: a direct ACP
session/closefollowed bysession/newresets history, ancestry, and tasks on the same child process; the original session can be resumed viasession/load. -
Cancelled permission response: responding
cancelledto asession/request_permissionaborts the turn before the tool can resume the model. -
Permission allow and reject: mediating one unrecognized shell write
allow-onceand onereject-onceproduces the expected file creation or non-creation and tool status.
tests/smoke/real-binary-boot.test.ts tests the built binary's boot path:
-
v1 settings upgrade: writes a v1
settings.yaml, runsclio-coder upgrade, asserts two migrations applied, a backup file is kept, and the migrated settings match the expected v2 shape. Re-runningupgradereports no pending migrations and leaves the file unchanged. -
--autonomyflag: rejects unsupported values, refuses to drop the flag on subcommands that would silently ignore it, and accepts it for the interactive session without rewritingsettings.yaml. -
First-run setup: takes a genuinely empty home through the interactive setup wizard, seeding a target via
lmstudioand reaching the editor.
The tests/extended/ and tests/extended-smoke/ directories contain longer integration tests that run only under pnpm run test:full. The repository handbook is explicit: "A regression test belongs in tests/contracts/. tests/extended/** runs only under pnpm run test:full, never in CI (except the two keyboard files test:maintenance runs), so a test placed there guards nothing."
tests/extended-smoke/headless-artifact.test.ts is representative: it drives the built clio-coder run binary with a seeded OpenAI-compatible fixture, asserts the terminal artifact was written, and verifies the sealed receipt against its ledger row. The sealedReceipt() helper from tests/harness/headless-run.ts reads the run journal via readRunJournal(), finds the single receipt, locates its envelope in runs.json, and calls verifyReceiptIntegrity() to confirm the integrity seal binds them.
The worker entry can run end-to-end without provider credentials through CLIO_CODER_WORKER_FAUX. When this env var equals "1", registerFauxFromEnv() in src/engine/ai.ts registers a pi-ai faux provider and queues a single deterministic assistant response. The faux model is wired into the worker runtime at src/engine/worker-runtime.ts:432:
const fauxModel = registerFauxFromEnv();
// ...
const model = applyModelCapabilityPatch(
input.target.runtime === "faux" && fauxModel ? fauxModel : synthesized,
input.modelCapabilities,
);The faux provider responds to every prompt with the configured text (default "ok") and stop reason (default "stop"). Additional env vars control the response:
| Env var | Purpose | Default |
|---|---|---|
CLIO_CODER_WORKER_FAUX |
Must equal "1" to arm registration |
— |
CLIO_CODER_WORKER_FAUX_MODEL |
Model id registered under the faux provider | "faux-model" |
CLIO_CODER_WORKER_FAUX_TEXT |
Assistant response text | "ok" |
CLIO_CODER_WORKER_FAUX_STOP_REASON |
Assistant stop reason | "stop" |
CLIO_CODER_WORKER_FAUX_ERROR_MESSAGE |
Optional assistant error message | — |
This mechanism is documented in docs/guide/environment-variables.md and is recognized by the hygiene checker (scripts/check-hygiene.ts:615) as a valid env var pattern.
The receipt system provides a durable, tamper-evident account of every dispatch run. The test harness for receipts is split across three files:
-
tests/harness/receipt.ts: providesfixtureEnvelope(runId)andfixtureReceiptDraft(envelope)that construct complete, valid receipt envelopes and drafts. The integrity digest covers every receipt field, so a new required field breaks every suite at once. -
tests/harness/run-journal.ts: providesreadRunJournal(stateDir)which reads the run ledger (runs.json) and the receipts directory understateDir, returning aRunJournalwith envelopes keyed by run id and all parsed receipts. -
tests/harness/headless-run.ts: providesheadlessScratch(prefix)which creates a scratch home initialized bydoctor --fix,runCli(args, options)which spawns the built CLI with a timeout, andsealedReceipt(stateDir)which reads the run journal, finds the single receipt, verifies its integrity against the ledger row, and returns both.
The receipt integrity check is performed by verifyReceiptIntegrity(receipt, envelope) from src/domains/dispatch/receipt-integrity.ts. The test tests/contracts/acp-receipt-tool-truth.test.ts demonstrates the receipt capture flow: it dispatches a run against an ACP peer that emits tool call and tool result frames, and asserts that the sealed receipt records the tool kinds (edit, execute), completed spans, mutations, and safety decisions.
tests/boundaries/check-boundaries.ts implements six static import rules that enforce architectural isolation between domains. The runBoundaryCheck(projectRoot) function walks every TypeScript file under src/, extracts all import specifiers (including dynamic imports and /// <reference> directives), and evaluates each against the rules:
| Rule | Constraint |
|---|---|
| Rule 1 | Only src/engine/** may import @earendil-works/pi-* at all, including type-only imports. Since the 0.83.0 engine-boundary rework there is no type-only exception. |
| Rule 2 |
src/worker/** never value-imports src/domains/** except the three provider modules allow-listed in isAllowedWorkerProviderValueImport (plugins.ts, registry.ts, runtimes/builtins.ts). Type-only imports are allowed. |
| Rule 3 |
src/domains/<x> never imports src/domains/<y>/extension.ts for y != x. Use the contract exported from src/domains/<y>/index.ts instead. |
| Rule 4 |
src/tools/** never imports src/interactive/**. The tool substrate is surface-agnostic. |
| Rule 5 | The chat loop's turn modules (chat-loop.ts, turn-*.ts) never import src/entry/**. Composition flows one way: the entry point composes the loop, never the reverse. |
| Rule 6 | Any value importer outside the computed Stage 0 closure and its src/interactive/** and src/engine/** trees reaches those protected trees only through a declared seam in STAGE0_SEAMS. A seam may not lead back into Stage 0 unless that existing composition-root overlap is explicitly declared. |
Rule 6 has a second half: even when a seam is declared, the seam's own import closure must not reach into the Stage 0 owner's closure. The Stage 0 owner is src/interactive/terminal-lease.ts, and its closure is pinned to 16 chunks, 700,000 total bytes, and 175,000 Clio source bytes by tests/contracts/instant-shell-import-graph.test.ts.
The STAGE0_SEAMS array contains 30+ declared edges, each with a written reason explaining why the edge exists. For example: src/engine/types.ts is a seam because "the erased engine shapes (AgentMessage, ImageContent) domains and the CLI both take. Type-only at every call site."
-
The tmp-root preload is load-order dependent. It runs before every test module, so any test that reads
TMPDIRorCLIO_CODER_*env vars sees the redirected values. A test that needs the real temp dir must save and restore the original value explicitly. -
The
isolateClioEnvlock is process-wide. A test that callsawait isolateClioEnv()inbeforeEachandrestore()inafterEachholds the lock for the duration of the test. If two test files run concurrently (under--experimental-test-isolation=none), they serialize on this lock. A test that fails to callrestore()deadlocks every subsequent test. -
The tmp-git guard wraps
node:fsandnode:child_processglobally. It usessyncBuiltinESMExports()to republish the wrappers through the builtin ES module facades. A test that monkey-patchesnode:fsornode:child_processmay break the guard or vice versa. -
Smoke tests require a built
dist/. Thepretestscript buildsdist/only when it is missing. A staledist/is not rebuilt, and smoke tests run the built binary. After source changes, runpnpm run buildbeforepnpm test. -
Extended tests run only under
test:full. A regression test placed intests/extended/does not run in CI (except the two keyboard filestest:maintenanceruns). Regression tests belong intests/contracts/. -
The boundary checker reads any inline
{ type X }clause as a value import. Write type-only imports asimport type { X }to avoid rule 2/rule 6 failures. -
The faux model is worker-only.
registerFauxFromEnv()is called fromsrc/engine/worker-runtime.ts, which is the worker subprocess entry point. It is not available in the orchestrator process. -
The receipt integrity digest covers every field. When adding a new field to
RunEnvelopeorRunReceiptDraft, the fixture intests/harness/receipt.tsmust be updated in the same change, or every receipt test breaks at once.
Source and generation metadata
title: "Contract tests"
summary: "The contract test suite that guards Clio's behavioral invariants, covering state isolation, dispatch routing, engine lifecycle, smoke tests of the built binary, ACP boundary behavior, and import-boundary enforcement."
sources:
- "tests/harness/tmp-root.ts"
- "tests/harness/scratch-env.ts"
- "tests/harness/dispatch.ts"
- "tests/harness/receipt.ts"
- "tests/harness/run-journal.ts"
- "tests/harness/headless-run.ts"
- "tests/boundaries/check-boundaries.ts"
- "tests/contracts/dispatch-routing-deterministic.test.ts"
- "tests/contracts/engine-lifecycle.test.ts"
- "tests/contracts/fleet-dock.test.ts"
- "tests/contracts/quota-claude-code-provider.test.ts"
- "tests/smoke/acp-boundary.test.ts"
- "tests/smoke/real-binary-boot.test.ts"
symbols:
- "registerFauxFromEnv"
- "isolateClioEnv"
- "makeScratchHome"
- "runBoundaryCheck"
- "sealedReceipt"
- "readRunJournal"
- "installTmpGitGuard"
tests:
- "tests/contracts/dispatch-routing-deterministic.test.ts"
- "tests/contracts/engine-lifecycle.test.ts"
- "tests/contracts/fleet-dock.test.ts"
- "tests/contracts/quota-claude-code-provider.test.ts"
- "tests/smoke/acp-boundary.test.ts"
- "tests/smoke/real-binary-boot.test.ts"
invariants:
- "Tests never write to the operator's real home directory; the tmp-root preload redirects all Clio state into a per-run scratch root."
- "A `.git` marker at the system temp root or the run's scratch root is refused by the tmp-git guard, preventing ignore-policy contract failures."
- "Dispatch routing never awaits a decision model; all decision sites are skipped during admission."
- "The engine lifecycle ordering after pi 0.84.4 is locked: prepareNextTurn runs only before another assistant turn, never after a final or terminating turn."
- "Only src/engine/** imports @earendil-works/pi-*; the boundary checker enforces this and five other import rules."
validate:
- "pnpm test"Clio Coder · Repository · Website · Documentation
Wiki v0.1 · Developing implementation reference · Source snapshot: 657dce13d. Authored architecture documents define the product contracts.
- Clio Coder GUI Client
- apps / clio-coder-gui
- Apps clio coder gui server
- Apps clio coder gui tests
- apps
- Architecture
- Command-line surfaces
- Core
- Domains agents
- Config Domain
- Context Domain
- Dispatch domain
- Domains evidence
- Domains extensions
- Domains gateway
- domains
- Domains interop
- Domains lifecycle
- Domains memory
- Middleware Domain
- Domains mux
- Domains observability
- Domains plugins
- Prompt Compiler
- Domains providers
- Domains quota
- Domains resources
- Domains safety
- Domains scheduling
- Domains session
- Vendored Tool Registry and Resolution
- Engine
- Engine acp
- Engine apis
- engine
- Entry point
- Interactive
- interactive
- Interactive overlays
- Interactive renderers
- clio-coder wiki
- Scripts
- Contract tests
- Tests extended
- tests
- Tools
- Tools data
- tools
- Tools verify
- Worker runtime