Skip to content

Commit 5035fcb

Browse files
V3RONclaude
andauthored
test(e2e): prove the gateway happy path over the CLI and HTTP (ADR 0005) (#132)
Stacked on #131. Four smoke tests for the seam nothing covered: a real gateway process, real worker processes on a real uplink, and a client reaching the fleet the way a user does. ## Why `src/daemon/gateway-fleet.e2e.test.ts` already proves lease routing and a real `device.exec` across two worker processes — but through the daemon's own `dispatch()`, in-process. Everything between that dispatcher and a user was unproven for gateway mode: the CLI's gateway/worker branch, the HTTP lease and exec routes against a gateway, SSE streaming of proxied output, and exit-code propagation. The one out-of-process lease that existed (`e2e/gateway-fleet.test.ts`) deliberately went straight to a worker's own socket, with a comment saying fleet routing belonged to a later PR than the one that wrote it. That PR has since landed and nothing came back to close the gap. ## The tests 1. **CLI through a gateway** — `simlock lease` gives a grant carrying its `worker` block and a worker-named id; the worker's own `status` shows §27a's namespaced requester (`gw:<instance>:<agent>`); `simlock adb` takes the gateway branch (`device.exec` rather than a local spawn), streams both streams, and exits with the tool's own code; `release`. 2. **HTTP through a gateway** — lease request to granted, exec streaming SSE with a real exit code, a silent long-running command answered while it still runs, and a pre-process refusal keeping its own `422 PASSTHROUGH_REFUSED` instead of a committed `200`. 3. **Fleet-wide FIFO** — `REQUESTER_ALREADY_LEASED` (13), `--no-wait` → `NO_CAPACITY` (11), a waiter reporting `queued` at position 1 and served on release. 4. **Renew/release** — an over-cap `--ttl` refused gateway-side (§15), and the gateway-issued id round-tripping through `status` and `lease renew`. These are smoke tests: they prove the layers are wired to each other, not that the routing policy, queue ordering or refusal lists are correct. `src/gateway`'s own suites own that. ## Two new fake-driver flags, for one assertion `--fake-exec-silent` and `--fake-exec-sleep=<ms>` exist together to pin ADR §19a's `started` push end to end. What `started` buys a transport is **timing**, not status: `http/app.ts` races the command settling against `started`, so a command that merely succeeds reaches the same committed `200` either way — just not until it exits. Only a command that is both silent and slow separates them. Every existing fixture writes its argv and would let a first-chunk fallback pass identically. That assertion took three attempts, and the first two are why the third is worth having. Billing the `422` refusal as "the only end-to-end test of `started`" was false — it passes with the gateway's relay ripped out, as does a short silent command. Neutering `FleetLeaseCoordinator#exec`'s `onStarted` now fails exactly one assertion: `expected 4049 to be less than 2000`, the response head arriving only after the command exited. ## One harness subtlety worth knowing The CLI test passes `input: ""`, modelling `simlock adb ... < /dev/null`. That is load-bearing rather than tidiness: against a worker the CLI spawns the tool with inherited stdio and never reads stdin, but against a gateway it must read stdin to EOF before it can send the one `stdin` string `device.exec` carries. The e2e harness spawns with a pipe it never closes, so without this the CLI waits for an EOF that never comes — exactly what a real caller handing simlock an open pipe would see. ## Verification - typecheck, e2e typecheck, lint, format — clean - 1810 unit tests pass - 4/4 smoke tests pass against the pushed code (136s) - `device-exec` and `lease-environment-passthrough` re-run and unaffected by the fake-driver change 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01MA98m7ua7qvDFZjxFaww6Z --- _Generated by [Claude Code](https://claude.ai/code/session_01MA98m7ua7qvDFZjxFaww6Z)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b9e0215 commit 5035fcb

3 files changed

Lines changed: 483 additions & 6 deletions

File tree

e2e/fake-driver/fake-driver.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -118,12 +118,21 @@ const PASSTHROUGH_REFUSALS: Readonly<
118118
* its own environment rather than from the script file, so a test controls the exit code
119119
* through the CLI invocation it is already making and this stays synchronous.
120120
*
121-
* It also answers two flags of its own -- `--fake-exec-stderr=<text>` and
122-
* `--fake-exec-exit=<n>` -- because `device.exec` (ADR 0005 §19a) runs this program in the
121+
* It also answers three flags of its own -- `--fake-exec-stderr=<text>`, `--fake-exec-exit=<n>`
122+
* and `--fake-exec-silent` -- because `device.exec` (ADR 0005 §19a) runs this program in the
123123
* *daemon's* process, where a per-invocation environment variable cannot reach it: an HTTP
124-
* caller has only the argument list. They are the minimum needed to observe the two things a
124+
* caller has only the argument list. They are the minimum needed to observe the three things a
125125
* streamed exec must get right and a local passthrough never showed: output on the second
126-
* stream, and an exit code that is not zero.
126+
* stream, an exit code that is not zero, and a process that produces **no output at all**.
127+
*
128+
* `--fake-exec-silent` and `--fake-exec-sleep=<ms>` exist for that last one, together. What
129+
* §19a's `started` push buys a transport is *timing*, not status: `http/app.ts` races the
130+
* command settling against `started`, and a command that merely succeeds reaches the same
131+
* committed `200` either way -- just not until it exits. Only a command that is both silent and
132+
* slow separates them, because only then is there a stretch where the route must already have
133+
* answered and has nothing else to answer on. That is what makes "a silent long-running command
134+
* gets its `200` and its keepalives immediately" (the route's own words) an assertion rather
135+
* than a claim.
127136
*/
128137
const PASSTHROUGH_PROGRAM =
129138
"const argv = process.argv.slice(1);" +
@@ -137,6 +146,11 @@ const PASSTHROUGH_PROGRAM =
137146
// is only observable from the far end if the tool reads it back out. Opt-in, so every other
138147
// flow's command still exits without waiting on a stdin nobody wrote to.
139148
"const echoStdin = argv.includes('--fake-exec-echo-stdin');" +
149+
"const silent = argv.includes('--fake-exec-silent');" +
150+
// Keeps the event loop alive without writing anything, so the process genuinely exists and is
151+
// running while the route has to decide what to answer.
152+
"const sleepMs = Number(flag('--fake-exec-sleep') ?? 0);" +
153+
"if (sleepMs > 0) setTimeout(() => {}, sleepMs);" +
140154
"const report = (stdin) => process.stdout.write(JSON.stringify({" +
141155
"argv," +
142156
"platform: process.env.SIMLOCK_FAKE_PASSTHROUGH_PLATFORM ?? null," +
@@ -151,7 +165,7 @@ const PASSTHROUGH_PROGRAM =
151165
// the tool's own process, not merely that the daemon returned it in the resolved command --
152166
// the half of ADR 0001 decision 7 the wrapper exists for: handing back the scoping that
153167
// containment removed.
154-
"} else report(undefined);" +
168+
"} else if (!silent) report(undefined);" +
155169
// `exitCode` rather than `exit()`: over a pipe (which is how `device.exec` reads it, unlike
156170
// the CLI's inherited stdio) an immediate `exit()` can truncate a write that has not
157171
// flushed. Setting the code lets the process end once its streams have drained.

0 commit comments

Comments
 (0)