ADR-0010 Architecture decision record
BDD acceptance tests via Cucumber.js (black-box, scenario-isolated)
jr2's per-package tests are node --test suites that run in-process and socket-free (e.g. the CLI's cli.test.ts calls main(argv, io) with fetch bound to app.request). Those are fast and cover unit and integration behavior, but nothing exercises the system the way a user does — the real jr2 binary, a real HTTP socket, exit codes, signals. This ADR adds a second, complementary tier: black-box end-to-end acceptance tests, written in Gherkin and run by Cucumber.js, living in a top-level ./features/ workspace package (@jr2/e2e).
What we decided
- Two tiers, not one. In-process
node --teststays the unit/integration tier (fast, no sockets, per package). Cucumber.js is the e2e tier: each step spawns the realjr2binary as a child process against a real orchestrator server on an ephemeral port, and asserts only on what a shell sees — captured stdout (the one machine-readable result), stderr (human activity), and the exit code. The e2e tier does not re-test what the in-process tier already covers. - The per-scenario orchestrator is the image entrypoint, not a CLI verb. There is no host dev mode (ADR-0019), but the Orchestrator's server entrypoint is an ordinary Node process — the test fixture boots exactly that process on the host, with
--url/token env pointing the spawnedjr2binary at it. Same real server the cluster runs, no cluster, no user-facing verb. The wire-compatible stub Harness (ADR-0011) is a fixture owned by this tier and served the same way. Feature→Rule→Scenariomaps to criteria. ARuleis one acceptance criterion; aScenariois an example of it. This is organizational only —Rules carry no shared state.Scenariois the isolation unit — and the environment is part of it. Each scenario gets its own scaffolded temp instance (via the realjr2 init), its own server process + sqlite store, torn down in anAfterhook. It also gets its own environment: the World spawns nothing from the ambient one, because a tier that inherits the developer's shell has made that shell a silent participant in every scenario. Two classes are sealed. EveryJR2_*variable is scrubbed, since they say where to talk and as whom — an exportedJR2_URLwould point a whole suite at someone's deployed instance, andJR2_NAMESPACEreaches the entrypoint's data-plane switch (composesSandbox && namespace !== undefined), which would turnworkspace.feature's "no cluster, so it faults pointedly" into a real Sandbox provisioned on whatever cluster was configured. Each tier adds back exactly what it means to set, and the variables a scenario's own config reads arrive through the instance's.env, a file the step wrote.KUBECONFIGis pinned to/dev/nullwherever there is no cluster in the story, so a run-verb falling through to the kube path finds nothing rather than the last cluster a human used;@kindand@distown a namespace on a real cluster and keep the real one. (The leak this closes was not theoretical:cli-contract.feature's exit-code scenario hung for its full 120s timeout against an unreachable home cluster, where it should fail in milliseconds — and it hung only on the machine that had one.) Nothing is shared across scenarios, so the suite is safe undercucumber-js --parallel.- An opt-in
@kindtier for the data plane (added once theworkspace()slice landed — ADR-0012). Workspaces are always real Sandboxes, so the only way to test them is against a real cluster: the@kindscenarios drive the operator's Sandbox CR, a pod, the node's read-only Repo cache, an in-pod git worktree, and the Harness endpoint — faking only the LLM. (Originally the Sandbox ran a dev Harness image, a wire-compatible stub in a container withgit; ADR-0038 retired it. The pod runs the STOCK Harness now — real pi, the real Menu through the Custodian, real Working tools — and the substitution moved to the provider: a scripted OpenAI-compatible endpoint the World serves from the host. Which is what makes this tier a second pi canary.) They are tagged@kindand excluded from the default profile, so the everyday suite needs no docker. Bring-up is the product's own path (ADR-0019): one shared VANILLA kind cluster and nothing else, thenjr2 upper scenario into a fresh namespace — which since ADR-0038 builds and loads every image it deploys, so no image is pre-loaded by hand. Namespace-as-identity makes the scenario the isolation unit here too, so nothing is instance-bound to the cluster. The host address the pods dial back on is discovered, not assumed. The scripted provider runs host-side, so the tier needs the one address that routes from a pod back to this process — and it is not the same on every machine. On Linux the daemon IS the host, so the kind bridge's gateway is the answer. Under a VM-backed daemon (colima, Docker Desktop) that gateway is the VM: measured on colima,172.18.0.1reaches nothing whilehost.docker.internal,host.lima.internaland192.168.5.2all answer — and on Linux those names mean nothing. So the candidates are ordered by platform and the winner is VERIFIED, bycurling the provider that is already listening from inside a kind node (a pod's egress routes through it), for the price of onedocker execmemoized per worker process. Taking the bridge network's first IPAM entry is what broke: docker lists the IPv6 subnet first on some hosts and it carries no gateway at all, so the tier now reads every entry that HAS one, IPv4 first. Failing to find any address is one error naming every candidate tried, not an assertion in each scenario'sBeforehook. Measured: 22/22 on macOS/colima, ~3m20 warm at degree 4 against the ~1m35 Linux figure below — the VM is slower, not different. The tier runs parallel, and three properties are what make that safe. Scenario isolation (own namespace, own scripted model port); no shared mutable image name between concurrent converges of one checkout — a Sandbox Image builds straight to its content tag, with no intermediate, because the Harness arrives by volume at pod time (ADR-0037) — while ADR-0041 makes a warm scenario's converge build nothing (~13s fresh-namespace converge, every build disk-skipped); and connection retries at the two seats that dial a Sandbox before anything else ever has — ADR-0042 has the diagnosis (a Sandbox CR atphase: Readyis not yet ROUTABLE, so an unretried first dial is a lost turn; the Menu read was a second instance of the same defect). Parallelism does not create those windows, it only samples them more often — which is why any tier flake that vanishes at--parallel 1should be read as a routability-class defect first, not as an isolation bug. Measured: degree 4 → 8 consecutive runs, 88/88 scenarios, 1m30–1m43s each on a sealed tree (serial: ~5m). The wired default is now--parallel 4(in thekindprofile, so a directnpx cucumber-js --profile kindgets it too); pass--parallel 1to bisect a suspected isolation bug. Three tier changes came out of the hunt and stay. A FAILED@kindscenario dumps its evidence (the provider's request count,jr2 status, the Harness's?view=history, every pod log with timestamps, events, EndpointSlices) tofeatures/.tmp/kind-failures/before its namespace is deleted. The tier's own workflow ROUTESagent.faultinstead of ignoring it — an ignored terminal fault is an invisible one, and a tier that hangs where it could name the reason is a tier that costs a session per defect. And every scenario, passing or not, is now scraped for what ADR-0042's retries COST (jr2.routability seat=… attempts=… ms=… last=…), against a budget of 30s or 8 attempts judged per worker inAfterAll— two meters because a REJECT spends attempts and a dropped SYN spends time, and the tier measured one of each in its first two runs (2 attempts / 10667ms, then 3 attempts / 502ms /ECONNREFUSED). That last one is the tier watching the fix rather than merely enjoying it: the retry is absorbed by design, so without it a cluster drifting toward the 90s window would keep passing — slightly slower, silently — until the day it did not. - An opt-in
@consoletier for the Console's UX (added with ADR-0032). The Console's risky behavior is interaction — token-mode switching, the frame-triggered gate inbox, selection vs. folding — which no reducer test sees and no CLI step drives. These scenarios hold a Playwright page inside ordinary Cucumber steps (Playwright the library, not the@playwright/testrunner): the browser is a second driver of the same black box, reusing the per-scenario orchestrator fixture unchanged, and a scenario may drive both (the CLI starts a run; the browser sees it park). Tagged@consoleand excluded from the default profile exactly as@kindis — the everyday suite must not require a Chromium install — but they need no docker: a host-booted orchestrator serves the Console fine. Scope discipline: they assert what a user does and sees, never pixels or layout; what the page believes stays inconsole-store.test.ts. - Harness conformance, in
@jr2/harness, in the default gate (ADR-0027). The three tiers above all reach the Harness through the stub Harness, which is a hand-written model of the wire — it holds no conversation, so it can report a turn settledendedorabortedwithout ever having had a turn to settle. That is the right fixture for testing jr2, and it is blind by construction to what the real runtime keeps, drops, or sends onward. Those claims are covered by the conformance suite inpackages/harness/test/, driven through the real turn loop — pi at the exact pin, the Menu over a real socket to the Custodian's address, a scripted provider — so a turn's shape (where it stalls, what it half-emits) is chosen by the test rather than by a model. The witness is the message array the provider receives on the next turn, which is the only place these claims are visible. Not a separate tier: it runs as part ofpnpm -r test(no docker, no cluster), and it is the canary for pi bumps — run it before bumping the pin. (A predecessor tier that ran the retired foreign harness runtime at its pin dissolved into this suite — ADR-0027.) - The Custodian suite, opt-in, against the real engine (ADR-0059). The Custodian's claims — the swap on the wire, the strip, the refusals, the dial guard, TLS verification upstream, SSE timing, abort, and a real SDK through
HTTPS_PROXY— are Envoy's behaviour under jr2's configuration, so only the pinned Envoy image can prove them.just custodian-testrunsnode --test(packages/cli/test/custodian.docker.ts) against it under docker, on the host network with free ports, with local HTTPS upstreams under their own CA; about 23 seconds. Opt-in for the@kindreason — it needs docker — and NOT part ofpnpm -r test. What the default gate keeps is the rendering:custodian.test.tspins the bootstrap and script a pod's maker writes, socket-free. The@kindtier then proves the Custodian in a real pod, in both placements, against a model provider that refuses any request without the key. - An opt-in
@modeltier, against a real model (ADR-0066). Every tier above fakes the model, so none can see a change in what a model DOES with what jr2 shows it — and ADR-0029/0062 measured that such changes are large. This tier is@kindwith the last fake removed: the same cluster and bring-up,jr2 upper scenario into a fresh namespace, and behind the Custodian a real endpoint named by a profile (features/model-endpoints/<name>.json, selected byJR2_MODEL_ENDPOINT). Its scenarios are CLAIMS — an ADR's measured claim as N trials with a floor, scored intofeatures/.tmp/model-scorecard.jsonagainst a committed baseline — and THE RUN, one workflow from a failing test to a commit verified in the pod. It runs the endpoint profile'sworkersscenarios at once, andworkers× the profile'sparallelruns must stay within what the endpoint serves and what the host holds. Not in CI and not inpnpm -r test:just e2e-modelprobes the endpoint and saysskipped:when nothing answers, and whoever changes what the model reads runs it before calling the change done. A third pi canary. - Shared cluster state is the one thing a scenario cannot own, and the pool is PROCESSES.
--parallelruns cucumber workers as separate processes, so a fixture memoized "once per process" runs once per WORKER — four times, concurrently, against one cluster. The tier has exactly one such fixture: the git seed (steps/seed.ts) it serves in-cluster, which is cluster-scoped where everything else is namespaced per scenario.kubectl applycreates with GET-then-POST, so the workers that lose that race are toldAlreadyExists— which anensuretreats as success, because the object being there is the whole ask. The rollout wait that follows is the real rendezvous, and it is idempotent for every worker. (The same property is why the@distprofile is serial: its publish would otherwise stand up one verdaccio per worker on a single port.) - A workspace package, not a bare folder.
./features/is@jr2/e2eso it owns its ownxstate(+ cucumber) dependency: a scaffolded instance'sworkflows/*.tsimport "xstate", resolved by walking up from the temp dir, and the repo root has noxstate. The package'snode_modulessatisfies it. It is top-level (not underpackages/cli/) because an e2e test exercises most packages at once — the CLI is the common entry point, not the only conceivable one. - Zero-build, like the rest of the repo. Cucumber.js v13 runs
.tsESM step definitions directly under Node 24's native type-stripping — nots-node, no transpile step. (Verified before adopting it.)
Considered options
- Express BDD as a convention in
node --test(a Given/When/Then helper, no.featurefiles): keeps zero deps, but loses the plain-English acceptance specs that are the point of writing them at the user boundary. Rejected — we want the.featurefiles as readable, reviewable criteria. - In-process e2e (drive
main(argv, io)instead of a subprocess): faster, but it is integration, not e2e — it can't catch bin/shebang, exit-code, signal, or attach bugs. That's already the job of the in-process tier; the e2e tier exists precisely to cover the subprocess boundary. - Requiring a cluster for all e2e (every scenario through
jr2 up): honest but slow, and it gates the everyday suite on docker. Rejected — the control-plane surface is fully exercisable against the bare server process; only the data plane needs kind, and it has its own tier.
Consequences
@cucumber/cucumberis the first test-framework dependency in a repo that otherwise uses onlynode --test— a deliberate deviation, scoped to@jr2/e2e(it does not leak into the kit packages).playwrightjoins it there, same scoping, browser binaries installed only where@consoleruns (CI, ornpx playwright install chromiumlocally).- The suite is run with
pnpm --filter @jr2/e2e test:e2e;@jr2/e2eexposes notestscript, sopnpm -r teststays fast and unit-only. - Human-in-the-loop delivery is covered at the gates API (
features/agent-and-gates.feature— the agent played over/agents/<iid>/*, the human over/runs/:id/gates/:gate/events); thejr2 send --gateCLI verb is covered at the in-process tier. A CLI-driven e2e gate scenario remains a candidate follow-upRule.