Around 540 tests, ~5 seconds for the whole suite including a full build. The design goal is that the inner loop is fast enough that you never avoid running it.
npm test # typecheck + build + everything. The gate.
npm run typecheck # tsc --noEmit over src/ and test/
node --test "test/core/**/*.test.ts" # ~0.3s, no build needed
node --test test/golden.test.ts # one file
node --test --test-name-pattern "binding walk" "test/**/*.test.ts"
npm run test:e2e # Tier A: the REAL binary vs a recorder
npm run test:e2e:real # Tier B: vs the REAL CLIs (Docker; see below)Most suites need no build. Node executes .ts directly, and dist/ is
irrelevant to them. The exceptions are the three UI suites — test/ui.test.ts,
test/picker.test.ts, test/profiles-ui.test.ts — which drive the wizard
through the built dist/ui.js and therefore need npm run build first.
npm test always builds, so it is the safe default; node --test "test/core/**"
is the one you run every thirty seconds.
Node runs each test file in its own process. That is why
test/architecture.test.ts can monkey-patch process.emitWarning without
affecting anything else.
| Path | Kind | What it holds |
|---|---|---|
test/core/** |
Unit | Pure-function tests for src/core/** and the Claude Code lowering. Fast, exhaustive, no I/O. |
test/adapters/** |
Adapter | The filesystem config store, the process adapter, the doctor probe, the catalogs, each agent adapter. |
test/architecture.test.ts |
Structural | The layering and launch-path invariants, asserted against the source import graph. |
test/ports.conformance.ts |
Type-level | The whole adapter surface bound against the whole port surface. Not a *.test.ts. |
test/golden.test.ts |
Characterization | The exact environment every shipped provider hands to Claude Code. |
test/registry.test.ts |
Invariant | Descriptor rules every provider must satisfy. |
test/ui.test.ts, picker, profiles-ui |
End-to-end | The Ink wizard, driven with synthetic keystrokes. |
test/config-commands.test.ts, launch-overrides.test.ts |
Integration | Subcommand dispatch and the override pipeline through a composition root. |
test/e2e/*.e2e.ts |
End-to-end (hermetic) | The real bin/swisscode.js, launched against a recorder. See below. |
test/e2e/*.real.ts |
End-to-end (real CLIs) | The real binary vs the real coding CLIs, in Docker. See below. |
test/support/fixtures.ts |
Helper | Typed constructors for deliberately incomplete domain objects. |
architecture.test.ts walks the static import closure from src/cli.ts and
asserts what it must never reach — React, Ink, node_modules, .tsx,
adapters/ui, adapters/catalog, config-root, the doctor, fetch, network
node: modules — plus the layering rules for core/ and ports/.
Two properties make it trustworthy. It asserts on the import graph, not on
startup time, so a loaded CI box cannot make it flake. And the layering checks
run post-type-erasure (via node:module's stripTypeScriptTypes), so they
check the program the file actually becomes: a comment mentioning
ANTHROPIC_API_KEY passes, emitted code naming it fails, and a mixed
import {type A, b} — which keeps a live runtime binding — correctly fails where
a regex over the source could not tell the difference.
ports.conformance.ts is checked by tsc, not by node --test. It has no
runtime assertions, which is why it is deliberately misnamed: node --test skips
it and npm run typecheck runs it. It lives in test/ rather than src/
because tsconfig.build.json emits src/ to dist/, and a file whose only
purpose is compile-time checking must not ship.
golden.test.ts pins the exact env map each provider produces against one
fixed, deliberately polluted ambient environment (a stale ANTHROPIC_API_KEY, a
stale ANTHROPIC_BASE_URL, stale tier models). Those maps are the contract.
A change to one is fine; a change to one that does not update the golden map is
not — the point is that a human reads the diff on purpose. Its header comments
record every intentional change since 0.1.0 and why, which is the format to
follow when you change one.
Adding a provider without adding its GOLDEN entry fails on a dedicated test:
every shipped provider has a golden map.
registry.test.ts generates a test per provider for each descriptor
invariant: no hand-typed [1m], no /v1 suffix on a base URL, third-party
endpoints always clear ANTHROPIC_API_KEY, extendedContext agrees with
defaultModels, and compat flags are real. A new provider inherits all of them
for free.
It also holds the rule that a compat flag which trades something away must
declare what — and that no descriptor may write that flag's variable through the
env block, since env would set it while skipping the warning the compat
mechanism attaches. That replaced a deny-list naming one variable: the objection
was always that a costly switch must not act silently, which is a property of
the mechanism rather than of any one name, and a deny-list does not generalise to
the next provider with its own rules.
Tests routinely pass deliberately incomplete domain objects — {apiKey: 'k'}
where the type says Profile. That is the point of them: a unit test for
buildEnvPlan has no business inventing a provider field the function never
reads, and filling it in to satisfy the compiler would change the input under
test.
So use the constructors in test/support/fixtures.ts:
import { makeProfile, makeState, makeDescriptor } from '../support/fixtures.ts'
const profile = makeProfile({ apiKey: 'k', models: { opus: 'glm-5.2' } })They are identity functions — no field added, none removed, same object identity.
What they buy is that the argument is checked in full against Partial<Profile>
in a fresh-literal position: a misspelled key is TS2561, a wrong value type is
TS2322, nested types are checked. Only missingness is waived.
x as unknown as Profile is explicitly rejected: it compiles always and checks
nothing, so a typo'd field goes silently into the fixture. The file's header
comment explains why plain x as Profile also fails once a fixture sets a
Record<string, T> field.
Domain logic → test/core/<module>.test.ts. Plain node:test + node:assert/strict.
Call the pure function directly with fixtures. No mocks needed, because there is
nothing to mock.
An adapter → test/adapters/…. Inject fakes through the port; the composition
roots all take a LaunchDeps bag for exactly this reason.
const proc = { env: () => ({}), cwd: () => '/tmp', resolveBinary: …, replace: … }
const planned = planLaunch({ store, registry, agents, proc, /* … */ })Counting stubs are a good pattern here: test/core/overrides.test.ts pins "per-run
overrides never persist" with a store.save that counts calls and asserts zero.
A new invariant → add it to test/architecture.test.ts next to its siblings,
and state it in ARCHITECTURE.md too. A rule
documented but not enforced will be broken within two releases.
A UI change → the three wizard suites drive Ink with ink-testing-library and
synthetic keystrokes, written with React.createElement rather than JSX so they
run under plain node with no build of their own. They set XDG_CONFIG_HOME to
a temp directory; never let a test touch the real ~/.config/swisscode.
- A bug fix ships with a test that fails without the fix. If you cannot write one, say so in the PR and explain why — sometimes that is a legitimate answer, and it is a useful thing for a reviewer to know.
- No wall-clock assertions. Nothing may assert "this was fast". Assert on the structure that makes it fast.
- No network in tests. The doctor's probe is tested through an injected fake.
The only code allowed to make a real request is
adapters/doctor/probe, and it only runs when a user asks forconfig doctor. - Deterministic or it does not merge. No reliance on ordering that is not guaranteed, on real time, or on a machine's installed binaries.
- Tests are read as documentation. Name them as sentences describing the
property:
an unknown positional falls through, an unknown flag does not,no launch inherits a stale ANTHROPIC_API_KEY it did not ask for.
Everything above asserts on the launch plan — the in-memory object
planLaunch / buildEnvPlan produce. Nothing there runs the real binary and
observes what it launches. The e2e harness closes that gap, in two tiers, both
under test/e2e/.
Tier A — hermetic (*.e2e.ts, npm run test:e2e, every PR). The three
SWISSCODE_*_BIN overrides are pointed at recorder.mjs — a tiny fake agent
that writes down its own argv, env and cwd, then exits. The harness seeds a temp
config, runs the real bin/swisscode.js, and reads that capture. So the whole
pipeline runs for real — argv parsing, config load, resolution, env lowering,
the execve handoff — with no network, no credential and full determinism.
The assertion a plan test cannot make: for a third-party provider, the stale
ANTHROPIC_API_KEY from the polluted ambient env is absent from the launched
child. Present in plan.unset is not the same as gone from the process; only a
real launch shows the difference. This is test/golden.test.ts's claim, verified
one layer further out.
The recorder is copied into a temp dir, never symlinked or run from inside
the repo, because swisscode's recursion guard resolves candidates with
realpathSync and rejects anything under its own install directory — a symlink
resolves back into the repo and is (correctly) refused. A copy under /tmp is
accepted exactly as a real agent would be. The recorder exiting immediately is
also why this is not the "do not launch for real" hazard CLAUDE.md warns
about: it is the safe launch that warning leaves room for.
Tier B — real CLIs (*.real.ts, npm run test:e2e:real, manual only). A
Docker image (test/e2e/Dockerfile) installs the three actual CLIs at pinned
versions and runs swisscode against them. It forwards only --version, which
every CLI answers before any auth — non-billable, no secret, no cost. It cannot
be deterministic (it depends on upstream), so it never gates a PR; it catches an
upstream flag rename or a rejected env var when someone runs it:
docker build -f test/e2e/Dockerfile -t swisscode-e2e .
docker run --rm swisscode-e2eThe .real.ts extension is deliberate: test:e2e globs *.e2e.ts and
npm test globs *.test.ts, so the three sets are disjoint and a PR that has
not installed the real CLIs stays green.
.github/workflows/ci.yml runs npm test and then npm run test:e2e on every
push to main and every pull request, across Node 22 and 24 on ubuntu and macOS.
.github/workflows/e2e-real.yml runs Tier B, on manual dispatch only.
Node 22 is not there for symmetry — it is the floor engines promises, and the
version where the toolchain assumptions have to hold: type stripping is what lets
the suite run straight from .ts, and tsc's emit is equivalent to that stripped
program only because target: es2023 downlevels nothing there.
The execve dispatch inside createNodeProcess().replace() is now covered — by
the e2e harness, which runs the real binary in a child process, lets it execve
into the recorder, and reads back what landed. Taking that branch replaces the
child, not the test runner, which is what makes it observable. (Even Node 22.23
backported process.execve, so both supported versions take the execve branch;
the spawn fallback fires only where execve is truly absent — Windows and
Node < 23.11 — and stays unit-tested via spawnFallback with an injected
SignalHost. Running the e2e under both Node versions is defence in depth, not
two different dispatches.)
Windows is deliberately not in the matrix. execve does not exist there, the
POSIX mode bits the config store asserts are meaningless, and nobody has verified
the suite on it; claiming coverage we do not have would be worse than the gap.
Fixing that is a genuinely useful contribution.
publish.yml runs the same npm test on a v* tag before publishing, so a red
tree never reaches the registry.