@specific.dev/spectest 0.26.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/dist/aws-sigv4.d.ts +42 -0
  2. package/dist/aws-sigv4.js +166 -0
  3. package/dist/browser.d.ts +314 -0
  4. package/dist/browser.js +1320 -0
  5. package/dist/components/email.d.ts +135 -0
  6. package/dist/components/email.js +271 -0
  7. package/dist/components/expo.d.ts +69 -0
  8. package/dist/components/expo.js +125 -0
  9. package/dist/components/index.d.ts +8 -0
  10. package/dist/components/index.js +18 -0
  11. package/dist/components/k3s.d.ts +172 -0
  12. package/dist/components/k3s.js +1124 -0
  13. package/dist/components/postgres.d.ts +93 -0
  14. package/dist/components/postgres.js +58 -0
  15. package/dist/components/replayFake.d.ts +169 -0
  16. package/dist/components/replayFake.js +738 -0
  17. package/dist/components/s3.d.ts +99 -0
  18. package/dist/components/s3.js +81 -0
  19. package/dist/components/supabase.d.ts +197 -0
  20. package/dist/components/supabase.js +1003 -0
  21. package/dist/daemon.d.ts +1 -0
  22. package/dist/daemon.js +4611 -0
  23. package/dist/ids.d.ts +2 -0
  24. package/{src/ids.ts → dist/ids.js} +46 -50
  25. package/dist/index.d.ts +1328 -0
  26. package/dist/index.js +769 -0
  27. package/dist/ingress.d.ts +114 -0
  28. package/dist/ingress.js +210 -0
  29. package/dist/inspect.d.ts +228 -0
  30. package/dist/inspect.js +429 -0
  31. package/dist/locator.d.ts +260 -0
  32. package/dist/locator.js +293 -0
  33. package/dist/mobile.d.ts +71 -0
  34. package/dist/mobile.js +65 -0
  35. package/dist/record-secrets.d.ts +9 -0
  36. package/{src/record-secrets.ts → dist/record-secrets.js} +13 -15
  37. package/dist/recorder.d.ts +527 -0
  38. package/dist/recorder.js +219 -0
  39. package/dist/redis.d.ts +54 -0
  40. package/dist/redis.js +126 -0
  41. package/dist/replay-bundle.d.ts +38 -0
  42. package/{src/replay-bundle.ts → dist/replay-bundle.js} +29 -47
  43. package/dist/resolver.d.ts +1 -0
  44. package/dist/resolver.js +309 -0
  45. package/dist/s3.d.ts +89 -0
  46. package/dist/s3.js +198 -0
  47. package/dist/sql.d.ts +74 -0
  48. package/dist/sql.js +151 -0
  49. package/dist/terminal.d.ts +161 -0
  50. package/dist/terminal.js +538 -0
  51. package/package.json +24 -9
  52. package/src/browser.ts +0 -1819
  53. package/src/components/email.ts +0 -398
  54. package/src/components/expo.ts +0 -167
  55. package/src/components/index.ts +0 -63
  56. package/src/components/k3s.ts +0 -1312
  57. package/src/components/postgres.ts +0 -105
  58. package/src/components/replayFake.ts +0 -848
  59. package/src/components/s3.ts +0 -132
  60. package/src/components/supabase.ts +0 -1299
  61. package/src/daemon.ts +0 -4969
  62. package/src/index.ts +0 -2350
  63. package/src/ingress.ts +0 -288
  64. package/src/inspect.ts +0 -673
  65. package/src/locator.ts +0 -594
  66. package/src/mobile.ts +0 -133
  67. package/src/recorder.ts +0 -817
  68. package/src/redis.ts +0 -202
  69. package/src/resolver.ts +0 -351
  70. package/src/s3.ts +0 -333
  71. package/src/sql.ts +0 -243
  72. package/src/terminal.ts +0 -740
  73. package/src/vendor/rrweb-plugin-console-record.umd.js +0 -521
  74. package/src/vendor/rrweb-record.min.js +0 -5061
package/dist/daemon.js ADDED
@@ -0,0 +1,4611 @@
1
+ // Long-running HTTP daemon. Runs as a systemd unit on the VM host (not in
2
+ // a container). Owns:
3
+ //
4
+ // * Loading the user's `spectest/index.ts` and exposing the parsed
5
+ // `{ environment, tests? }` to the control plane.
6
+ // * Orchestrating Docker: image prep (pull/build), network + volumes,
7
+ // container start, ready probes — all by shelling out to the local
8
+ // `docker` CLI.
9
+ // * Running individual test cases on demand.
10
+ //
11
+ // The control plane handles VM lifecycle (create, snapshot, fork,
12
+ // terminate) and the tarball uploads to `/workspace` and `/opt/spectest/app`.
13
+ // Once those are in place the daemon does the rest.
14
+ //
15
+ // One sandbox = one daemon. Concurrency between tests is achieved by
16
+ // forking the sandbox; inside a single daemon we never run two tests at
17
+ // once — that keeps stdout capture and timeouts simple.
18
+ import http from "node:http";
19
+ import { execFile, spawn } from "node:child_process";
20
+ import { randomUUID } from "node:crypto";
21
+ import { existsSync, promises as fs, readFileSync } from "node:fs";
22
+ import net from "node:net";
23
+ import path from "node:path";
24
+ import { pathToFileURL } from "node:url";
25
+ import { assert, expect, expectRaw, lowerIngress, dnsName as makeDnsDecl, isWildcard, proxy as makeProxyDecl, } from "./index.js";
26
+ import { acquirePersistentBrowser } from "./browser.js";
27
+ import { isMobileApp, openPersistentMobile } from "./mobile.js";
28
+ import { openTerminal } from "./terminal.js";
29
+ import { recordEnv, recordExec, recordFake, recordHttp, recordTerminal, recordWait, reserveEvent, recorderEventCount, recorderMarkChildren, recorderTruncate, startRecording, stopRecording, truncateUtf8, } from "./recorder.js";
30
+ import { wrap, wrapResponse } from "./inspect.js";
31
+ import { clearRecordSecrets, setRecordSecrets } from "./record-secrets.js";
32
+ import { encodeReplayBundle, replayChunk } from "./replay-bundle.js";
33
+ function namedServices(cfg) {
34
+ return Object.entries(cfg.services).map(([name, def]) => ({ name, ...def }));
35
+ }
36
+ const DEFAULT_PORT = 9876;
37
+ const DEFAULT_TEST_TIMEOUT_MS = 60_000;
38
+ const NETWORK_NAME = process.env.SPECTEST_NETWORK ?? "spectest-net";
39
+ const WORKSPACE = process.env.SPECTEST_WORKSPACE ?? "/workspace";
40
+ // Stable hostname every service container resolves to the host (the
41
+ // `spectest-br0` gateway) — so apps that build or pull images at runtime
42
+ // can point a builder at `spectest-host:5000` (the zot Docker Hub mirror)
43
+ // or `spectest-host:1234` (the shared buildkitd) without hard-coding the
44
+ // gateway IP. Injected into each container's /etc/hosts in runContainer.
45
+ const SPECTEST_HOST_NAME = "spectest-host";
46
+ // The host image-cache gateway, discovered once from the same
47
+ // `registry-mirrors` entry the in-VM dockerd already uses (baked into the
48
+ // local provider's golden /etc/docker/daemon.json). `null` when there's
49
+ // no host cache, so nothing is injected.
50
+ let _hostCacheGateway;
51
+ function hostCacheGateway() {
52
+ if (_hostCacheGateway !== undefined)
53
+ return _hostCacheGateway;
54
+ try {
55
+ const cfg = JSON.parse(readFileSync("/etc/docker/daemon.json", "utf8"));
56
+ const first = cfg["registry-mirrors"]?.[0];
57
+ _hostCacheGateway = first ? new URL(first).hostname || null : null;
58
+ }
59
+ catch {
60
+ _hostCacheGateway = null;
61
+ }
62
+ return _hostCacheGateway;
63
+ }
64
+ const APP_DIR = process.env.SPECTEST_APP_DIR ?? "/opt/spectest/app";
65
+ // Root CA baked into the base snapshot at base-snapshot build time
66
+ // (see base.rs::BASE_SETUP_SH). Bind-mounted into every service
67
+ // container so apps can verify HTTPS to the daemon's fakes, and
68
+ // referenced when we layer it into each image's system trust store.
69
+ const CA_PATH = process.env.SPECTEST_CA_PATH ?? "/etc/spectest/ca.crt";
70
+ const CA_KEY_PATH = process.env.SPECTEST_CA_KEY_PATH ?? "/etc/spectest/ca.key";
71
+ let loaded = null;
72
+ function casesMetadata(suite) {
73
+ if (!suite)
74
+ return [];
75
+ return suite.tests.map((t) => ({
76
+ id: t.id,
77
+ name: t.name,
78
+ dependsOn: t.dependsOn?.id,
79
+ timeoutMs: t.timeoutMs,
80
+ }));
81
+ }
82
+ // Display-only summary of the project's fakes for the control plane (folded
83
+ // into the env config's `fakes`, surfaced on the run page). Fakes aren't part
84
+ // of `project.environment`, so they ride alongside it as a separate field of
85
+ // the /load response — just the routing surface, no handler/state/helpers.
86
+ function fakesSummary(project) {
87
+ if (!project?.fakes)
88
+ return [];
89
+ return Object.entries(project.fakes).map(([name, def]) => ({
90
+ name,
91
+ hostnames: def.hostnames.map((h) => h.toLowerCase()),
92
+ port: def.port ?? DEFAULT_FAKE_PORT,
93
+ }));
94
+ }
95
+ function resolveEntry() {
96
+ const explicit = process.env.SPECTEST_PROJECT_ENTRY;
97
+ if (explicit && existsSync(explicit))
98
+ return explicit;
99
+ const dir = process.env.SPECTEST_PROJECT_DIR ?? path.join(APP_DIR, "spectest");
100
+ for (const name of ["index.ts", "index.mts", "index.mjs", "index.js"]) {
101
+ const p = path.join(dir, name);
102
+ if (existsSync(p))
103
+ return p;
104
+ }
105
+ throw new Error(`could not find project entry in ${dir} (looked for index.ts/.mts/.mjs/.js)`);
106
+ }
107
+ // Import the env entry (`spectest/index.ts`) and load everything that
108
+ // defines the *environment* — services, fakes, project setup — but not the
109
+ // test bodies (those live in `spectest/tests/**`; see `loadTests`). The
110
+ // import URL is deliberately stable (no cache-busting query): test files
111
+ // import this same module (`import { env } from "../index"`) and must resolve
112
+ // to the SAME `env` instance, so their `env.test(...)` calls land in the
113
+ // registry the default-exported Project reads back. Each daemon process
114
+ // imports the entry at most once — an env change always takes the cold path
115
+ // with a fresh daemon — so there's nothing to bust.
116
+ async function loadEnv() {
117
+ const entry = resolveEntry();
118
+ const url = pathToFileURL(entry).href;
119
+ const mod = await import(url);
120
+ const candidate = (mod && typeof mod === "object" && "default" in mod ? mod.default : mod);
121
+ if (!candidate || !candidate.environment) {
122
+ throw new Error(`project entry ${entry} must default-export a Project (from env.project(...) where env = defineEnvironment(...))`);
123
+ }
124
+ loaded = { project: candidate, byId: new Map() };
125
+ rebuildCatalogue();
126
+ // Any cached convenience clients belong to the previous project; drop
127
+ // them so the next test rebuilds against the freshly loaded definitions.
128
+ HELPERS_CACHE.clear();
129
+ // Register fakes + service-tls proxies here; startIngress() during
130
+ // /bootstrap actually binds the listeners — this just parses + validates
131
+ // and tears down any prior runtime so a reload picks up edits.
132
+ buildIngress(candidate);
133
+ return candidate;
134
+ }
135
+ // Import the test files under `spectest/tests/**` into the already-loaded
136
+ // env, then refresh the catalogue. The split layout keeps test bodies out of
137
+ // the warm-template cache key, so a test-only edit restores the cached env
138
+ // and lands here to pick up the new tests. A no-op for the legacy single-file
139
+ // layout (no `tests/` dir; the suite is already on the default export).
140
+ //
141
+ // Crucially this is only ever called AFTER the warm-template snapshot is
142
+ // captured (cold path) or against a freshly restored VM (warm path), so each
143
+ // test file is imported for the first time in that daemon process — no
144
+ // cache-busting needed and no stale ESM module to fight.
145
+ async function loadTests() {
146
+ requireLoaded();
147
+ const dir = path.join(path.dirname(resolveEntry()), "tests");
148
+ if (existsSync(dir)) {
149
+ for (const file of await collectTestFiles(dir)) {
150
+ await import(pathToFileURL(file).href);
151
+ }
152
+ }
153
+ rebuildCatalogue();
154
+ // The test set may have changed; drop cached helpers so the next test
155
+ // rebuilds cleanly.
156
+ HELPERS_CACHE.clear();
157
+ }
158
+ // (Re)build the id→TestCase index from whatever the loaded project currently
159
+ // exposes as its suite: the lazy registry getter for split layouts, or the
160
+ // frozen explicit suite for inline ones.
161
+ function rebuildCatalogue() {
162
+ const l = requireLoaded();
163
+ const byId = new Map();
164
+ if (l.project.tests) {
165
+ for (const t of l.project.tests.tests)
166
+ byId.set(t.id, t);
167
+ }
168
+ l.byId = byId;
169
+ }
170
+ // Recursively collect importable test modules under `spectest/tests/`, sorted
171
+ // for deterministic import order. Skips declaration files and dependency dirs.
172
+ async function collectTestFiles(dir) {
173
+ const out = [];
174
+ const walk = async (d) => {
175
+ const entries = await fs.readdir(d, { withFileTypes: true });
176
+ for (const e of entries) {
177
+ const full = path.join(d, e.name);
178
+ if (e.isDirectory()) {
179
+ if (e.name === "node_modules" || e.name === ".spectest")
180
+ continue;
181
+ await walk(full);
182
+ }
183
+ else if (e.isFile() &&
184
+ /\.(ts|mts|cts|js|mjs|cjs)$/.test(e.name) &&
185
+ !e.name.endsWith(".d.ts")) {
186
+ out.push(full);
187
+ }
188
+ }
189
+ };
190
+ await walk(dir);
191
+ out.sort();
192
+ return out;
193
+ }
194
+ function requireLoaded() {
195
+ if (!loaded) {
196
+ throw new Error("no project loaded; call POST /load first");
197
+ }
198
+ return loaded;
199
+ }
200
+ function shx(file, args, timeoutMs, env) {
201
+ return new Promise((resolve) => {
202
+ let done = false;
203
+ const child = execFile(file, args, { maxBuffer: 64 * 1024 * 1024, env: env ? { ...process.env, ...env } : process.env }, (err, stdout, stderr) => {
204
+ if (done)
205
+ return;
206
+ done = true;
207
+ const code = err && typeof err.code === "number"
208
+ ? Number(err.code)
209
+ : err
210
+ ? 1
211
+ : 0;
212
+ resolve({ stdout: String(stdout), stderr: String(stderr), code });
213
+ });
214
+ if (timeoutMs && timeoutMs > 0) {
215
+ setTimeout(() => {
216
+ if (!done) {
217
+ done = true;
218
+ try {
219
+ child.kill("SIGKILL");
220
+ }
221
+ catch {
222
+ /* already exited */
223
+ }
224
+ resolve({ stdout: "", stderr: `timeout after ${timeoutMs}ms`, code: 124 });
225
+ }
226
+ }, timeoutMs);
227
+ }
228
+ });
229
+ }
230
+ function docker(args, timeoutMs, env) {
231
+ return shx("docker", args, timeoutMs, env);
232
+ }
233
+ /**
234
+ * Like `shx` but invokes `onLine` for each line of combined stdout/stderr
235
+ * as it streams in, so callers can surface live progress (docker build
236
+ * steps, image pull layers) into bootstrap progress. Still resolves with
237
+ * the full captured output + exit code, so existing error handling and
238
+ * post-hoc parsing (`summarizeBuildKit`) are unchanged.
239
+ */
240
+ function shxStream(file, args, timeoutMs, env, onLine) {
241
+ return new Promise((resolve) => {
242
+ const child = spawn(file, args, {
243
+ env: env ? { ...process.env, ...env } : process.env,
244
+ });
245
+ let stdout = "";
246
+ let stderr = "";
247
+ let buf = "";
248
+ let done = false;
249
+ const feed = (chunk) => {
250
+ buf += chunk;
251
+ let nl;
252
+ while ((nl = buf.indexOf("\n")) >= 0) {
253
+ const line = buf.slice(0, nl).replace(/\r$/, "");
254
+ buf = buf.slice(nl + 1);
255
+ try {
256
+ onLine(line);
257
+ }
258
+ catch {
259
+ /* a progress callback must never break the build */
260
+ }
261
+ }
262
+ };
263
+ child.stdout?.on("data", (d) => {
264
+ const s = String(d);
265
+ stdout += s;
266
+ feed(s);
267
+ });
268
+ child.stderr?.on("data", (d) => {
269
+ const s = String(d);
270
+ stderr += s;
271
+ feed(s);
272
+ });
273
+ const finish = (code, extraStderr) => {
274
+ if (done)
275
+ return;
276
+ done = true;
277
+ resolve({ stdout, stderr: extraStderr ? stderr + extraStderr : stderr, code });
278
+ };
279
+ child.on("error", () => finish(1));
280
+ child.on("close", (code) => finish(code == null ? 1 : code));
281
+ if (timeoutMs && timeoutMs > 0) {
282
+ setTimeout(() => {
283
+ if (done)
284
+ return;
285
+ try {
286
+ child.kill("SIGKILL");
287
+ }
288
+ catch {
289
+ /* already exited */
290
+ }
291
+ finish(124, `\ntimeout after ${timeoutMs}ms`);
292
+ }, timeoutMs);
293
+ }
294
+ });
295
+ }
296
+ // ── Boot log ─────────────────────────────────────────────────────────────
297
+ // Everything the daemon logs while bringing the environment up, including
298
+ // `console.log` from service `setup` hooks and project `setup`. Those used
299
+ // to reach only the VM's journal, so on a *successful* boot they were
300
+ // invisible — the one case where a long bootstrap most needs explaining.
301
+ // Kept in daemon memory (so it rides snapshots like everything else) and
302
+ // served by GET /boot-log; capped so a chatty setup can't grow unbounded.
303
+ const BOOT_LOG_MAX_BYTES = 512 * 1024;
304
+ const BOOT_LOG = {
305
+ lines: [],
306
+ bytes: 0,
307
+ dropped: 0,
308
+ };
309
+ /** A {@link LineSink} that appends to the capped boot log. */
310
+ const BOOT_LOG_SINK = {
311
+ push(line) {
312
+ BOOT_LOG.lines.push(line);
313
+ BOOT_LOG.bytes += line.length;
314
+ // Drop from the front: the tail is what explains where a boot is now.
315
+ while (BOOT_LOG.bytes > BOOT_LOG_MAX_BYTES && BOOT_LOG.lines.length > 1) {
316
+ const gone = BOOT_LOG.lines.shift();
317
+ BOOT_LOG.bytes -= gone.length;
318
+ BOOT_LOG.dropped += 1;
319
+ }
320
+ },
321
+ };
322
+ function bootLogText() {
323
+ const head = BOOT_LOG.dropped > 0
324
+ ? `[spectest: ${BOOT_LOG.dropped} earlier line(s) dropped — boot log capped at ${BOOT_LOG_MAX_BYTES} bytes]\n`
325
+ : "";
326
+ return head + BOOT_LOG.lines.join("");
327
+ }
328
+ let BOOTSTRAP_PROGRESS = null;
329
+ function progressInit(services) {
330
+ BOOTSTRAP_PROGRESS = {
331
+ phase: "Preparing images",
332
+ services: services.map((s) => ({
333
+ name: s.name,
334
+ kind: s.image.type === "registry" ? "pull" : "build",
335
+ status: "pending",
336
+ })),
337
+ startedAt: Date.now(),
338
+ updatedAt: Date.now(),
339
+ done: false,
340
+ };
341
+ }
342
+ function progressPhase(phase) {
343
+ if (!BOOTSTRAP_PROGRESS)
344
+ return;
345
+ BOOTSTRAP_PROGRESS.phase = phase;
346
+ BOOTSTRAP_PROGRESS.updatedAt = Date.now();
347
+ }
348
+ function progressService(name, patch) {
349
+ if (!BOOTSTRAP_PROGRESS)
350
+ return;
351
+ const svc = BOOTSTRAP_PROGRESS.services.find((s) => s.name === name);
352
+ if (!svc)
353
+ return;
354
+ Object.assign(svc, patch);
355
+ BOOTSTRAP_PROGRESS.updatedAt = Date.now();
356
+ }
357
+ function progressDone() {
358
+ if (!BOOTSTRAP_PROGRESS)
359
+ return;
360
+ BOOTSTRAP_PROGRESS.phase = "Ready";
361
+ BOOTSTRAP_PROGRESS.done = true;
362
+ BOOTSTRAP_PROGRESS.updatedAt = Date.now();
363
+ }
364
+ // BuildKit (docker buildx) gives per-step timing via `--progress=plain`,
365
+ // parallel stages, and `RUN --mount=type=cache`. Detected once: where the
366
+ // buildx plugin isn't installed (e.g. a Freestyle base without it) we fall
367
+ // back to the legacy builder, which takes no `--progress` flag.
368
+ let _buildxAvailable;
369
+ async function hasBuildx() {
370
+ if (_buildxAvailable === undefined) {
371
+ const r = await docker(["buildx", "version"], 15_000);
372
+ _buildxAvailable = r.code === 0;
373
+ }
374
+ return _buildxAvailable;
375
+ }
376
+ // A single buildkitd runs on the host (see scripts/install-buildkitd.sh),
377
+ // reachable from every VM at the bridge gateway. Building against it as a
378
+ // `remote` buildx builder gives a persistent, shared layer/mount cache that
379
+ // survives forks and warm-template misses — a fresh VM no longer rebuilds
380
+ // from scratch. The build runs on the host (runc-isolated); `--load` pulls
381
+ // the finished image back into the in-VM dockerd. Detected once; if the
382
+ // builder can't be created or buildkitd is unreachable we fall back to the
383
+ // in-VM builder, so a missing/dead buildkitd just means slower builds.
384
+ const REMOTE_BUILDER_ADDR = process.env.SPECTEST_BUILDKIT_ADDR ?? "tcp://10.42.0.1:1234";
385
+ const REMOTE_BUILDER_NAME = "spectest-remote";
386
+ let _remoteBuilder;
387
+ async function ensureRemoteBuilder() {
388
+ if (_remoteBuilder !== undefined)
389
+ return _remoteBuilder;
390
+ if (!(await hasBuildx())) {
391
+ _remoteBuilder = false;
392
+ return false;
393
+ }
394
+ // Idempotent: a repeat create with the same name errors ("existing
395
+ // instance"), which we treat as already-present.
396
+ const create = await docker(["buildx", "create", "--name", REMOTE_BUILDER_NAME, "--driver", "remote", REMOTE_BUILDER_ADDR], 30_000);
397
+ if (create.code !== 0 && !/existing instance|already exists/i.test(create.stderr)) {
398
+ _remoteBuilder = false;
399
+ return false;
400
+ }
401
+ // `inspect --bootstrap` actually dials buildkitd, so it's our reachability
402
+ // probe. If buildkitd is down this fails and we fall back.
403
+ const boot = await docker(["buildx", "inspect", "--bootstrap", REMOTE_BUILDER_NAME], 60_000);
404
+ _remoteBuilder = boot.code === 0;
405
+ if (!_remoteBuilder) {
406
+ // eslint-disable-next-line no-console
407
+ console.warn(`[build] remote buildkitd at ${REMOTE_BUILDER_ADDR} unreachable; using in-VM builder:\n${boot.stderr.trim()}`);
408
+ }
409
+ return _remoteBuilder;
410
+ }
411
+ // Parse `docker build --progress=plain` (BuildKit) output into per-step
412
+ // timings, sorted slowest-first. Steps are correlated by their `#N` id:
413
+ // the declaration line carries the command, the `DONE`/`CACHED` line the
414
+ // duration. Best-effort — unparseable output yields an empty list.
415
+ function summarizeBuildKit(out) {
416
+ const names = new Map();
417
+ const secs = new Map();
418
+ const cached = new Set();
419
+ for (const line of out.split("\n")) {
420
+ let m = line.match(/^#(\d+)\s+\[[^\]]*\]\s+(.+)$/);
421
+ if (m) {
422
+ const id = `#${m[1]}`;
423
+ if (!names.has(id))
424
+ names.set(id, m[2].trim().slice(0, 80));
425
+ continue;
426
+ }
427
+ m = line.match(/^#(\d+)\s+DONE\s+([\d.]+)s/);
428
+ if (m) {
429
+ secs.set(`#${m[1]}`, parseFloat(m[2]));
430
+ continue;
431
+ }
432
+ m = line.match(/^#(\d+)\s+CACHED/);
433
+ if (m) {
434
+ const id = `#${m[1]}`;
435
+ cached.add(id);
436
+ if (!secs.has(id))
437
+ secs.set(id, 0);
438
+ }
439
+ }
440
+ const steps = [];
441
+ for (const [id, name] of names) {
442
+ steps.push({ name, secs: secs.get(id) ?? 0, cached: cached.has(id) });
443
+ }
444
+ return steps.sort((a, b) => b.secs - a.secs);
445
+ }
446
+ // ────────────────────────────────────────────────────────────────────────
447
+ // Bootstrap stages
448
+ // ────────────────────────────────────────────────────────────────────────
449
+ async function ensureNetwork() {
450
+ const inspect = await docker(["network", "inspect", NETWORK_NAME], 30_000);
451
+ if (inspect.code === 0)
452
+ return;
453
+ const create = await docker(["network", "create", NETWORK_NAME], 60_000);
454
+ if (create.code !== 0) {
455
+ throw new Error(`docker network create ${NETWORK_NAME} failed: ${create.stderr.trim() || create.stdout.trim()}`);
456
+ }
457
+ }
458
+ function sanitizeSegment(p) {
459
+ return p
460
+ .replace(/^\/+/, "")
461
+ .replace(/[^A-Za-z0-9_-]/g, "-")
462
+ .replace(/^-+|-+$/g, "");
463
+ }
464
+ function resolveHostPath(service, vol) {
465
+ if (vol.name) {
466
+ // Named shared volume: one backing dir per name, shared by every
467
+ // service that mounts the same name (storage-api ↔ imgproxy). Rooted
468
+ // in the per-env state tree (or the cache tree when cache-flagged),
469
+ // so teardown/fork semantics match ordinary volumes.
470
+ const root = vol.cache
471
+ ? ["/var/cache/spectest/volumes", "_shared"]
472
+ : [WORKSPACE, ".spectest", "volumes", "_shared"];
473
+ return path.join(...root, sanitizeSegment(vol.name));
474
+ }
475
+ if (vol.source && vol.source.startsWith("/"))
476
+ return vol.source;
477
+ // Cache volumes root OUTSIDE /workspace so the delta-restore teardown
478
+ // (rm -rf /workspace) keeps them — they hold only content-addressed
479
+ // accelerator data (see VolumeMount.cache), never env state.
480
+ const root = vol.cache
481
+ ? ["/var/cache/spectest/volumes", service]
482
+ : [WORKSPACE, ".spectest", "volumes", service];
483
+ if (vol.source) {
484
+ return path.join(...root, vol.source.replace(/^\/+/, ""));
485
+ }
486
+ return path.join(...root, sanitizeSegment(vol.target));
487
+ }
488
+ /// Where the daemon records every ABSOLUTE-source, non-cache volume dir it
489
+ /// has created, one path per line. The delta-restore teardown wipes the
490
+ /// listed dirs: they live outside /workspace (which the teardown removes
491
+ /// wholesale) and outside /var/cache/spectest (deliberately kept), so
492
+ /// without this manifest a `source: "/data/pg"` volume would carry the
493
+ /// previous generation's data into a "fresh" environment. tmpfs-backed
494
+ /// (/run) — survives snapshots like all guest memory, dies with the VM.
495
+ const VOLUME_DIRS_MANIFEST = "/run/spectest-volume-dirs";
496
+ const ABS_VOLUME_DIRS = new Set();
497
+ async function recordAbsoluteVolumeDir(host) {
498
+ if (ABS_VOLUME_DIRS.has(host))
499
+ return;
500
+ ABS_VOLUME_DIRS.add(host);
501
+ await fs.writeFile(VOLUME_DIRS_MANIFEST, [...ABS_VOLUME_DIRS].join("\n") + "\n");
502
+ }
503
+ async function ensureVolumes(svc) {
504
+ const flags = [];
505
+ if (!svc.volumes || svc.volumes.length === 0)
506
+ return flags;
507
+ for (const vol of svc.volumes) {
508
+ // Boot services are validated in defineEnvironment; re-check here so
509
+ // runtime `startService` specs get the same contract.
510
+ if (vol.name && vol.source) {
511
+ throw new Error(`service "${svc.name}" volume for ${JSON.stringify(vol.target)} sets both \`name\` and \`source\``);
512
+ }
513
+ const host = resolveHostPath(svc.name, vol);
514
+ await fs.mkdir(host, { recursive: true });
515
+ if (vol.source?.startsWith("/") && !host.startsWith("/var/cache/spectest/")) {
516
+ await recordAbsoluteVolumeDir(host);
517
+ }
518
+ flags.push(`--volume=${host}:${vol.target}${vol.readOnly ? ":ro" : ""}`);
519
+ }
520
+ return flags;
521
+ }
522
+ // Materialize `svc.files` onto the VM host and return `--volume` flags
523
+ // bind-mounting each into the container (read-only). Single-file bind
524
+ // mounts mean the seeded config lands in place *before the container's
525
+ // entrypoint runs* — the one injection point earlier than any setup
526
+ // hook. Staging path mirrors ensureVolumes: a per-service dir derived
527
+ // from the in-container path, so two files never collide and the
528
+ // content is captured by snapshots like everything else under WORKSPACE.
529
+ async function ensureFiles(svc) {
530
+ const flags = [];
531
+ if (!svc.files || svc.files.length === 0)
532
+ return flags;
533
+ const dir = path.join(WORKSPACE, ".spectest", "files", svc.name);
534
+ await fs.mkdir(dir, { recursive: true });
535
+ for (const f of svc.files) {
536
+ if (!f.path.startsWith("/")) {
537
+ throw new Error(`service "${svc.name}": file path ${JSON.stringify(f.path)} must be absolute`);
538
+ }
539
+ // `{{SPECTEST_SERVICE}}` expands to this service's name (its
540
+ // services-map key) so a component can author self-referential
541
+ // config without knowing the key the user will choose — e.g. k3s's
542
+ // registries.yaml keying on `<key>.internal:5000`.
543
+ const content = f.content.replaceAll("{{SPECTEST_SERVICE}}", svc.name);
544
+ const host = path.join(dir, sanitizeSegment(f.path));
545
+ await fs.writeFile(host, content);
546
+ if (f.mode)
547
+ await fs.chmod(host, parseInt(f.mode, 8));
548
+ flags.push(`--volume=${host}:${f.path}:ro`);
549
+ }
550
+ return flags;
551
+ }
552
+ /**
553
+ * Mint each `svc.certificates` entry from the in-VM root CA and return
554
+ * `--volume` flags bind-mounting the PEMs into the container, using the
555
+ * same pre-entrypoint injection point as {@link ensureFiles}.
556
+ *
557
+ * This is what lets a service terminate TLS *itself* with a certificate
558
+ * the environment already trusts — the `tls` field can't help there,
559
+ * since it terminates in the daemon and proxies plain HTTP to the
560
+ * upstream. Anything that routes by SNI, verifies a client cert, or
561
+ * speaks a protocol with its own TLS handshake needs the key material.
562
+ *
563
+ * Minted fresh on every bootstrap rather than cached: certs are cheap
564
+ * (~50 ms), and a stale one outliving a CA rotation would fail in a way
565
+ * that reads as a code bug.
566
+ */
567
+ async function ensureCertificates(svc) {
568
+ const flags = [];
569
+ const certs = svc.certificates ?? [];
570
+ if (certs.length === 0)
571
+ return flags;
572
+ if (!existsSync(CA_PATH) || !existsSync(CA_KEY_PATH)) {
573
+ throw new Error(`service "${svc.name}": certificates require the in-VM root CA at ${CA_PATH}`);
574
+ }
575
+ const dir = path.join(WORKSPACE, ".spectest", "certs", svc.name);
576
+ await fs.mkdir(dir, { recursive: true });
577
+ for (const [i, c] of certs.entries()) {
578
+ for (const [label, p] of [
579
+ ["certPath", c.certPath],
580
+ ["keyPath", c.keyPath],
581
+ ...(c.caPath ? [["caPath", c.caPath]] : []),
582
+ ]) {
583
+ if (!p.startsWith("/")) {
584
+ throw new Error(`service "${svc.name}": certificate ${label} ${JSON.stringify(p)} must be absolute`);
585
+ }
586
+ }
587
+ const hostnames = c.hostnames.map((h) => h.replaceAll("{{SPECTEST_SERVICE}}", svc.name));
588
+ if (hostnames.length === 0) {
589
+ throw new Error(`service "${svc.name}": certificate entry ${i} lists no hostnames`);
590
+ }
591
+ const { cert, key } = await generateHostCert(`${svc.name}-${i}`, hostnames);
592
+ const certHost = path.join(dir, `${i}.crt`);
593
+ const keyHost = path.join(dir, `${i}.key`);
594
+ await fs.writeFile(certHost, cert);
595
+ await fs.writeFile(keyHost, key);
596
+ // The key's mode has to be set on the staged file: a bind mount
597
+ // carries the host inode's permissions straight through, and a
598
+ // server that checks (postgres, ssh) refuses a lax one.
599
+ if (c.mode)
600
+ await fs.chmod(keyHost, parseInt(c.mode, 8));
601
+ flags.push(`--volume=${certHost}:${c.certPath}:ro`);
602
+ flags.push(`--volume=${keyHost}:${c.keyPath}:ro`);
603
+ if (c.caPath)
604
+ flags.push(`--volume=${CA_PATH}:${c.caPath}:ro`);
605
+ console.log(`[bootstrap] ${svc.name}: minted certificate for ${hostnames.join(", ")}`);
606
+ }
607
+ return flags;
608
+ }
609
+ function imageTag(name) {
610
+ return `spectest/${name}:latest`;
611
+ }
612
+ const DEFAULT_DOCKERIGNORE = [
613
+ ".git",
614
+ ".spectest",
615
+ "spectest",
616
+ "node_modules",
617
+ "target",
618
+ "__pycache__",
619
+ ".venv",
620
+ ".env",
621
+ ".env.local",
622
+ ".env.*",
623
+ "dist",
624
+ "build",
625
+ ".next",
626
+ ".turbo",
627
+ ".DS_Store",
628
+ ];
629
+ /** First line of the `.dockerignore` we generate ourselves, so a later
630
+ * bootstrap can tell our file apart from one the project ships and never
631
+ * mistakes its own output for user intent. */
632
+ const GENERATED_DOCKERIGNORE_HEADER = "# spectest-generated — do not edit (your own .dockerignore is honoured verbatim)";
633
+ /**
634
+ * The project's own `/workspace/.dockerignore`, read once per bootstrap
635
+ * before we write anything, or `null` when it ships none.
636
+ *
637
+ * This file is the project's statement about what belongs in a build
638
+ * context, and it is frequently the difference between a 30-second and a
639
+ * 30-minute build (the `**` + negations idiom keeps a monorepo's context
640
+ * down to the handful of files a Go build actually reads). We used to
641
+ * ignore it entirely *and* overwrite it — so a carefully minimised
642
+ * context silently became the whole repo, and the checked-out file was
643
+ * clobbered for any in-env tooling that read it too.
644
+ */
645
+ let PROJECT_DOCKERIGNORE = null;
646
+ async function readProjectDockerignore() {
647
+ try {
648
+ const text = await fs.readFile(path.join(WORKSPACE, ".dockerignore"), "utf8");
649
+ // Ours, from a previous bootstrap of this workspace — not the project's.
650
+ if (text.startsWith(GENERATED_DOCKERIGNORE_HEADER))
651
+ return null;
652
+ return text;
653
+ }
654
+ catch {
655
+ return null;
656
+ }
657
+ }
658
+ /**
659
+ * Ignore rules for one dockerfile build, in precedence order: our
660
+ * defaults, then the project's own `.dockerignore` verbatim, then that
661
+ * service's `exclude`.
662
+ *
663
+ * Order is load-bearing for the `**` + negations idiom — the project's
664
+ * `**` subsumes our defaults, its `!` lines re-include exactly what the
665
+ * build needs, and the per-service `exclude` still gets the last word.
666
+ */
667
+ function serviceDockerignore(exclude) {
668
+ const parts = [DEFAULT_DOCKERIGNORE.join("\n")];
669
+ if (PROJECT_DOCKERIGNORE !== null) {
670
+ parts.push(`# --- from the project's .dockerignore ---\n${PROJECT_DOCKERIGNORE.trimEnd()}`);
671
+ }
672
+ if (exclude && exclude.length > 0) {
673
+ parts.push(`# --- from this service's exclude ---\n${exclude.join("\n")}`);
674
+ }
675
+ return parts.join("\n") + "\n";
676
+ }
677
+ function unionDockerignore(services) {
678
+ const seen = new Set(DEFAULT_DOCKERIGNORE);
679
+ const extras = [];
680
+ for (const s of services) {
681
+ if (s.image.type === "dockerfile" && s.image.exclude) {
682
+ for (const e of s.image.exclude) {
683
+ if (!seen.has(e)) {
684
+ seen.add(e);
685
+ extras.push(e);
686
+ }
687
+ }
688
+ }
689
+ }
690
+ return ([GENERATED_DOCKERIGNORE_HEADER, ...DEFAULT_DOCKERIGNORE, ...extras].join("\n") + "\n");
691
+ }
692
+ /// In-flight/finished dockerfile builds of this bootstrap, keyed by
693
+ /// sha256(dockerfile content + exclude list). Services that share an
694
+ /// identical image definition (e.g. an API server and a worker running the
695
+ /// same codebase with different entrypoints) build ONCE; the others wait
696
+ /// and `docker tag` the result. Cleared at every bootstrap() — the build
697
+ /// CONTEXT (/workspace) is an input too, so dedup is only valid within one
698
+ /// workspace generation (runtime services started mid-test share it).
699
+ const BUILD_DEDUP = new Map();
700
+ function buildContentKey(image) {
701
+ return new Bun.CryptoHasher("sha256")
702
+ .update(image.content)
703
+ .update("\0")
704
+ .update(JSON.stringify(image.exclude ?? []))
705
+ .digest("hex");
706
+ }
707
+ async function prepareServiceImage(svc, opts) {
708
+ const tag = imageTag(svc.name);
709
+ if (svc.image.type === "registry") {
710
+ const ref = svc.image.reference;
711
+ // Always pull — even when the (delta-restored) store already has the
712
+ // ref. With the layers present this costs ~a manifest round-trip per
713
+ // image ("Already exists" all the way down, through the zot mirror),
714
+ // off the bootstrap critical path; skipping it would freeze floating
715
+ // tags (`foo:latest`) at whatever the previous generation pulled, for
716
+ // as long as the delta chain lives — a silent semantic divergence
717
+ // from the cold build a delta restore must be equivalent to.
718
+ progressService(svc.name, { status: "pulling", detail: `pulling ${ref}` });
719
+ let layers = 0;
720
+ const pull = await shxStream("docker", ["pull", ref], 900_000, undefined, (line) => {
721
+ // `docker pull` (no TTY) prints one line per layer: "<id>: Pull
722
+ // complete" / "Already exists". Count them for a live layer tally.
723
+ if (/(?:Pull complete|Already exists)\s*$/.test(line)) {
724
+ layers++;
725
+ progressService(svc.name, { status: "pulling", detail: `${layers} layers` });
726
+ }
727
+ });
728
+ if (pull.code !== 0) {
729
+ progressService(svc.name, { status: "failed" });
730
+ throw new Error(`docker pull ${ref} failed: ${pull.stderr.trim() || pull.stdout.trim()}`);
731
+ }
732
+ const tagr = await docker(["tag", ref, tag], 30_000);
733
+ if (tagr.code !== 0) {
734
+ throw new Error(`docker tag ${ref} ${tag} failed: ${tagr.stderr.trim()}`);
735
+ }
736
+ await ensureCaTrustedImage(svc.name, tag);
737
+ return { tag };
738
+ }
739
+ // Dockerfile build. Within one bootstrap, identical definitions (shared
740
+ // codebase images) dedup to a single build. Only bootstrap opts in: the
741
+ // dedup key is dockerfile content + exclude, but the build CONTEXT
742
+ // (/workspace) is an input too — a runtime service started mid-test
743
+ // after setup/test code mutated /workspace must rebuild, not share a
744
+ // pre-mutation image.
745
+ const image = svc.image;
746
+ if (opts?.dedup) {
747
+ const key = buildContentKey(image);
748
+ const inflight = BUILD_DEDUP.get(key);
749
+ if (inflight) {
750
+ progressService(svc.name, { status: "building", detail: `sharing ${inflight.name}'s build` });
751
+ let first;
752
+ try {
753
+ first = await inflight.promise;
754
+ }
755
+ catch (err) {
756
+ progressService(svc.name, { status: "failed" });
757
+ throw err;
758
+ }
759
+ // first.tag is already CA-layered; tagging it covers this service too.
760
+ const tagr = await docker(["tag", first.tag, tag], 30_000);
761
+ if (tagr.code !== 0) {
762
+ throw new Error(`docker tag ${first.tag} ${tag} failed: ${tagr.stderr.trim()}`);
763
+ }
764
+ progressService(svc.name, { status: "prepared" });
765
+ return { tag, buildSteps: first.buildSteps };
766
+ }
767
+ const promise = buildServiceImage(svc.name, image, tag);
768
+ BUILD_DEDUP.set(key, { name: svc.name, promise });
769
+ try {
770
+ return await promise;
771
+ }
772
+ catch (err) {
773
+ // Let a sharer arriving later rebuild rather than inherit this
774
+ // build's failure forever.
775
+ BUILD_DEDUP.delete(key);
776
+ throw err;
777
+ }
778
+ }
779
+ return buildServiceImage(svc.name, image, tag);
780
+ }
781
+ async function buildServiceImage(name, image, tag) {
782
+ let buildSteps;
783
+ {
784
+ const dfDir = path.join(WORKSPACE, ".spectest", "services", name);
785
+ await fs.mkdir(dfDir, { recursive: true });
786
+ const dfPath = path.join(dfDir, "Dockerfile");
787
+ await fs.writeFile(dfPath, image.content);
788
+ // Per-service ignore: BuildKit resolves `<Dockerfile>.dockerignore`
789
+ // (next to the Dockerfile) in preference to the context root's
790
+ // `.dockerignore`, so this build sees the defaults, the project's own
791
+ // `.dockerignore`, and ITS OWN `exclude` — one service excluding
792
+ // `handhelds/**` no longer empties a sibling's build context. Verified
793
+ // on both the remote-buildx and DOCKER_BUILDKIT paths (client-side
794
+ // context filtering). The root `.dockerignore` written at bootstrap
795
+ // stays as the fallback for the legacy non-BuildKit builder, which
796
+ // predates per-Dockerfile ignores — and is written ONLY when the
797
+ // project ships none of its own (see readProjectDockerignore).
798
+ await fs.writeFile(`${dfPath}.dockerignore`, serviceDockerignore(image.exclude));
799
+ const useRemote = await ensureRemoteBuilder();
800
+ // Both the remote builder and a local buildx are BuildKit, so both emit
801
+ // per-step timing on stderr under `--progress=plain` (parsed below). Only
802
+ // the legacy in-VM builder takes no progress flag.
803
+ const useBuildKit = useRemote || (await hasBuildx());
804
+ const buildEnv = {};
805
+ let buildArgs;
806
+ if (useRemote) {
807
+ // Build on the host-side shared buildkitd (persistent cross-VM cache);
808
+ // `--load` brings the finished image back into the in-VM dockerd so
809
+ // runContainer can `docker run` it. The build context (WORKSPACE, minus
810
+ // .dockerignore) streams to buildkitd over the bridge.
811
+ buildArgs = [
812
+ "buildx", "build",
813
+ "--builder", REMOTE_BUILDER_NAME,
814
+ "--load",
815
+ "--progress=plain",
816
+ "-t", tag, "-f", dfPath, WORKSPACE,
817
+ ];
818
+ }
819
+ else if (useBuildKit) {
820
+ buildArgs = ["build", "-t", tag, "-f", dfPath, "--progress=plain", WORKSPACE];
821
+ buildEnv.DOCKER_BUILDKIT = "1";
822
+ }
823
+ else {
824
+ buildArgs = ["build", "-t", tag, "-f", dfPath, WORKSPACE];
825
+ }
826
+ progressService(name, { status: "building", detail: "starting build" });
827
+ const build = await shxStream("docker", buildArgs, 1_800_000, buildEnv, (line) => {
828
+ // BuildKit `--progress=plain` declares each step as
829
+ // `#N [<stage> M/N] <cmd>`; the legacy builder as `Step M/N : <cmd>`.
830
+ // Track the most-recent step as live detail.
831
+ let m = line.match(/^#\d+\s+\[([^\]]*)\]\s+(.+)$/);
832
+ if (m) {
833
+ const step = m[1].match(/\d+\/\d+/)?.[0];
834
+ const cmd = m[2].trim().slice(0, 60);
835
+ progressService(name, {
836
+ status: "building",
837
+ detail: step ? `step ${step} ${cmd}` : cmd,
838
+ });
839
+ return;
840
+ }
841
+ m = line.match(/^Step (\d+\/\d+)\s*:\s*(.+)$/);
842
+ if (m) {
843
+ progressService(name, {
844
+ status: "building",
845
+ detail: `step ${m[1]} ${m[2].trim().slice(0, 60)}`,
846
+ });
847
+ }
848
+ });
849
+ if (build.code !== 0) {
850
+ progressService(name, { status: "failed" });
851
+ throw new Error(`docker build for ${name} failed:\n${build.stderr.trim()}\n${build.stdout.trim()}`);
852
+ }
853
+ if (useBuildKit) {
854
+ // Keep only the slowest dozen steps ≥1s — enough to profile, small
855
+ // enough to ride back in the /bootstrap response and the journal.
856
+ buildSteps = summarizeBuildKit(build.stderr)
857
+ .filter((s) => s.secs >= 1)
858
+ .slice(0, 12);
859
+ }
860
+ }
861
+ // Layer the spectest CA into the image's system trust store so apps
862
+ // that read the system bundle (Go, Java, CLIs that don't honour the
863
+ // SSL_CERT_FILE env vars) accept HTTPS to fakes. Best-effort: images
864
+ // without `update-ca-certificates` / `update-ca-trust` (distroless,
865
+ // scratch) fall through to the env-var path that `runContainer` sets.
866
+ await ensureCaTrustedImage(name, tag);
867
+ return { tag, buildSteps };
868
+ }
869
+ /**
870
+ * Build a derivative image on top of `tag` that copies the spectest
871
+ * root CA into the system trust store. Tagged back as `tag`, so the
872
+ * rest of the orchestrator (runContainer, image cache) is oblivious.
873
+ * Failures are warned-and-ignored: the env-var injection in
874
+ * runContainer is the universal fallback, so apps that use it (most
875
+ * Node/Python/Ruby/AWS clients) still trust the CA even when the
876
+ * image's trust store can't be updated.
877
+ */
878
+ async function ensureCaTrustedImage(serviceName, tag) {
879
+ if (!existsSync(CA_PATH)) {
880
+ // Daemon running outside a base-snapshot VM (dev/test). Nothing to
881
+ // layer; env vars also harmless (they point at a missing path, but
882
+ // most consumers ignore missing files).
883
+ return;
884
+ }
885
+ const ctxDir = path.join(WORKSPACE, ".spectest", "ca-trust", serviceName);
886
+ await fs.mkdir(ctxDir, { recursive: true });
887
+ await fs.copyFile(CA_PATH, path.join(ctxDir, "spectest-ca.crt"));
888
+ const dockerfile = `FROM ${tag}
889
+ COPY spectest-ca.crt /usr/local/share/ca-certificates/spectest-ca.crt
890
+ RUN if command -v update-ca-certificates >/dev/null 2>&1; then \\
891
+ update-ca-certificates; \\
892
+ elif command -v update-ca-trust >/dev/null 2>&1; then \\
893
+ cp /usr/local/share/ca-certificates/spectest-ca.crt /etc/pki/ca-trust/source/anchors/spectest-ca.crt && update-ca-trust extract; \\
894
+ else \\
895
+ echo "[spectest] no system CA trust tool in image; env-var trust only"; \\
896
+ fi
897
+ `;
898
+ await fs.writeFile(path.join(ctxDir, "Dockerfile"), dockerfile);
899
+ const build = await docker(["build", "-t", tag, ctxDir], 300_000);
900
+ if (build.code !== 0) {
901
+ // eslint-disable-next-line no-console
902
+ console.warn(`[ca-trust] could not layer spectest CA into ${serviceName} (${tag}); env-var fallback only:\n${build.stderr.trim() || build.stdout.trim()}`);
903
+ }
904
+ }
905
+ async function runContainer(svc, tag, volumeFlags,
906
+ // Extra `--network-alias`es beyond the lowered `aliasesByService`. Used by
907
+ // runtime `startService` (whose service isn't in LOWERED) to give the new
908
+ // container docker-native multi-label resolution for its `hostnames`.
909
+ extraAliases = []) {
910
+ // Idempotent: clean up any leftover container with the same name.
911
+ await docker(["rm", "-f", svc.name], 30_000);
912
+ const args = [
913
+ "run",
914
+ "-d",
915
+ "--restart=no",
916
+ `--name=${svc.name}`,
917
+ `--hostname=${svc.name}`,
918
+ `--network=${NETWORK_NAME}`,
919
+ // Every service is reachable at `<name>.internal` as well as its
920
+ // bare `<name>`. The fully-qualified form is what kubeconfigs and
921
+ // other tooling that expect a multi-label hostname should use; it's
922
+ // resolved both inside containers (Docker's embedded DNS) and on
923
+ // the VM host (spectest-resolver scans aliases).
924
+ `--network-alias=${svc.name}.internal`,
925
+ ];
926
+ // Extra peer aliases for this service — lowered from `hostnames` and any
927
+ // dnsName(h, { service }) into LOWERED.aliasesByService, plus any passed
928
+ // explicitly by a runtime startService (not present in LOWERED).
929
+ for (const h of [...(LOWERED.aliasesByService[svc.name] ?? []), ...extraAliases]) {
930
+ args.push(`--network-alias=${h}`);
931
+ }
932
+ // Bound TCP give-up time inside THIS container's network namespace.
933
+ // net.ipv4.tcp_retries2 is per-netns and a fresh netns resets to the kernel
934
+ // default (15 ≈ ~15 min of RTO backoff), so lowering it on the guest's init
935
+ // netns (BASE_SETUP_SH) does NOT reach containers — and the connections that
936
+ // actually wedge run here: buildkit/buildctl pulling base images + exporting
937
+ // cache, and the k3s container's containerd pulling images, all to the host
938
+ // zot over the VM↔host path. On a lost-retransmit (transient loss under
939
+ // concurrent forks) such a flow otherwise stalls a build/pull for minutes.
940
+ // Setting it per container resets a genuinely-stuck flow in ~tens of seconds
941
+ // so the client retries on a fresh connection; live connections keep getting
942
+ // ACKs and are unaffected. Safe because every service runs on the
943
+ // spectest-net bridge (own netns), never --network=host where net.* is denied.
944
+ args.push("--sysctl", "net.ipv4.tcp_retries2=6");
945
+ // Wire every ingress hostname (fakes, TLS-terminated proxies, and any
946
+ // dnsName(h, { ingress: true })) into the container's /etc/hosts so
947
+ // `fetch("http://api.stripe.com")` or `fetch("https://app.test")` from
948
+ // app code reaches the daemon's ingress listener via the bridge gateway.
949
+ // /etc/hosts beats Docker's embedded DNS (127.0.0.11), so we don't need
950
+ // to touch the container's resolver settings.
951
+ if (cachedGatewayIp) {
952
+ for (const h of LOWERED.ingressHosts) {
953
+ args.push(`--add-host=${h}:${cachedGatewayIp}`);
954
+ }
955
+ }
956
+ // Resolve `spectest-host` to the host image-cache gateway so apps can
957
+ // address the zot mirrors / shared buildkitd by name (see
958
+ // SPECTEST_HOST_NAME). Skipped where there's no host cache.
959
+ const hostGw = hostCacheGateway();
960
+ if (hostGw)
961
+ args.push(`--add-host=${SPECTEST_HOST_NAME}:${hostGw}`);
962
+ if (svc.workdir)
963
+ args.push(`--workdir=${svc.workdir}`);
964
+ // Trust the spectest root CA from inside the container. Bind-mount
965
+ // the cert + set the conventional env vars so language runtimes
966
+ // (Node, Python requests/httpx, AWS SDKs) pick it up without
967
+ // touching the image's system trust store. The per-image
968
+ // ensureCaTrustedImage layer also installs it into the system
969
+ // trust store; this env-var path is the belt-and-braces fallback
970
+ // for images where the layer step couldn't run (no
971
+ // update-ca-certificates).
972
+ args.push(`--volume=${CA_PATH}:${CA_PATH}:ro`);
973
+ args.push("-e", `NODE_EXTRA_CA_CERTS=${CA_PATH}`);
974
+ args.push("-e", `SSL_CERT_FILE=${CA_PATH}`);
975
+ args.push("-e", `REQUESTS_CA_BUNDLE=${CA_PATH}`);
976
+ args.push("-e", `AWS_CA_BUNDLE=${CA_PATH}`);
977
+ if (svc.env) {
978
+ for (const [k, v] of Object.entries(svc.env)) {
979
+ args.push("-e", `${k}=${v}`);
980
+ }
981
+ }
982
+ for (const flag of volumeFlags)
983
+ args.push(flag);
984
+ if (svc.privileged)
985
+ args.push("--privileged");
986
+ for (const p of svc.tmpfs ?? [])
987
+ args.push(`--tmpfs=${p}`);
988
+ if (svc.cgroupns)
989
+ args.push(`--cgroupns=${svc.cgroupns}`);
990
+ // `command` runs via sh -c, replacing the image entrypoint; `args` is a
991
+ // plain CMD override (`docker run <image> <args…>`) that keeps the
992
+ // entrypoint — what init-wrapped images (postgres) need for extra flags.
993
+ if (svc.command && svc.args?.length) {
994
+ throw new Error(`service ${svc.name}: \`command\` and \`args\` are mutually exclusive ` +
995
+ `(command replaces the entrypoint with /bin/sh -c; args keeps it)`);
996
+ }
997
+ if (svc.command)
998
+ args.push("--entrypoint=/bin/sh");
999
+ args.push(tag);
1000
+ if (svc.command)
1001
+ args.push("-c", svc.command);
1002
+ else if (svc.args?.length)
1003
+ args.push(...svc.args);
1004
+ const r = await docker(args, 300_000);
1005
+ if (r.code !== 0) {
1006
+ throw new Error(`docker run ${svc.name} failed: ${r.stderr.trim() || r.stdout.trim()}`);
1007
+ }
1008
+ }
1009
+ async function probeTcp(host, port) {
1010
+ return new Promise((resolve) => {
1011
+ const sock = net.createConnection({ host, port });
1012
+ let settled = false;
1013
+ const finish = (v) => {
1014
+ if (settled)
1015
+ return;
1016
+ settled = true;
1017
+ try {
1018
+ sock.destroy();
1019
+ }
1020
+ catch {
1021
+ /* ignore */
1022
+ }
1023
+ resolve(v);
1024
+ };
1025
+ sock.setTimeout(2000);
1026
+ sock.once("connect", () => finish(true));
1027
+ sock.once("error", () => finish(false));
1028
+ sock.once("timeout", () => finish(false));
1029
+ });
1030
+ }
1031
+ async function probeHttp(host, port, urlPath, headers, expectStatus) {
1032
+ const ctrl = new AbortController();
1033
+ const to = setTimeout(() => ctrl.abort(), 5000);
1034
+ try {
1035
+ const res = await fetch(`http://${host}:${port}${urlPath}`, {
1036
+ signal: ctrl.signal,
1037
+ headers,
1038
+ });
1039
+ return expectStatus !== undefined ? res.status === expectStatus : res.ok;
1040
+ }
1041
+ catch {
1042
+ return false;
1043
+ }
1044
+ finally {
1045
+ clearTimeout(to);
1046
+ }
1047
+ }
1048
+ async function probeExec(name, command) {
1049
+ const r = await docker(["exec", name, "sh", "-c", command], 10_000);
1050
+ return r.code === 0;
1051
+ }
1052
+ async function waitForReady(svc) {
1053
+ const check = svc.readyCheck;
1054
+ if (!check)
1055
+ return;
1056
+ const timeoutSecs = check.timeoutSecs ?? 60;
1057
+ const deadline = Date.now() + timeoutSecs * 1000;
1058
+ // Ramped poll: a flat 500ms quantized every service's ready latency
1059
+ // (and compounds down dependsOn chains). Fast early probes catch
1060
+ // quick services; the ramp caps the polling load on slow ones. Exec
1061
+ // probes keep a higher floor — each attempt spawns a docker exec.
1062
+ const ramp = check.type === "exec" ? [250, 250, 400, 400, 500] : [50, 100, 150, 250, 400, 500];
1063
+ let attempt = 0;
1064
+ while (Date.now() < deadline) {
1065
+ let ok = false;
1066
+ if (check.type === "tcp") {
1067
+ ok = await probeTcp(svc.name, check.port);
1068
+ }
1069
+ else if (check.type === "http") {
1070
+ ok = await probeHttp(svc.name, check.port, check.path ?? "/", check.headers, check.expectStatus);
1071
+ }
1072
+ else {
1073
+ ok = await probeExec(svc.name, check.command);
1074
+ }
1075
+ if (ok)
1076
+ return;
1077
+ const delay = ramp[Math.min(attempt, ramp.length - 1)];
1078
+ attempt++;
1079
+ await new Promise((r) => setTimeout(r, delay));
1080
+ }
1081
+ const logs = await docker(["logs", "--tail=200", svc.name], 30_000);
1082
+ throw new Error(`service ${svc.name} not ready within ${timeoutSecs}s. Recent container logs:\n${logs.stdout}\n${logs.stderr}`);
1083
+ }
1084
+ /**
1085
+ * Validate the `dependsOn` graph and return the name→service map used to
1086
+ * walk it. Rejects unknown dependencies and cycles (the same two errors
1087
+ * the old level scheduler raised) so the DAG runner can assume a clean
1088
+ * graph.
1089
+ */
1090
+ function validateServiceGraph(services) {
1091
+ const byName = new Map(services.map((s) => [s.name, s]));
1092
+ for (const s of services) {
1093
+ for (const d of s.dependsOn ?? []) {
1094
+ if (!byName.has(d)) {
1095
+ throw new Error(`service ${s.name} depends on unknown service ${d}`);
1096
+ }
1097
+ }
1098
+ }
1099
+ // Cycle detection via DFS coloring (white=unseen, gray=on stack, black=done).
1100
+ const WHITE = 0, GRAY = 1, BLACK = 2;
1101
+ const color = new Map(services.map((s) => [s.name, WHITE]));
1102
+ const visit = (name) => {
1103
+ color.set(name, GRAY);
1104
+ for (const d of byName.get(name).dependsOn ?? []) {
1105
+ const c = color.get(d);
1106
+ if (c === GRAY)
1107
+ throw new Error("service dependency cycle");
1108
+ if (c === WHITE)
1109
+ visit(d);
1110
+ }
1111
+ color.set(name, BLACK);
1112
+ };
1113
+ for (const s of services)
1114
+ if (color.get(s.name) === WHITE)
1115
+ visit(s.name);
1116
+ return byName;
1117
+ }
1118
+ /**
1119
+ * Bring up every service as early as its own dependencies allow.
1120
+ *
1121
+ * Each service starts the instant all of its `dependsOn` services have
1122
+ * finished `startOne` (run → readyCheck → setup) — not when its whole
1123
+ * topological "level" has. Independent branches run fully concurrently;
1124
+ * a slow probe on one service delays only its own transitive dependents,
1125
+ * never an unrelated branch. `startOne(svc)`'s promise is memoized so each
1126
+ * service runs exactly once even when several dependents share a dep, and
1127
+ * a dependency failure propagates by rejecting every dependent's await.
1128
+ */
1129
+ async function startServices(services, startOne) {
1130
+ const byName = validateServiceGraph(services);
1131
+ const started = new Map();
1132
+ const start = (svc) => {
1133
+ const existing = started.get(svc.name);
1134
+ if (existing)
1135
+ return existing;
1136
+ const p = (async () => {
1137
+ await Promise.all((svc.dependsOn ?? [])
1138
+ .filter((d) => byName.has(d))
1139
+ .map((d) => start(byName.get(d))));
1140
+ await startOne(svc);
1141
+ })();
1142
+ started.set(svc.name, p);
1143
+ return p;
1144
+ };
1145
+ await Promise.all(services.map(start));
1146
+ }
1147
+ // ────────────────────────────────────────────────────────────────────────
1148
+ // Ingress — in-daemon HTTP/HTTPS listeners that route by Host header.
1149
+ //
1150
+ // Two kinds of routes share the same listeners:
1151
+ //
1152
+ // * Fakes — in-daemon mock APIs. Each fake declares `hostnames` and a
1153
+ // `port` (default 80); the request hits the fake's handler with the
1154
+ // fake's `state`. HTTPS always serves on 443 (SNI per hostname,
1155
+ // leaf cert signed by the in-VM root CA).
1156
+ //
1157
+ // * Service TLS — reverse-proxy fronts for user services. Each
1158
+ // `services.<name>.tls` entry declares `{ hostname, port }`; the
1159
+ // daemon binds the hostname on :80 AND :443 and proxies each
1160
+ // request to `http://<service>:<port>` inside the docker network.
1161
+ // WebSocket upgrades are bridged. The leaf cert is signed by the
1162
+ // same root CA, so `ctx.browser()` and peer services trust it.
1163
+ //
1164
+ // Both listeners bind on 0.0.0.0 so containers reach them via the
1165
+ // bridge gateway IP (also written into /run/spectest-fakes.json for
1166
+ // spectest-resolver and injected as --add-host on every container).
1167
+ //
1168
+ // Per-fake `state` is plain JS memory and lives across snapshot/fork
1169
+ // along with the rest of the daemon — every fork sees its own copy.
1170
+ // ────────────────────────────────────────────────────────────────────────
1171
+ const FAKES_REGISTRY_PATH = process.env.SPECTEST_FAKES_REGISTRY ?? "/run/spectest-fakes.json";
1172
+ const DEFAULT_FAKE_PORT = 80;
1173
+ /** Fixed HTTPS port shared by every route (fakes + service-tls). */
1174
+ const INGRESS_HTTPS_PORT = 443;
1175
+ /** Fixed HTTP port always bound for service-tls (alongside any
1176
+ * fakes whose `port` happens to be 80). */
1177
+ const INGRESS_HTTP_PORT = 80;
1178
+ /** All loaded fakes, keyed by stable name (the `fakes` map key). Holds the
1179
+ * in-daemon handler, forked state, and helpers — the parts intrinsic to a
1180
+ * fake. Their *networking* (certs, DNS, routes) comes from `LOWERED`. */
1181
+ const FAKES = new Map();
1182
+ /** Generic ingress derived from the loaded project (tls/hostnames/fakes/
1183
+ * component `provides`) by the SDK's `lowerIngress`. The daemon executes
1184
+ * this and never reads `svc.tls`/`svc.hostnames` itself. Rebuilt on /load. */
1185
+ let LOWERED = {
1186
+ certificates: [],
1187
+ proxies: [],
1188
+ ingressHosts: [],
1189
+ aliasesByService: {},
1190
+ wildcards: [],
1191
+ };
1192
+ /** Running HTTP servers per port (Bun.Server). Rebuilt on /load. */
1193
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1194
+ const INGRESS_HTTP_SERVERS = new Map();
1195
+ /** Running HTTPS servers per port (currently always {INGRESS_HTTPS_PORT}). */
1196
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1197
+ const INGRESS_HTTPS_SERVERS = new Map();
1198
+ /**
1199
+ * Live per-port route tables, keyed by listen port (80 / 443 / fake ports).
1200
+ * Each listener's `fetch` closure captures *this* Map object, so adding an
1201
+ * entry takes effect immediately with no rebind — that's what lets a runtime
1202
+ * `tls` (a `ctx.startService({ tls })`) bind a new ingress route after boot.
1203
+ * Held at module scope so it's part of the live daemon process and forks
1204
+ * with the snapshot, exactly like fake state / the names REGISTRY. Rebuilt on
1205
+ * /load (cleared by {@link stopIngressServers}).
1206
+ */
1207
+ const INGRESS_ROUTES_BY_PORT = new Map();
1208
+ /**
1209
+ * The :443 SNI cert table: serverName → leaf. Unlike the route table, Bun's
1210
+ * TLS config is fixed at `Bun.serve` time (reload won't add an SNI entry), so
1211
+ * minting a cert for a *new* hostname requires rebinding the :443 listener
1212
+ * (cheap, ~1ms — see {@link rebindHttpsListener}). A hostname already covered
1213
+ * by an existing exact or wildcard cert needs no rebind, just a route entry.
1214
+ */
1215
+ const HTTPS_CERT_BY_HOST = new Map();
1216
+ /**
1217
+ * Tear down listener servers between /load calls so the new project's
1218
+ * routes can rebind cleanly.
1219
+ */
1220
+ function stopIngressServers() {
1221
+ for (const [port, srv] of INGRESS_HTTP_SERVERS) {
1222
+ try {
1223
+ srv.stop?.();
1224
+ }
1225
+ catch (err) {
1226
+ // eslint-disable-next-line no-console
1227
+ console.warn(`[ingress] failed to stop http server on :${port}:`, err);
1228
+ }
1229
+ }
1230
+ INGRESS_HTTP_SERVERS.clear();
1231
+ for (const [port, srv] of INGRESS_HTTPS_SERVERS) {
1232
+ try {
1233
+ srv.stop?.();
1234
+ }
1235
+ catch (err) {
1236
+ // eslint-disable-next-line no-console
1237
+ console.warn(`[ingress] failed to stop https server on :${port}:`, err);
1238
+ }
1239
+ }
1240
+ INGRESS_HTTPS_SERVERS.clear();
1241
+ INGRESS_ROUTES_BY_PORT.clear();
1242
+ HTTPS_CERT_BY_HOST.clear();
1243
+ }
1244
+ function buildIngress(project) {
1245
+ stopIngressServers();
1246
+ FAKES.clear();
1247
+ // Lower the friendly surface (tls/hostnames/provides/fakes) into the
1248
+ // generic decl set the daemon executes. The special-casing lives in the
1249
+ // SDK's lowerIngress, not here.
1250
+ LOWERED = lowerIngress(project);
1251
+ if (!project.fakes)
1252
+ return;
1253
+ for (const [name, def] of Object.entries(project.fakes)) {
1254
+ FAKES.set(name, {
1255
+ def,
1256
+ state: undefined, // built in startIngress after `state()` runs
1257
+ hostnames: def.hostnames.map((h) => h.toLowerCase()),
1258
+ port: def.port ?? DEFAULT_FAKE_PORT,
1259
+ });
1260
+ }
1261
+ }
1262
+ /**
1263
+ * Generate a leaf cert + key for one ingress route (fake or service
1264
+ * proxy), signed by the in-VM root CA at {CA_PATH}. SANs cover every
1265
+ * hostname the route answers to, so a client connecting with TLS
1266
+ * verifies cleanly regardless of which hostname it used. Shells out
1267
+ * to `openssl req -x509 -CA ... -CAkey ...` (OpenSSL 3.0+; Debian
1268
+ * bookworm ships 3.0.x).
1269
+ *
1270
+ * `label` is a short tag baked into the cert Subject CN and the temp
1271
+ * file names — only used for diagnostics, not for TLS verification.
1272
+ */
1273
+ async function generateHostCert(label, hostnames) {
1274
+ const id = `spectest-host-${sanitizeSegment(label)}-${randomUUID().slice(0, 8)}`;
1275
+ const keyPath = path.join("/tmp", `${id}.key`);
1276
+ const crtPath = path.join("/tmp", `${id}.crt`);
1277
+ const sans = hostnames.map((h) => `DNS:${h}`).join(",");
1278
+ const args = [
1279
+ "req",
1280
+ "-newkey",
1281
+ "rsa:2048",
1282
+ "-nodes",
1283
+ "-keyout",
1284
+ keyPath,
1285
+ "-out",
1286
+ crtPath,
1287
+ "-x509",
1288
+ "-CA",
1289
+ CA_PATH,
1290
+ "-CAkey",
1291
+ CA_KEY_PATH,
1292
+ "-days",
1293
+ "3650",
1294
+ "-subj",
1295
+ `/CN=spectest-${label}`,
1296
+ "-addext",
1297
+ `subjectAltName=${sans}`,
1298
+ "-addext",
1299
+ "basicConstraints=CA:FALSE",
1300
+ "-addext",
1301
+ "extendedKeyUsage=serverAuth",
1302
+ "-addext",
1303
+ "keyUsage=digitalSignature,keyEncipherment",
1304
+ ];
1305
+ const r = await shx("openssl", args, 30_000);
1306
+ if (r.code !== 0) {
1307
+ throw new Error(`openssl req for ${label} failed (rc=${r.code}): ${r.stderr.trim() || r.stdout.trim()}`);
1308
+ }
1309
+ try {
1310
+ const [cert, key] = await Promise.all([
1311
+ fs.readFile(crtPath, "utf8"),
1312
+ fs.readFile(keyPath, "utf8"),
1313
+ ]);
1314
+ return { cert, key };
1315
+ }
1316
+ finally {
1317
+ await Promise.all([
1318
+ fs.unlink(keyPath).catch(() => { }),
1319
+ fs.unlink(crtPath).catch(() => { }),
1320
+ ]);
1321
+ }
1322
+ }
1323
+ /** Resolve the spectest-net bridge gateway IP — the address containers
1324
+ * use to reach the VM host. Asks dockerd via the docker CLI; cached for
1325
+ * the daemon's life because the network is recreated only on reload. */
1326
+ let cachedGatewayIp = null;
1327
+ async function bridgeGatewayIp() {
1328
+ if (cachedGatewayIp)
1329
+ return cachedGatewayIp;
1330
+ const out = await docker([
1331
+ "network",
1332
+ "inspect",
1333
+ "--format",
1334
+ "{{(index .IPAM.Config 0).Gateway}}",
1335
+ NETWORK_NAME,
1336
+ ], 10_000);
1337
+ if (out.code !== 0) {
1338
+ throw new Error(`docker network inspect ${NETWORK_NAME} failed (rc=${out.code}): ${out.stderr.trim()}`);
1339
+ }
1340
+ const ip = out.stdout.trim();
1341
+ if (!ip)
1342
+ throw new Error(`no gateway IP returned for network ${NETWORK_NAME}`);
1343
+ cachedGatewayIp = ip;
1344
+ return ip;
1345
+ }
1346
+ /**
1347
+ * Pristine `fetch` captured at module load, before any test-scoped
1348
+ * fetch wrapper can monkey-patch `globalThis.fetch`. The reverse-proxy
1349
+ * uses this directly so its outbound HTTP calls aren't intercepted by
1350
+ * the test recorder — they'd be (a) misattributed to the test's
1351
+ * timeline, and (b) trip up the Response constructor because the
1352
+ * recorder wraps `res.status` / `res.body` in inspectable proxies
1353
+ * that don't pass through as primitives.
1354
+ */
1355
+ const NATIVE_FETCH = globalThis.fetch.bind(globalThis);
1356
+ /** Hop-by-hop headers per RFC 7230 §6.1 — never forwarded by a proxy. */
1357
+ const HOP_BY_HOP_HEADERS = new Set([
1358
+ "connection",
1359
+ "keep-alive",
1360
+ "proxy-authenticate",
1361
+ "proxy-authorization",
1362
+ "te",
1363
+ "trailers",
1364
+ "transfer-encoding",
1365
+ "upgrade",
1366
+ "host",
1367
+ ]);
1368
+ /**
1369
+ * Is this a CORS preflight? A preflight is the browser's own probe (never
1370
+ * app business logic): an `OPTIONS` carrying `Origin` +
1371
+ * `Access-Control-Request-Method`. Plain `OPTIONS` calls (no `ACRM`) are real
1372
+ * app requests and pass straight through to the upstream/fake.
1373
+ */
1374
+ function isCorsPreflight(req) {
1375
+ return (req.method === "OPTIONS" &&
1376
+ req.headers.has("origin") &&
1377
+ req.headers.has("access-control-request-method"));
1378
+ }
1379
+ /**
1380
+ * Answer a CORS preflight at the ingress, permissively, reflecting exactly
1381
+ * what the browser asked for.
1382
+ *
1383
+ * Why this belongs in the platform, not the app: inside the hermetic sandbox
1384
+ * the app page's origin (e.g. `http://<svc>.internal:<port>`) and every host
1385
+ * it fetches through this ingress (`https://api.example.com`) are *always*
1386
+ * different origins, so any request with a non-safelisted header — which
1387
+ * includes `Authorization`, and crucially `Cache-Control` / `Pragma` — is
1388
+ * preflighted by the browser. If we forward the `OPTIONS` to the upstream, the
1389
+ * request succeeds or fails on whether *that* app happens to enumerate the
1390
+ * header in its `Access-Control-Allow-Headers`. Real apps list `Authorization`
1391
+ * but almost never `Cache-Control`/`Pragma`, so a client that sends those (many
1392
+ * HTTP libraries add `Cache-Control: no-cache` by default) fails the preflight
1393
+ * with an instant "Failed to fetch" — even though the identical request works
1394
+ * in production behind a permissive edge/gateway. Reflecting
1395
+ * `Access-Control-Request-Headers` verbatim makes the ingress transparent to
1396
+ * whatever header vocabulary the app under test uses.
1397
+ */
1398
+ function corsPreflightResponse(req) {
1399
+ const origin = req.headers.get("origin") ?? "*";
1400
+ const reqHeaders = req.headers.get("access-control-request-headers");
1401
+ const reqMethod = req.headers.get("access-control-request-method");
1402
+ const headers = new Headers();
1403
+ headers.set("access-control-allow-origin", origin);
1404
+ // Echo the specific origin (not `*`) so credentialed requests are allowed;
1405
+ // `Allow-Origin: *` + `Allow-Credentials: true` is a spec violation browsers
1406
+ // reject.
1407
+ headers.set("access-control-allow-credentials", "true");
1408
+ headers.set("access-control-allow-methods", reqMethod && reqMethod.length > 0
1409
+ ? reqMethod
1410
+ : "GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS");
1411
+ headers.set("access-control-allow-headers", reqHeaders && reqHeaders.length > 0 ? reqHeaders : "*");
1412
+ headers.set("access-control-max-age", "600");
1413
+ // The response varies by the reflected origin/headers — keep caches honest.
1414
+ headers.append("vary", "Origin");
1415
+ headers.append("vary", "Access-Control-Request-Headers");
1416
+ return new Response(null, { status: 204, headers });
1417
+ }
1418
+ /**
1419
+ * Make sure the browser sees an `Access-Control-Allow-Origin` it accepts on the
1420
+ * *actual* cross-origin response. Only fills one in when the upstream/fake
1421
+ * didn't set its own, so an app that manages CORS itself keeps full control;
1422
+ * this just stops a missing header from turning an otherwise-fine 200 into a
1423
+ * "Failed to fetch". No-op for same-origin requests (no `Origin`).
1424
+ */
1425
+ function augmentCorsResponse(req, res) {
1426
+ const origin = req.headers.get("origin");
1427
+ if (!origin)
1428
+ return res;
1429
+ if (res.headers.has("access-control-allow-origin"))
1430
+ return res;
1431
+ try {
1432
+ res.headers.set("access-control-allow-origin", origin);
1433
+ res.headers.set("access-control-allow-credentials", "true");
1434
+ res.headers.append("vary", "Origin");
1435
+ }
1436
+ catch {
1437
+ // Some responses (e.g. a 101 upgrade stub) carry guarded/immutable
1438
+ // headers — leave those untouched.
1439
+ }
1440
+ return res;
1441
+ }
1442
+ /**
1443
+ * Bring ingress servers up: bind one Bun.serve per unique HTTP port
1444
+ * (fakes' ports plus the always-on :80 for service proxies), plus a
1445
+ * shared HTTPS :443 (SNI per hostname). Build each fake's initial state,
1446
+ * then write the hostname→ip registry that spectest-resolver consults
1447
+ * for DNS. Idempotent — calling twice rebuilds.
1448
+ */
1449
+ async function startIngress() {
1450
+ const hasIngress = FAKES.size > 0 ||
1451
+ LOWERED.proxies.length > 0 ||
1452
+ LOWERED.ingressHosts.length > 0 ||
1453
+ LOWERED.wildcards.length > 0;
1454
+ if (!hasIngress) {
1455
+ // Make sure the resolver doesn't see stale entries from a prior project.
1456
+ REGISTRY.hosts = {};
1457
+ REGISTRY.wildcards = [];
1458
+ await writeRegistry();
1459
+ return;
1460
+ }
1461
+ const gw = await bridgeGatewayIp();
1462
+ // Initialise per-fake state. Awaited sequentially — state factories
1463
+ // are expected to be tiny constructors; the cost of serial init is
1464
+ // dwarfed by the eventual snapshot.
1465
+ for (const [name, fake] of FAKES) {
1466
+ if (fake.def.state) {
1467
+ try {
1468
+ fake.state = await fake.def.state();
1469
+ }
1470
+ catch (err) {
1471
+ throw new Error(`fake ${JSON.stringify(name)} state() factory threw: ${err.message}`);
1472
+ }
1473
+ }
1474
+ else {
1475
+ fake.state = {};
1476
+ }
1477
+ }
1478
+ // Mint one leaf cert per `certificate` decl (SANs = its hostnames),
1479
+ // signed by the in-VM root CA, and index it by hostname for SNI. Done
1480
+ // before binding so the HTTPS listener has certs ready and a startup
1481
+ // failure aborts /bootstrap cleanly.
1482
+ const caPresent = existsSync(CA_PATH) && existsSync(CA_KEY_PATH);
1483
+ if (caPresent) {
1484
+ for (const group of LOWERED.certificates) {
1485
+ if (group.hostnames.length === 0)
1486
+ continue;
1487
+ const leaf = await generateHostCert(group.hostnames[0], group.hostnames);
1488
+ for (const h of group.hostnames)
1489
+ HTTPS_CERT_BY_HOST.set(h, leaf);
1490
+ }
1491
+ }
1492
+ else if (LOWERED.certificates.length > 0) {
1493
+ // eslint-disable-next-line no-console
1494
+ console.warn(`[ingress] root CA missing at ${CA_PATH}; skipping HTTPS bind (fakes/proxies will be HTTP-only)`);
1495
+ }
1496
+ const Bun = requireBun();
1497
+ // Resolve every ingress hostname to its handler into the live per-port
1498
+ // route tables (module scope, so runtime `tls` can extend them later).
1499
+ // Fakes run an in-daemon handler on their declared port; proxies
1500
+ // reverse-proxy to a service:port and bind :80 (HTTPS, if any, is :443).
1501
+ const ensurePort = (port) => {
1502
+ const m = INGRESS_ROUTES_BY_PORT.get(port) ?? new Map();
1503
+ INGRESS_ROUTES_BY_PORT.set(port, m);
1504
+ return m;
1505
+ };
1506
+ for (const fake of FAKES.values()) {
1507
+ if (fake.port === INGRESS_HTTPS_PORT)
1508
+ continue;
1509
+ const routes = ensurePort(fake.port);
1510
+ for (const h of fake.hostnames)
1511
+ routes.set(h, { kind: "fake", fake });
1512
+ }
1513
+ if (LOWERED.proxies.length > 0) {
1514
+ const routes = ensurePort(INGRESS_HTTP_PORT);
1515
+ for (const p of LOWERED.proxies) {
1516
+ routes.set(p.hostname, { kind: "proxy", service: p.service, port: p.port });
1517
+ }
1518
+ }
1519
+ // The :443 route table mirrors every certificated hostname's handler.
1520
+ if (HTTPS_CERT_BY_HOST.size > 0) {
1521
+ const httpsRoutes = ensurePort(INGRESS_HTTPS_PORT);
1522
+ for (const h of HTTPS_CERT_BY_HOST.keys()) {
1523
+ for (const fake of FAKES.values()) {
1524
+ if (fake.hostnames.includes(h))
1525
+ httpsRoutes.set(h, { kind: "fake", fake });
1526
+ }
1527
+ const proxy = LOWERED.proxies.find((p) => p.hostname === h);
1528
+ if (proxy)
1529
+ httpsRoutes.set(h, { kind: "proxy", service: proxy.service, port: proxy.port });
1530
+ }
1531
+ }
1532
+ // ── HTTP listeners (one per non-443 port).
1533
+ for (const [port, byHost] of INGRESS_ROUTES_BY_PORT) {
1534
+ if (port === INGRESS_HTTPS_PORT)
1535
+ continue;
1536
+ INGRESS_HTTP_SERVERS.set(port, bindIngressServer(Bun, port, byHost, `port ${port}`));
1537
+ // eslint-disable-next-line no-console
1538
+ console.log(`[ingress] http :${port} for ${[...byHost.keys()].join(", ")}`);
1539
+ }
1540
+ // ── HTTPS listener on INGRESS_HTTPS_PORT: SNI per certificated hostname.
1541
+ if (HTTPS_CERT_BY_HOST.size > 0)
1542
+ rebindHttpsListener(Bun);
1543
+ // Seed the resolver's names registry: ingress hostnames (fakes, TLS
1544
+ // proxies, dnsName(→ingress)) → bridge gateway, plus ingress-targeted
1545
+ // wildcards. Service-targeted wildcards wait for the post-container pass
1546
+ // (their containers aren't up yet). Dynamic ctx.dnsName calls extend this.
1547
+ await seedNamesRegistry({ servicesUp: false });
1548
+ }
1549
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1550
+ function requireBun() {
1551
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1552
+ const Bun = globalThis.Bun;
1553
+ if (!Bun?.serve) {
1554
+ throw new Error("ingress requires Bun.serve; the daemon must run under Bun (it does in-VM)");
1555
+ }
1556
+ return Bun;
1557
+ }
1558
+ /** Flatten {@link HTTPS_CERT_BY_HOST} into Bun's TLS-entry SNI array. */
1559
+ function tlsEntriesFromCerts() {
1560
+ return [...HTTPS_CERT_BY_HOST].map(([serverName, leaf]) => ({
1561
+ cert: leaf.cert,
1562
+ key: leaf.key,
1563
+ serverName,
1564
+ }));
1565
+ }
1566
+ /**
1567
+ * (Re)bind the :443 listener from the current cert table + route map. Bun's
1568
+ * TLS config is immutable per `Bun.serve`, so adding an SNI cert means
1569
+ * stopping the old listener and serving a fresh one — cheap (~1ms) and the
1570
+ * window is sub-millisecond. The route Map is the persistent module object,
1571
+ * so the new listener closes over the same table (later route additions need
1572
+ * no rebind). No-ops to a plain rebind when only routes changed.
1573
+ */
1574
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1575
+ function rebindHttpsListener(Bun) {
1576
+ const routes = INGRESS_ROUTES_BY_PORT.get(INGRESS_HTTPS_PORT) ?? new Map();
1577
+ INGRESS_ROUTES_BY_PORT.set(INGRESS_HTTPS_PORT, routes);
1578
+ const old = INGRESS_HTTPS_SERVERS.get(INGRESS_HTTPS_PORT);
1579
+ if (old) {
1580
+ try {
1581
+ old.stop(true);
1582
+ }
1583
+ catch (err) {
1584
+ // eslint-disable-next-line no-console
1585
+ console.warn("[ingress] failed to stop https listener for rebind:", err);
1586
+ }
1587
+ }
1588
+ const server = bindIngressServer(Bun, INGRESS_HTTPS_PORT, routes, `https :${INGRESS_HTTPS_PORT}`, tlsEntriesFromCerts());
1589
+ INGRESS_HTTPS_SERVERS.set(INGRESS_HTTPS_PORT, server);
1590
+ // eslint-disable-next-line no-console
1591
+ console.log(`[ingress] https :${INGRESS_HTTPS_PORT} for ${[...routes.keys()].join(", ")}`);
1592
+ }
1593
+ /** True if an exact or wildcard cert already covers `hostname` for SNI. */
1594
+ function certCovers(hostname) {
1595
+ if (HTTPS_CERT_BY_HOST.has(hostname))
1596
+ return true;
1597
+ for (const serverName of HTTPS_CERT_BY_HOST.keys()) {
1598
+ if (isWildcard(serverName) && hostname.endsWith(wildcardSuffix(serverName))) {
1599
+ return true;
1600
+ }
1601
+ }
1602
+ return false;
1603
+ }
1604
+ /**
1605
+ * Bind a runtime ingress route for one `tls: [{ hostname, port }]` entry on a
1606
+ * {@link RuntimeServiceSpec} — the runtime twin of a boot service's `tls`.
1607
+ * Mints a leaf cert (unless one already covers the hostname), stands up a
1608
+ * TLS-terminating reverse proxy at `https://<hostname>/` → `service:port`,
1609
+ * also serves plain `http://<hostname>/`, and points the hostname at the
1610
+ * daemon gateway in the resolver registry. Idempotent per hostname.
1611
+ *
1612
+ * Everything it mutates (the live route tables, the :443 cert table, the
1613
+ * names REGISTRY) is daemon-process state, so the binding forks with the
1614
+ * per-test snapshot exactly like fake state — a `dependsOn` child inherits
1615
+ * it, siblings forked from an earlier snapshot never see it.
1616
+ */
1617
+ async function bindRuntimeTls(hostname, service, port) {
1618
+ // Reuse the boot primitives for validation + lowercasing; throws on a
1619
+ // malformed hostname / upstream just like a boot `tls` would at load.
1620
+ const decl = makeProxyDecl(hostname, { service, port });
1621
+ const host = decl.hostname;
1622
+ if (!existsSync(CA_PATH) || !existsSync(CA_KEY_PATH)) {
1623
+ throw new Error(`runtime tls for ${JSON.stringify(host)} requires the in-VM root CA at ${CA_PATH}`);
1624
+ }
1625
+ const Bun = requireBun();
1626
+ const route = { kind: "proxy", service, port };
1627
+ // Plain HTTP on :80 (parity with boot `tls`, which serves both schemes).
1628
+ let httpRoutes = INGRESS_ROUTES_BY_PORT.get(INGRESS_HTTP_PORT);
1629
+ if (!httpRoutes) {
1630
+ httpRoutes = new Map();
1631
+ INGRESS_ROUTES_BY_PORT.set(INGRESS_HTTP_PORT, httpRoutes);
1632
+ }
1633
+ httpRoutes.set(host, route);
1634
+ if (!INGRESS_HTTP_SERVERS.has(INGRESS_HTTP_PORT)) {
1635
+ INGRESS_HTTP_SERVERS.set(INGRESS_HTTP_PORT, bindIngressServer(Bun, INGRESS_HTTP_PORT, httpRoutes, `port ${INGRESS_HTTP_PORT}`));
1636
+ }
1637
+ // HTTPS on :443. A new cert forces a listener rebind; an already-covered
1638
+ // hostname (exact dup or a boot wildcard) just needs the route entry.
1639
+ const needCert = !certCovers(host);
1640
+ if (needCert) {
1641
+ HTTPS_CERT_BY_HOST.set(host, await generateHostCert(host, [host]));
1642
+ }
1643
+ const httpsRoutes = INGRESS_ROUTES_BY_PORT.get(INGRESS_HTTPS_PORT) ?? new Map();
1644
+ INGRESS_ROUTES_BY_PORT.set(INGRESS_HTTPS_PORT, httpsRoutes);
1645
+ httpsRoutes.set(host, route);
1646
+ if (needCert || !INGRESS_HTTPS_SERVERS.has(INGRESS_HTTPS_PORT)) {
1647
+ rebindHttpsListener(Bun);
1648
+ }
1649
+ // Resolve the hostname to the daemon gateway (where :443/:80 listen).
1650
+ const gw = await bridgeGatewayIp();
1651
+ REGISTRY.hosts[host] = gw;
1652
+ await writeRegistry();
1653
+ // eslint-disable-next-line no-console
1654
+ console.log(`[ingress] runtime https ${host} -> ${service}:${port}`);
1655
+ }
1656
+ /**
1657
+ * Undo {@link bindRuntimeTls} for one hostname when its runtime service is
1658
+ * stopped: drop the route (so it 404s) and the registry entry. The cert is
1659
+ * left in the SNI table — harmless without a route, and removing it would
1660
+ * mean an avoidable :443 rebind.
1661
+ */
1662
+ async function unbindRuntimeTls(hostname) {
1663
+ const host = hostname.toLowerCase();
1664
+ INGRESS_ROUTES_BY_PORT.get(INGRESS_HTTP_PORT)?.delete(host);
1665
+ INGRESS_ROUTES_BY_PORT.get(INGRESS_HTTPS_PORT)?.delete(host);
1666
+ if (host in REGISTRY.hosts) {
1667
+ delete REGISTRY.hosts[host];
1668
+ await writeRegistry();
1669
+ }
1670
+ }
1671
+ /**
1672
+ * Spin up one Bun.serve listener bound to (port, optional TLS) that
1673
+ * dispatches every request to the matching Route by Host header.
1674
+ *
1675
+ * Shared by the HTTP and HTTPS branches. Also exports a `websocket`
1676
+ * handler so reverse-proxy targets can transparently bridge WS
1677
+ * upgrades through to their upstream service.
1678
+ */
1679
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1680
+ function bindIngressServer(
1681
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1682
+ Bun, port, byHost, listenerLabel, tlsEntries) {
1683
+ // A TLS listener terminates https; everything else is plain http. Used
1684
+ // to stamp X-Forwarded-Proto so upstreams that build absolute URLs or
1685
+ // redirect see the scheme the client actually used, not our http hop.
1686
+ const proto = tlsEntries ? "https" : "http";
1687
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1688
+ const opts = {
1689
+ port,
1690
+ hostname: "0.0.0.0",
1691
+ // Bun.serve defaults to a 10s idleTimeout, which kills any proxied
1692
+ // request whose upstream takes >10s to produce bytes — under parallel
1693
+ // test load that surfaced as "fetch failed"/"other side closed" on
1694
+ // deploy-archive uploads and ERR_EMPTY_RESPONSE in browser tests
1695
+ // ([Bun.serve]: request timed out after 10 seconds). Ingress fronts
1696
+ // arbitrarily slow app endpoints (deploys can legitimately take
1697
+ // minutes), so disable the idle timeout entirely; forked test VMs are
1698
+ // short-lived, leaked-connection risk is bounded by the fork.
1699
+ idleTimeout: 0,
1700
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1701
+ fetch: (req, server) => dispatchIngress(req, server, byHost, listenerLabel, proto),
1702
+ websocket: {
1703
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1704
+ async open(ws) {
1705
+ const data = ws.data;
1706
+ try {
1707
+ const upstream = new WebSocket(data.upstreamUrl);
1708
+ // ArrayBuffer so binary frames can be ws.send()'d to the
1709
+ // downstream client verbatim — Blob would need an extra
1710
+ // .arrayBuffer() round-trip on every message.
1711
+ upstream.binaryType = "arraybuffer";
1712
+ data.upstream = upstream;
1713
+ upstream.addEventListener("open", () => {
1714
+ for (const m of data.pending)
1715
+ upstream.send(m);
1716
+ data.pending = [];
1717
+ });
1718
+ upstream.addEventListener("message", (ev) => {
1719
+ try {
1720
+ ws.send(ev.data);
1721
+ }
1722
+ catch {
1723
+ /* client gone */
1724
+ }
1725
+ });
1726
+ upstream.addEventListener("close", (ev) => {
1727
+ try {
1728
+ ws.close(ev.code, ev.reason);
1729
+ }
1730
+ catch {
1731
+ /* already closed */
1732
+ }
1733
+ });
1734
+ upstream.addEventListener("error", () => {
1735
+ try {
1736
+ ws.close(1011, "upstream error");
1737
+ }
1738
+ catch {
1739
+ /* already closed */
1740
+ }
1741
+ });
1742
+ }
1743
+ catch (err) {
1744
+ // eslint-disable-next-line no-console
1745
+ console.warn(`[ingress] ws upstream open failed for ${data.upstreamUrl}:`, err);
1746
+ try {
1747
+ ws.close(1011, "upstream open failed");
1748
+ }
1749
+ catch {
1750
+ /* ignore */
1751
+ }
1752
+ }
1753
+ },
1754
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1755
+ message(ws, message) {
1756
+ const data = ws.data;
1757
+ const payload = typeof message === "string" ? message : new Uint8Array(message);
1758
+ if (data.upstream && data.upstream.readyState === WebSocket.OPEN) {
1759
+ data.upstream.send(payload);
1760
+ }
1761
+ else {
1762
+ // Buffer until the upstream finishes its handshake.
1763
+ data.pending.push(payload);
1764
+ }
1765
+ },
1766
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1767
+ close(ws, code, reason) {
1768
+ const data = ws.data;
1769
+ try {
1770
+ data.upstream?.close(code, reason);
1771
+ }
1772
+ catch {
1773
+ /* ignore */
1774
+ }
1775
+ },
1776
+ },
1777
+ };
1778
+ if (tlsEntries)
1779
+ opts.tls = tlsEntries;
1780
+ return Bun.serve(opts);
1781
+ }
1782
+ /**
1783
+ * Per-request dispatch shared by every ingress listener. Looks up the
1784
+ * Route by Host header (port stripped) and either:
1785
+ * - fake: invokes the handler, wraps thrown errors as 500;
1786
+ * - proxy: WebSocket upgrade → server.upgrade(); else reverse-proxy
1787
+ * to the upstream service over plain HTTP.
1788
+ */
1789
+ async function dispatchIngress(req,
1790
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1791
+ server, byHost, listenerLabel, proto) {
1792
+ const host = (req.headers.get("host") ?? "")
1793
+ .toLowerCase()
1794
+ .split(":")[0]
1795
+ .trim();
1796
+ const route = byHost.get(host);
1797
+ if (!route) {
1798
+ return new Response(`spectest-daemon: no ingress route bound to Host=${JSON.stringify(host)} on ${listenerLabel}\n`, { status: 404, headers: { "content-type": "text/plain" } });
1799
+ }
1800
+ // Answer CORS preflights at the ingress (see corsPreflightResponse) so a
1801
+ // cross-origin browser request carrying any header — Authorization,
1802
+ // Cache-Control, Pragma, … — isn't rejected by whatever the upstream happens
1803
+ // to list in Access-Control-Allow-Headers.
1804
+ if (isCorsPreflight(req))
1805
+ return corsPreflightResponse(req);
1806
+ if (route.kind === "fake") {
1807
+ try {
1808
+ const res = await route.fake.def.handler(req, route.fake.state, FAKE_CTX);
1809
+ return augmentCorsResponse(req, res);
1810
+ }
1811
+ catch (err) {
1812
+ const e = err;
1813
+ return new Response(`spectest-daemon: fake ${route.fake.def.name} threw: ${e?.message ?? String(err)}\n`, { status: 500, headers: { "content-type": "text/plain" } });
1814
+ }
1815
+ }
1816
+ const res = await proxyToService(req, server, route.service, route.port, listenerLabel, proto);
1817
+ return augmentCorsResponse(req, res);
1818
+ }
1819
+ /**
1820
+ * Reverse-proxy a request to `http://<service>:<port>` on
1821
+ * `spectest-net`. Handles plain HTTP/1.1 + 2 and WebSocket upgrades:
1822
+ *
1823
+ * - WS upgrade requests get routed through `server.upgrade()`, with
1824
+ * the upstream URL stashed on `ws.data`. The shared `websocket`
1825
+ * handler opens the upstream and bridges frames both ways.
1826
+ * - Plain requests pass through via `fetch()` with hop-by-hop
1827
+ * headers stripped; the response body is a ReadableStream returned
1828
+ * verbatim, so it streams back without buffering.
1829
+ *
1830
+ * `decompress: false` makes this a true byte-for-byte pass-through:
1831
+ * Bun's fetch otherwise auto-decompresses the upstream body, which would
1832
+ * leave us forwarding the original `Content-Encoding`/`Content-Length`
1833
+ * over a now-plaintext body — browsers then fail with
1834
+ * ERR_CONTENT_DECODING_FAILED or truncate on the stale length (the
1835
+ * WHATWG fetch footgun in whatwg/fetch#1729). Keeping the body encoded
1836
+ * means those headers still describe the bytes we send, and we relay the
1837
+ * client's `Accept-Encoding` upstream so the upstream picks the scheme.
1838
+ */
1839
+ async function proxyToService(req,
1840
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1841
+ server, service, port, listenerLabel, proto) {
1842
+ const url = new URL(req.url);
1843
+ const upstreamPath = `${url.pathname}${url.search}`;
1844
+ const upgrade = req.headers.get("upgrade")?.toLowerCase() ?? "";
1845
+ if (upgrade === "websocket") {
1846
+ const upstreamUrl = `ws://${await proxyUpstreamHost(service)}:${port}${upstreamPath}`;
1847
+ const wsData = {
1848
+ upstreamUrl,
1849
+ upstream: null,
1850
+ pending: [],
1851
+ };
1852
+ const ok = server.upgrade(req, { data: wsData });
1853
+ if (ok) {
1854
+ // Bun has already taken over the response — return a stub.
1855
+ return new Response(null, { status: 101 });
1856
+ }
1857
+ return new Response(`spectest-daemon: ws upgrade refused on ${listenerLabel}\n`, { status: 426, headers: { "content-type": "text/plain" } });
1858
+ }
1859
+ const fwdHeaders = new Headers();
1860
+ for (const [k, v] of req.headers) {
1861
+ if (HOP_BY_HOP_HEADERS.has(k.toLowerCase()))
1862
+ continue;
1863
+ fwdHeaders.append(k, v);
1864
+ }
1865
+ // Standard reverse-proxy provenance headers: the upstream sees the
1866
+ // public scheme/host it was reached through and the client's address,
1867
+ // even though we rewrite Host below to the service-net name.
1868
+ const clientIp = server.requestIP?.(req)?.address;
1869
+ const priorXff = req.headers.get("x-forwarded-for");
1870
+ const xff = clientIp ? (priorXff ? `${priorXff}, ${clientIp}` : clientIp) : priorXff;
1871
+ if (xff)
1872
+ fwdHeaders.set("x-forwarded-for", xff);
1873
+ fwdHeaders.set("x-forwarded-proto", proto);
1874
+ const publicHost = req.headers.get("host");
1875
+ if (publicHost)
1876
+ fwdHeaders.set("x-forwarded-host", publicHost);
1877
+ // Override Host so the upstream sees its own service-net name, not
1878
+ // the public hostname. Lets origin servers that vhost by Host header
1879
+ // continue to find the right virtual host.
1880
+ fwdHeaders.set("host", `${service}:${port}`);
1881
+ // Fresh connection per upstream request — never reuse a pooled
1882
+ // keep-alive conn. Upstreams with short idle timeouts (uvicorn defaults
1883
+ // to 5s) close pooled connections under Bun's fetch, and the next
1884
+ // request on the dead socket fails with "socket closed unexpectedly"
1885
+ // even though the service is healthy. In-VM connects to a peer
1886
+ // container are sub-ms, so per-request connects cost nothing at test
1887
+ // scale. `connection: close` makes the upstream tear down immediately;
1888
+ // `keepalive: false` on the fetch below keeps Bun from pooling its end.
1889
+ // (Both verified effective on Bun 1.3.14.)
1890
+ fwdHeaders.set("connection", "close");
1891
+ // Buffer bounded request bodies so a transient upstream connect failure
1892
+ // can be retried (a ReadableStream body is consumed by the first
1893
+ // attempt). Under heavy parallel-fork load an in-guest connect to a
1894
+ // peer container occasionally fails outright ("Unable to connect" on a
1895
+ // healthy upstream) — observed on deploy-tarball uploads to s3mock; a
1896
+ // bounded retry absorbs it. Bodies above the cap (or with unknown
1897
+ // length and a stream that exceeds it) keep streaming semantics and
1898
+ // simply don't retry.
1899
+ const RETRY_BODY_CAP = 128 * 1024 * 1024;
1900
+ const hasBody = req.method !== "GET" && req.method !== "HEAD";
1901
+ // Only bodies with a known, bounded length are buffered — an unknown
1902
+ // (chunked/streaming) length could be an endless client stream, which
1903
+ // must keep flowing through, not accumulate.
1904
+ const declaredLen = Number(req.headers.get("content-length") ?? NaN);
1905
+ let bufferedBody;
1906
+ if (hasBody && Number.isFinite(declaredLen) && declaredLen <= RETRY_BODY_CAP) {
1907
+ try {
1908
+ bufferedBody = await req.arrayBuffer();
1909
+ }
1910
+ catch {
1911
+ /* client aborted mid-upload; fall through, attempt will fail */
1912
+ }
1913
+ }
1914
+ const retryable = !hasBody || (bufferedBody !== undefined && bufferedBody.byteLength <= RETRY_BODY_CAP);
1915
+ const attempts = retryable ? 3 : 1;
1916
+ let lastErr;
1917
+ for (let attempt = 1; attempt <= attempts; attempt++) {
1918
+ // Resolve the upstream per attempt: a connect failure below drops the
1919
+ // cached IP, so a retry re-inspects the container.
1920
+ const upstreamUrl = `http://${await proxyUpstreamHost(service)}:${port}${upstreamPath}`;
1921
+ try {
1922
+ const upstreamReq = new Request(upstreamUrl, {
1923
+ method: req.method,
1924
+ headers: fwdHeaders,
1925
+ body: hasBody ? (bufferedBody ?? req.body) : undefined,
1926
+ redirect: "manual",
1927
+ });
1928
+ // decompress:false → forward the encoded body untouched (see fn doc).
1929
+ // keepalive:false → fresh connection per request (see fwdHeaders above).
1930
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1931
+ const upstreamRes = await NATIVE_FETCH(upstreamReq, {
1932
+ decompress: false,
1933
+ keepalive: false,
1934
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1935
+ });
1936
+ // Strip hop-by-hop response headers; let Bun set content-length / TE.
1937
+ const respHeaders = new Headers();
1938
+ for (const [k, v] of upstreamRes.headers) {
1939
+ if (HOP_BY_HOP_HEADERS.has(k.toLowerCase()))
1940
+ continue;
1941
+ respHeaders.append(k, v);
1942
+ }
1943
+ return new Response(upstreamRes.body, {
1944
+ status: upstreamRes.status,
1945
+ statusText: upstreamRes.statusText,
1946
+ headers: respHeaders,
1947
+ });
1948
+ }
1949
+ catch (err) {
1950
+ lastErr = err;
1951
+ // Retry connection-level failures: connect errors, plus a socket
1952
+ // that died before any response bytes ("socket closed unexpectedly",
1953
+ // ECONNRESET, hang-up) — under boot-time bursts an upstream accepts
1954
+ // and drops connections while still warming up. Pre-response
1955
+ // failures are the standard retry class for reverse proxies
1956
+ // (nginx's proxy_next_upstream error). Anything that produced a
1957
+ // response is never replayed.
1958
+ const msg = err?.message ?? String(err);
1959
+ const connectFailure = /unable to connect|connection refused|connect|typo in the url|socket closed|connection closed|econnreset|socket hang ?up|epipe/i.test(msg);
1960
+ if (!connectFailure || attempt === attempts)
1961
+ break;
1962
+ // The cached IP may be stale (container recreated) — re-resolve.
1963
+ PROXY_IP_CACHE.delete(service);
1964
+ // eslint-disable-next-line no-console
1965
+ console.warn(`[ingress] upstream ${service}:${port} connect failed (attempt ${attempt}/${attempts}), retrying: ${msg}`);
1966
+ await new Promise((r) => setTimeout(r, 250 * attempt));
1967
+ }
1968
+ }
1969
+ const e = lastErr;
1970
+ return new Response(`spectest-daemon: upstream ${service}:${port} unreachable: ${e?.message ?? String(lastErr)}\n`, { status: 502, headers: { "content-type": "text/plain" } });
1971
+ }
1972
+ /**
1973
+ * In-memory names registry, serialised to FAKES_REGISTRY_PATH for the
1974
+ * resolver. `startIngress` seeds it from LOWERED (the static
1975
+ * tls/hostnames/fakes/wildcard decls); `registerDnsName` mutates it live
1976
+ * when a test calls `ctx.dnsName`. It lives in daemon memory, so it forks
1977
+ * with the rest of the snapshot — a test's dynamic registration is
1978
+ * isolated to its own fork, exactly like fake state.
1979
+ */
1980
+ const REGISTRY = { hosts: {}, wildcards: [] };
1981
+ async function writeRegistry() {
1982
+ const body = JSON.stringify({
1983
+ hosts: REGISTRY.hosts,
1984
+ wildcards: REGISTRY.wildcards,
1985
+ updatedAt: Date.now(),
1986
+ });
1987
+ try {
1988
+ await fs.mkdir(path.dirname(FAKES_REGISTRY_PATH), { recursive: true });
1989
+ await fs.writeFile(FAKES_REGISTRY_PATH, body);
1990
+ }
1991
+ catch (err) {
1992
+ // Resolver gracefully degrades; just log.
1993
+ // eslint-disable-next-line no-console
1994
+ console.warn(`[names] failed to write registry at ${FAKES_REGISTRY_PATH}:`, err);
1995
+ }
1996
+ }
1997
+ /** `*.example.com` → `.example.com` — the suffix the resolver matches. */
1998
+ function wildcardSuffix(pattern) {
1999
+ return pattern.slice(1); // drop the leading "*"
2000
+ }
2001
+ /** A service container's IP on spectest-net. `null` if the container isn't
2002
+ * up or isn't attached to the network yet. */
2003
+ async function serviceContainerIp(name) {
2004
+ const out = await docker([
2005
+ "inspect",
2006
+ "--format",
2007
+ `{{(index .NetworkSettings.Networks "${NETWORK_NAME}").IPAddress}}`,
2008
+ name,
2009
+ ], 10_000);
2010
+ if (out.code !== 0)
2011
+ return null;
2012
+ const ip = out.stdout.trim();
2013
+ return ip.length > 0 && ip !== "<no value>" ? ip : null;
2014
+ }
2015
+ /** Cache of service container IPs for the ingress proxy, so the proxy hot
2016
+ * path doesn't depend on in-guest DNS: name resolution goes through
2017
+ * spectest-resolver — a single-threaded Bun process that can be starved
2018
+ * when the guest's vCPUs are saturated (observed as ~30s of
2019
+ * "tarballs-s3:9090 unreachable" 502s during parallel deploy tests while
2020
+ * the container was healthy). Populated lazily via `docker inspect`
2021
+ * (local socket, no DNS); the proxy drops an entry on connect failure so
2022
+ * a recreated container re-resolves on retry. */
2023
+ const PROXY_IP_CACHE = new Map();
2024
+ async function proxyUpstreamHost(service) {
2025
+ const cached = PROXY_IP_CACHE.get(service);
2026
+ if (cached)
2027
+ return cached;
2028
+ const ip = await serviceContainerIp(service).catch(() => null);
2029
+ if (ip) {
2030
+ PROXY_IP_CACHE.set(service, ip);
2031
+ return ip;
2032
+ }
2033
+ // Fall back to the name (resolver / docker DNS) — e.g. a target that
2034
+ // isn't a docker container on spectest-net.
2035
+ return service;
2036
+ }
2037
+ /** Resolve a DnsTarget to a concrete IP: ingress → bridge gateway, service
2038
+ * → that container's IP. Throws if a service target has no IP yet. */
2039
+ async function resolveDnsTarget(target) {
2040
+ if ("ingress" in target)
2041
+ return bridgeGatewayIp();
2042
+ const ip = await serviceContainerIp(target.service);
2043
+ if (!ip) {
2044
+ throw new Error(`dnsName target service ${JSON.stringify(target.service)} has no IP on ${NETWORK_NAME} (is it a running service?)`);
2045
+ }
2046
+ return ip;
2047
+ }
2048
+ /**
2049
+ * Seed REGISTRY from the static lowered decls and write it. Run twice per
2050
+ * /bootstrap: once from startIngress (`servicesUp: false`) so ingress
2051
+ * hostnames answer during container startup, then once after every
2052
+ * container is up (`servicesUp: true`) so service-targeted wildcards (e.g.
2053
+ * k3s `ingressDomains`) can resolve their container IPs. The post-container
2054
+ * pass is what's captured into the warm template.
2055
+ */
2056
+ async function seedNamesRegistry(opts) {
2057
+ const gw = await bridgeGatewayIp();
2058
+ REGISTRY.hosts = {};
2059
+ REGISTRY.wildcards = [];
2060
+ for (const h of LOWERED.ingressHosts)
2061
+ REGISTRY.hosts[h] = gw;
2062
+ for (const w of LOWERED.wildcards) {
2063
+ if ("ingress" in w.target) {
2064
+ REGISTRY.wildcards.push({ suffix: wildcardSuffix(w.pattern), ip: gw });
2065
+ continue;
2066
+ }
2067
+ // Service target — only resolvable once the container has an IP.
2068
+ if (!opts.servicesUp)
2069
+ continue;
2070
+ const ip = await serviceContainerIp(w.target.service);
2071
+ if (ip) {
2072
+ REGISTRY.wildcards.push({ suffix: wildcardSuffix(w.pattern), ip });
2073
+ }
2074
+ else {
2075
+ // eslint-disable-next-line no-console
2076
+ console.warn(`[names] wildcard ${w.pattern}: service ${JSON.stringify(w.target.service)} has no IP on ${NETWORK_NAME}`);
2077
+ }
2078
+ }
2079
+ await writeRegistry();
2080
+ }
2081
+ /**
2082
+ * Register a hostname at runtime — the implementation behind `ctx.dnsName`.
2083
+ * Validates via the same `dnsName` primitive the static path uses, resolves
2084
+ * the target to an IP, and updates + persists the registry. Exact names go
2085
+ * in `hosts`; `*.suffix` wildcards in `wildcards`. The resolver re-reads on
2086
+ * the next query (it watches the file's mtime), so the name is live
2087
+ * immediately — answered for VM-host/test/browser code and for peer
2088
+ * containers (Docker forwards unknown names to the host resolver). It does
2089
+ * NOT land in any container's /etc/hosts.
2090
+ */
2091
+ async function registerDnsName(hostname, target) {
2092
+ const resv = reserveEvent();
2093
+ // Reuse the primitive purely for validation + lowercasing.
2094
+ const decl = makeDnsDecl(hostname, target);
2095
+ const ip = await resolveDnsTarget(target);
2096
+ if (isWildcard(decl.hostname)) {
2097
+ const suffix = wildcardSuffix(decl.hostname);
2098
+ REGISTRY.wildcards = REGISTRY.wildcards.filter((w) => w.suffix !== suffix);
2099
+ REGISTRY.wildcards.push({ suffix, ip });
2100
+ }
2101
+ else {
2102
+ REGISTRY.hosts[decl.hostname] = ip;
2103
+ }
2104
+ await writeRegistry();
2105
+ recordEnv({ op: "dnsName", hostname: decl.hostname, ip, durationMs: 0 }, resv);
2106
+ }
2107
+ /**
2108
+ * Mint a leaf certificate from the in-VM root CA and hand back the PEMs
2109
+ * — the implementation behind `ctx.certificate`.
2110
+ *
2111
+ * The value-returning counterpart to the `certificates` service field:
2112
+ * use that to hand a cert to a container at boot, use this when a *test*
2113
+ * needs the material as data — loading it into a Kubernetes Secret,
2114
+ * posting it to a control-plane API, driving a client-cert handshake.
2115
+ * The returned `ca` is the same root every service and `ctx.fetch` /
2116
+ * `ctx.browser()` already trust, so a server configured with these PEMs
2117
+ * verifies cleanly with no `rejectUnauthorized: false`.
2118
+ */
2119
+ async function mintCertificate(hostnames) {
2120
+ const resv = reserveEvent();
2121
+ const t = Date.now();
2122
+ if (!Array.isArray(hostnames) || hostnames.length === 0) {
2123
+ throw new Error("ctx.certificate(hostnames): at least one hostname is required");
2124
+ }
2125
+ if (!existsSync(CA_PATH) || !existsSync(CA_KEY_PATH)) {
2126
+ throw new Error(`ctx.certificate(): the in-VM root CA is missing at ${CA_PATH}`);
2127
+ }
2128
+ const { cert, key } = await generateHostCert("ctx", hostnames);
2129
+ const ca = await fs.readFile(CA_PATH, "utf8");
2130
+ recordEnv({ op: "certificate", hostnames, durationMs: Date.now() - t }, resv);
2131
+ return { cert, key, ca };
2132
+ }
2133
+ // ────────────────────────────────────────────────────────────────────────
2134
+ // Runtime services — containers started after bootstrap (from a test, an
2135
+ // eval, project setup, or a fake handler reacting to the app under test).
2136
+ //
2137
+ // A runtime service is a *real machine on the network*: it joins
2138
+ // spectest-net with its own IP and is reached directly by name/IP, not
2139
+ // through the daemon's HTTP ingress. The same helpers bootstrap uses
2140
+ // (prepareServiceImage → runContainer → waitForReady) drive it, so it gets
2141
+ // the same image cache, CA trust, and ready-probing. Because it lives in
2142
+ // dockerd, it's captured by the per-test post-state snapshot exactly like
2143
+ // the boot services — a `dependsOn` child inherits the live container while
2144
+ // siblings (which fork from the parent's earlier snapshot) never see it.
2145
+ //
2146
+ // Tracked here only for in-VM bookkeeping (failure log capture, teardown);
2147
+ // the map forks with daemon memory, so each fork sees the services it (or
2148
+ // its ancestors) actually started.
2149
+ // ────────────────────────────────────────────────────────────────────────
2150
+ const RUNTIME_SERVICES = new Map();
2151
+ // A runtime service spec is a ServiceConfig (minus tls/dependsOn) + a name;
2152
+ // the orchestration helpers want a NamedService, which is the same shape.
2153
+ function specToNamedService(spec) {
2154
+ const { name, ...rest } = spec;
2155
+ return { name, ...rest };
2156
+ }
2157
+ /** Implementation behind `ctx.startService` / a fake's `ctx.startService`.
2158
+ * Prepares the image (pulling on first use through the host cache), runs
2159
+ * the container on spectest-net, and waits for its readyCheck. Returns the
2160
+ * container's name + IP. */
2161
+ async function startRuntimeService(spec) {
2162
+ if (!spec.name || spec.name.length === 0) {
2163
+ throw new Error("startService: `name` is required");
2164
+ }
2165
+ const t0 = Date.now();
2166
+ const resv = reserveEvent();
2167
+ const svc = specToNamedService(spec);
2168
+ const aliases = (spec.hostnames ?? []).map((h) => h.toLowerCase());
2169
+ const imageRef = svc.image.type === "registry" ? svc.image.reference : "(dockerfile)";
2170
+ try {
2171
+ const { tag } = await prepareServiceImage(svc);
2172
+ const flags = [
2173
+ ...(await ensureVolumes(svc)),
2174
+ ...(await ensureFiles(svc)),
2175
+ ...(await ensureCertificates(svc)),
2176
+ ];
2177
+ await runContainer(svc, tag, flags, aliases);
2178
+ await waitForReady(svc);
2179
+ const ip = (await serviceContainerIp(svc.name)) ?? "";
2180
+ // `tls` is the runtime twin of a boot service's: stand up a
2181
+ // TLS-terminating reverse proxy at https://<hostname>/ → this container.
2182
+ for (const entry of svc.tls ?? []) {
2183
+ await bindRuntimeTls(entry.hostname, svc.name, entry.port);
2184
+ }
2185
+ RUNTIME_SERVICES.set(svc.name, svc);
2186
+ recordEnv({
2187
+ op: "startService",
2188
+ service: svc.name,
2189
+ image: imageRef,
2190
+ ip,
2191
+ durationMs: Date.now() - t0,
2192
+ }, resv);
2193
+ return { name: svc.name, ip };
2194
+ }
2195
+ catch (err) {
2196
+ recordEnv({
2197
+ op: "startService",
2198
+ service: svc.name,
2199
+ image: imageRef,
2200
+ durationMs: Date.now() - t0,
2201
+ error: errMessage(err),
2202
+ }, resv);
2203
+ throw err;
2204
+ }
2205
+ }
2206
+ /** Implementation behind `ctx.stopService`. Removes the container and drops
2207
+ * it from the runtime registry. No-op (rc ignored) if it's already gone. */
2208
+ async function stopRuntimeService(name) {
2209
+ const t0 = Date.now();
2210
+ const resv = reserveEvent();
2211
+ const svc = RUNTIME_SERVICES.get(name);
2212
+ await docker(["rm", "-f", name], 30_000);
2213
+ RUNTIME_SERVICES.delete(name);
2214
+ for (const entry of svc?.tls ?? [])
2215
+ await unbindRuntimeTls(entry.hostname);
2216
+ recordEnv({ op: "stopService", service: name, durationMs: Date.now() - t0 }, resv);
2217
+ }
2218
+ /** The runtime environment-control handle handed to fakes (3rd handler arg /
2219
+ * `ctx` in `helpers`). The same primitives tests get on `ctx`; module-level
2220
+ * because none of them depend on a running test. */
2221
+ const FAKE_CTX = {
2222
+ startService: startRuntimeService,
2223
+ stopService: stopRuntimeService,
2224
+ dnsName: registerDnsName,
2225
+ certificate: mintCertificate,
2226
+ };
2227
+ /** Build (or fetch from cache) the helpers record for a fake — the
2228
+ * value that ends up at `ctx.fakes.<name>`. Defaults to `{}` (a fake
2229
+ * with no `helpers` exposes nothing — tests never touch private state
2230
+ * directly). Returns a tracking proxy (see `trackFakeHelpers`) so helper
2231
+ * calls land in the test timeline. */
2232
+ async function ensureFakeHelpers(name) {
2233
+ const fake = FAKES.get(name);
2234
+ if (!fake)
2235
+ throw new Error(`fake ${JSON.stringify(name)} is not loaded`);
2236
+ if (fake.trackedHelpers)
2237
+ return fake.trackedHelpers;
2238
+ fake.helpers = fake.def.helpers
2239
+ ? (await fake.def.helpers({
2240
+ name,
2241
+ state: fake.state,
2242
+ ctx: FAKE_CTX,
2243
+ }))
2244
+ : {};
2245
+ fake.trackedHelpers = trackFakeHelpers(name, fake.helpers);
2246
+ return fake.trackedHelpers;
2247
+ }
2248
+ /** Wrap a fake's helpers so each call becomes a recorded `fake` event
2249
+ * and its return value is `wrap()`ped for assertion provenance. Helpers
2250
+ * are functions that read/mutate the fake's private state via closure;
2251
+ * tests only ever see what those functions return. The proxy is built
2252
+ * once and shared across tests; it consults the recorder at call time,
2253
+ * so it's a transparent no-op when nothing is recording (eval / project
2254
+ * setup).
2255
+ *
2256
+ * Only own function properties are intercepted — inherited members
2257
+ * (`toString`, etc.), symbols, and any stray non-function property pass
2258
+ * straight through untouched. */
2259
+ function trackFakeHelpers(fakeName, helpers) {
2260
+ return new Proxy(helpers, {
2261
+ get(target, prop, receiver) {
2262
+ if (typeof prop === "symbol")
2263
+ return Reflect.get(target, prop, receiver);
2264
+ const desc = Object.getOwnPropertyDescriptor(target, prop);
2265
+ if (!desc || typeof desc.value !== "function") {
2266
+ return Reflect.get(target, prop, receiver);
2267
+ }
2268
+ const fn = desc.value;
2269
+ const member = String(prop);
2270
+ return (...args) => invokeFakeHelper(fakeName, member, fn, target, args);
2271
+ },
2272
+ });
2273
+ }
2274
+ /** Invoke a fake helper function, recording a `fake` event and wrapping
2275
+ * the return value. Handles both sync and async helpers, and records an
2276
+ * error event (then rethrows) if the helper throws. */
2277
+ function invokeFakeHelper(fakeName, member, fn, thisArg, args) {
2278
+ const t = Date.now();
2279
+ const resv = reserveEvent();
2280
+ const safeArgs = args.map((a) => safeSerialize(a));
2281
+ const recordResult = (value) => {
2282
+ const seq = recordFake({
2283
+ fake: fakeName,
2284
+ member,
2285
+ args: safeArgs,
2286
+ result: safeSerialize(value),
2287
+ durationMs: Date.now() - t,
2288
+ }, resv);
2289
+ return wrap(value, seq);
2290
+ };
2291
+ const recordError = (err) => {
2292
+ recordFake({
2293
+ fake: fakeName,
2294
+ member,
2295
+ args: safeArgs,
2296
+ durationMs: Date.now() - t,
2297
+ error: errMessage(err),
2298
+ }, resv);
2299
+ };
2300
+ let result;
2301
+ try {
2302
+ result = fn.apply(thisArg, args);
2303
+ }
2304
+ catch (err) {
2305
+ recordError(err);
2306
+ throw err;
2307
+ }
2308
+ if (result instanceof Promise) {
2309
+ return result.then(recordResult, (err) => {
2310
+ recordError(err);
2311
+ throw err;
2312
+ });
2313
+ }
2314
+ return recordResult(result);
2315
+ }
2316
+ function errMessage(err) {
2317
+ return err?.message ?? String(err);
2318
+ }
2319
+ /** Build the `fakes` map exposed on the test/eval context. Includes
2320
+ * every loaded fake; helpers are constructed lazily but we eagerly
2321
+ * materialise them here so a test can just read `ctx.fakes.x.y`. */
2322
+ async function buildFakeHandles() {
2323
+ const handles = {};
2324
+ for (const name of FAKES.keys()) {
2325
+ handles[name] = await ensureFakeHelpers(name);
2326
+ }
2327
+ return handles;
2328
+ }
2329
+ function serviceTotalMs(s) {
2330
+ return s.prepMs + (s.runMs ?? 0) + (s.readyMs ?? 0) + (s.setupMs ?? 0);
2331
+ }
2332
+ // Logged to the daemon journal (also folded into the /bootstrap response,
2333
+ // which the control plane logs). One compact line per service plus the
2334
+ // slowest BuildKit steps, so a slow cold start is profileable without
2335
+ // dumping the full build output.
2336
+ function logBootstrapTimings(t) {
2337
+ for (const s of t.services) {
2338
+ const parts = [`prep=${s.prepMs}ms(${s.kind})`];
2339
+ if (s.runMs != null)
2340
+ parts.push(`run=${s.runMs}ms`);
2341
+ if (s.readyMs != null)
2342
+ parts.push(`ready=${s.readyMs}ms`);
2343
+ if (s.setupMs)
2344
+ parts.push(`setup=${s.setupMs}ms`);
2345
+ console.log(`[bootstrap] ${s.name}: ${parts.join(" ")}`);
2346
+ if (s.buildSteps && s.buildSteps.length) {
2347
+ const top = s.buildSteps
2348
+ .map((x) => `${x.cached ? "cached" : x.secs.toFixed(1) + "s"} ${x.name}`)
2349
+ .join(" | ");
2350
+ console.log(`[bootstrap] ${s.name} build steps: ${top}`);
2351
+ }
2352
+ }
2353
+ console.log(`[bootstrap] total ${t.totalMs}ms across ${t.services.length} service(s)`);
2354
+ }
2355
+ async function bootstrap() {
2356
+ // Tee everything this bootstrap logs — ours and every service `setup`
2357
+ // hook's — into the boot log, so GET /boot-log can explain a slow or
2358
+ // finished boot. Restored in the finally at the end.
2359
+ const restoreBootLog = captureConsole(BOOT_LOG_SINK);
2360
+ try {
2361
+ return await bootstrapInner();
2362
+ }
2363
+ finally {
2364
+ restoreBootLog();
2365
+ }
2366
+ }
2367
+ async function bootstrapInner() {
2368
+ const bootStart = Date.now();
2369
+ const cfg = requireLoaded().project.environment;
2370
+ const services = namedServices(cfg);
2371
+ const timings = new Map();
2372
+ progressInit(services);
2373
+ // Build dedup is only valid within one workspace generation — a fresh
2374
+ // bootstrap may follow a workspace re-upload with the same dockerfile
2375
+ // text but different build-context content.
2376
+ BUILD_DEDUP.clear();
2377
+ // Network create is independent of the workspace-side prep, so run
2378
+ // them concurrently. .dockerignore only blocks `docker build`s — pulls
2379
+ // wouldn't need it — but the writes are sub-millisecond so we just
2380
+ // gate image prep behind both.
2381
+ await Promise.all([
2382
+ ensureNetwork(),
2383
+ (async () => {
2384
+ await fs.mkdir(WORKSPACE, { recursive: true });
2385
+ // Read the project's own .dockerignore BEFORE we consider writing
2386
+ // one — every per-service ignore composes on top of it.
2387
+ PROJECT_DOCKERIGNORE = await readProjectDockerignore();
2388
+ if (PROJECT_DOCKERIGNORE !== null) {
2389
+ console.log("[bootstrap] using the project's .dockerignore for every build context");
2390
+ // Never overwrite it. BuildKit reads the per-service
2391
+ // `<Dockerfile>.dockerignore` (which already folds this file in),
2392
+ // and the legacy builder reads the project's file directly —
2393
+ // which is what the project asked for. Clobbering it also broke
2394
+ // any in-env tooling that reads it.
2395
+ return;
2396
+ }
2397
+ await fs.writeFile(path.join(WORKSPACE, ".dockerignore"), unionDockerignore(services));
2398
+ })(),
2399
+ ]);
2400
+ // Image prep is DECOUPLED from container start: every service's image
2401
+ // prep kicks off now, independent of `dependsOn`, and each service's
2402
+ // container start (in startServices below) gates on (its OWN image ready)
2403
+ // AND (its deps up) — there is no whole-graph barrier. So a service whose
2404
+ // image is pulled and whose deps are up starts immediately; it never sits
2405
+ // at "image ready" waiting for an unrelated slow build elsewhere.
2406
+ //
2407
+ // Prep concurrency: registry pulls always run in parallel (network-bound,
2408
+ // low VM RAM). Dockerfile builds parallelize *only* when the host
2409
+ // buildkitd is in play — there the build executes host-side under runc, so
2410
+ // N concurrent builds don't touch the VM's memory ceiling. When we fall
2411
+ // back to the in-VM builder, two or more concurrent builds routinely OOM a
2412
+ // single VM on monorepos with parallel pnpm/npm installs (each install
2413
+ // fans out to ~16 fetchers + lifecycle workers, ~70 MB/process), so we
2414
+ // serialize that case behind a FIFO chain — but only the in-VM builds
2415
+ // serialize; pulls and starts run freely alongside them. The remote-builder
2416
+ // probe is memoized, so this up-front call is free; skip it with no builds.
2417
+ const tags = new Map();
2418
+ const builds = services.filter((s) => s.image.type === "dockerfile");
2419
+ const buildsRunHostSide = builds.length > 0 && (await ensureRemoteBuilder());
2420
+ // A promise chain is a fair FIFO mutex: when builds run in-VM, each build
2421
+ // waits for the previous to settle. Pulls and host-side builds bypass it.
2422
+ let inVmBuildChain = Promise.resolve();
2423
+ const prepImage = (svc) => {
2424
+ // Bootstrap is the only dedup scope: all its builds share one
2425
+ // /workspace generation (see prepareServiceImage).
2426
+ const run = () => prepareServiceImage(svc, { dedup: true });
2427
+ if (svc.image.type === "dockerfile" && !buildsRunHostSide) {
2428
+ const next = inVmBuildChain.then(run, run);
2429
+ // Keep the chain moving even if a build throws; the chain itself never
2430
+ // rejects (the per-service prep promise below is what surfaces errors).
2431
+ inVmBuildChain = next.then(() => undefined, () => undefined);
2432
+ return next;
2433
+ }
2434
+ return run();
2435
+ };
2436
+ const prep = new Map();
2437
+ for (const svc of services) {
2438
+ const p = (async () => {
2439
+ const t0 = Date.now();
2440
+ const { tag, buildSteps } = await prepImage(svc);
2441
+ progressService(svc.name, { status: "prepared", detail: undefined });
2442
+ tags.set(svc.name, tag);
2443
+ timings.set(svc.name, {
2444
+ name: svc.name,
2445
+ kind: svc.image.type === "registry" ? "pull" : "build",
2446
+ prepMs: Date.now() - t0,
2447
+ buildSteps,
2448
+ });
2449
+ })();
2450
+ // A dependent whose dep fails aborts before it awaits this prep, which
2451
+ // would leave the prep promise unobserved. Attach a no-op handler so a
2452
+ // late rejection can't crash the daemon; startOne still re-throws it for
2453
+ // services that do reach their await.
2454
+ p.catch(() => undefined);
2455
+ prep.set(svc.name, p);
2456
+ }
2457
+ // Ingress (fakes + service-tls proxies) comes up BEFORE services so
2458
+ // that any service that calls a fake URL during its own startup
2459
+ // probe finds it answering. Service-tls proxies will return 502
2460
+ // until their upstream containers start, but no one is hitting
2461
+ // https://<svc>.test/ during bootstrap so that's harmless. The
2462
+ // bridge gateway IP is set on `network create` — independent of
2463
+ // any container being up — so we don't need services to determine
2464
+ // the listener address.
2465
+ await startIngress();
2466
+ progressPhase("Starting services");
2467
+ // Container start + ready probe driven by the dependsOn DAG: each
2468
+ // service starts the moment its own dependencies finish run→probe→setup,
2469
+ // instead of waiting for a whole topological level to clear. We chain
2470
+ // run→probe→setup per service so a dependent sees the post-setup state
2471
+ // of its deps (a database with its schema applied, a k3s cluster with
2472
+ // its ingress controller already running) — but an unrelated slow probe
2473
+ // no longer holds back a branch that's ready to go.
2474
+ await startServices(services, async (svc) => {
2475
+ // Gate on our OWN image being ready. startServices already gated on our
2476
+ // deps; this adds the image edge. The two compose: we run the moment
2477
+ // both are satisfied, with no whole-graph barrier between them.
2478
+ await prep.get(svc.name);
2479
+ const flags = [
2480
+ ...(await ensureVolumes(svc)),
2481
+ ...(await ensureFiles(svc)),
2482
+ ...(await ensureCertificates(svc)),
2483
+ ];
2484
+ const tag = tags.get(svc.name);
2485
+ if (!tag)
2486
+ throw new Error(`internal: no image tag for ${svc.name}`);
2487
+ const tRun = Date.now();
2488
+ progressService(svc.name, { status: "starting", detail: undefined });
2489
+ await runContainer(svc, tag, flags);
2490
+ const tReady = Date.now();
2491
+ progressService(svc.name, { status: "probing", detail: "ready check" });
2492
+ await waitForReady(svc);
2493
+ const tSetup = Date.now();
2494
+ if (svc.setup) {
2495
+ progressService(svc.name, { status: "probing", detail: "running setup" });
2496
+ const helpers = await ensureHelpers(svc.name, svc);
2497
+ await svc.setup({ name: svc.name, helpers, ...componentContext() });
2498
+ }
2499
+ progressService(svc.name, { status: "ready", detail: undefined });
2500
+ const ti = timings.get(svc.name);
2501
+ if (ti) {
2502
+ ti.runMs = tReady - tRun;
2503
+ ti.readyMs = tSetup - tReady;
2504
+ ti.setupMs = svc.setup ? Date.now() - tSetup : 0;
2505
+ }
2506
+ });
2507
+ // Containers now have IPs — re-seed so service-targeted wildcards (e.g.
2508
+ // k3s ingressDomains → the cluster container) resolve. Captured into the
2509
+ // warm template, so warm starts inherit the resolved entries.
2510
+ await seedNamesRegistry({ servicesUp: true });
2511
+ // Browser pre-warm DISABLED (2026-06-08). We used to pre-open one view
2512
+ // into the pool (browser.ts VIEW_POOL) here so every fork inherited a
2513
+ // live renderer and the first ctx.browser() skipped the ~1.2-1.5s spawn.
2514
+ // But a renderer spawned BEFORE the snapshot and restored in a fork holds
2515
+ // stale DNS state: its first navigate to an ingress host fails
2516
+ // `net::ERR_NAME_NOT_RESOLVED` even though getaddrinfo/fetch resolve fine
2517
+ // (the --disable-features=AsyncDns flag doesn't save the pooled view). A
2518
+ // view created fresh AFTER the fork (openBrowser → createView, since the
2519
+ // pool is now empty) spawns a post-restore renderer with correct DNS. The
2520
+ // tradeoff is the per-test spawn cost is back on the browser path; we
2521
+ // accept it to keep the suite's browser-rooted DAGs working. See
2522
+ // browser.ts:213 (the long-standing intermittent NAME_NOT_RESOLVED) and
2523
+ // the clocksource-regression notes. Re-enabling requires fixing the
2524
+ // restored-renderer DNS state, not just re-adding the prewarm call.
2525
+ // NOTE: persistent sessions (ctx.browser/ctx.mobile keep one live view
2526
+ // across tests, so restored forks navigate on a pre-snapshot renderer
2527
+ // routinely) hit the same bug head-on; browser.ts handles it there by
2528
+ // rebuilding the view in the same Chrome and retrying the navigation
2529
+ // (`rebuildView`) — profile state survives, so auth carries over. That
2530
+ // recovery is scoped to inherited-navigation failures and does NOT make
2531
+ // the about:blank prewarm pool safe to re-enable.
2532
+ const result = {
2533
+ totalMs: Date.now() - bootStart,
2534
+ services: [...timings.values()].sort((a, b) => serviceTotalMs(b) - serviceTotalMs(a)),
2535
+ };
2536
+ progressDone();
2537
+ logBootstrapTimings(result);
2538
+ return result;
2539
+ }
2540
+ /**
2541
+ * Run the loaded project's `setup` hook, if any. Called by the control
2542
+ * plane once between /bootstrap and the warm-template snapshot, so the
2543
+ * effects (seeded DB rows, initial pods, fixture files) are captured
2544
+ * exactly once and inherited by every later snapshot/fork.
2545
+ *
2546
+ * Unlike test runs, this is NOT instrumented — no recorder, no event
2547
+ * timeline, no timeout from the test runner. Setup failures abort the
2548
+ * env bring-up; the control plane surfaces them as a start failure.
2549
+ */
2550
+ async function runProjectSetup() {
2551
+ // Same tee as bootstrap: a project `setup` hook's console output is
2552
+ // part of explaining the boot, not something to lose on success.
2553
+ const restoreBootLog = captureConsole(BOOT_LOG_SINK);
2554
+ try {
2555
+ return await runProjectSetupInner();
2556
+ }
2557
+ finally {
2558
+ restoreBootLog();
2559
+ }
2560
+ }
2561
+ async function runProjectSetupInner() {
2562
+ const proj = requireLoaded().project;
2563
+ if (!proj.setup)
2564
+ return { ran: false, durationMs: 0 };
2565
+ const start = Date.now();
2566
+ // Build the same `svc` handles tests see, so setup and tests share
2567
+ // helper instances (e.g. a Bun.SQL pool created here is reused later).
2568
+ const svc = (await buildServiceHandles(proj.environment));
2569
+ const fakes = await buildFakeHandles();
2570
+ // Install the fetch wrapper for the duration of setup so `ctx.fetch` (and any
2571
+ // client routed through `globalThis.fetch`) returns a wrapped Response, same
2572
+ // as in a test. No recorder is active here, so it wraps without provenance —
2573
+ // but the wrapped type stays honest at runtime (`.unwrap()` works).
2574
+ const restoreFetch = installFetchWrapper();
2575
+ const ctx = {
2576
+ fetch: globalThis.fetch,
2577
+ exec: execInServiceWrapped,
2578
+ svc,
2579
+ fakes,
2580
+ dnsName: registerDnsName,
2581
+ certificate: mintCertificate,
2582
+ startService: startRuntimeService,
2583
+ stopService: stopRuntimeService,
2584
+ };
2585
+ try {
2586
+ await proj.setup(ctx);
2587
+ }
2588
+ finally {
2589
+ restoreFetch();
2590
+ }
2591
+ return { ran: true, durationMs: Date.now() - start };
2592
+ }
2593
+ // ────────────────────────────────────────────────────────────────────────
2594
+ // Replay bundles (rrweb sessions → gzipped side-channel, pulled in chunks)
2595
+ // ────────────────────────────────────────────────────────────────────────
2596
+ //
2597
+ // `RunResult.browserSessions` never rides the `/run` reply: the vm-agent
2598
+ // caps proxied daemon responses at 16 MB and a browser-heavy case's
2599
+ // recording is tens of MB of JSON (see replay-bundle.ts for the whole
2600
+ // story). Instead the /run handler encodes the sessions into a gzipped,
2601
+ // asset-deduplicated bundle, parks it here keyed by case id, and replies
2602
+ // with a tiny `replay: { bytes }` ref; the control plane then pulls the
2603
+ // bundle via `POST /replay-chunk` in base64 chunks sized under the cap,
2604
+ // before it tears the fork down.
2605
+ //
2606
+ // The map is module memory, so it FORKS with the snapshot (the post-test
2607
+ // snapshot is captured before the control plane fetches). The size cap
2608
+ // keeps a long handoff chain from accreting every ancestor's (gzipped)
2609
+ // bundle in guest RAM — entries older than the last few are always
2610
+ // already-fetched leftovers frozen into some snapshot.
2611
+ const REPLAY_BUNDLES = new Map();
2612
+ const REPLAY_BUNDLES_MAX = 8;
2613
+ function stashReplayBundle(caseId, gz) {
2614
+ // Re-insert to refresh recency (Map iterates in insertion order).
2615
+ REPLAY_BUNDLES.delete(caseId);
2616
+ REPLAY_BUNDLES.set(caseId, gz);
2617
+ while (REPLAY_BUNDLES.size > REPLAY_BUNDLES_MAX) {
2618
+ const oldest = REPLAY_BUNDLES.keys().next().value;
2619
+ if (oldest === undefined)
2620
+ break;
2621
+ REPLAY_BUNDLES.delete(oldest);
2622
+ }
2623
+ }
2624
+ // ────────────────────────────────────────────────────────────────────────
2625
+ // Cumulative service logs (per-case deltas → S3, reconstructed on the web)
2626
+ // ────────────────────────────────────────────────────────────────────────
2627
+ //
2628
+ // A child test runs in a fork restored from its parent's memory+filesystem
2629
+ // snapshot, so `docker logs <svc>` in the child already contains the
2630
+ // parent's entire history plus the child's own output — logs are
2631
+ // inherently cumulative along each branch. Rather than store the (growing,
2632
+ // redundant) full log at every case, each case stores only its DELTA (the
2633
+ // lines it added), and the dashboard reconstructs a branch's full log by
2634
+ // concatenating deltas along the ancestor chain.
2635
+ //
2636
+ // The load-bearing trick: `LOG_MARKERS` lives in module memory, so it
2637
+ // FORKS with the snapshot (same mechanism as `TEST_DATA` / `RUNTIME_SERVICES`).
2638
+ // `runOne` advances the markers BEFORE `/run` returns, and the control
2639
+ // plane snapshots the fork AFTER `/run` returns — so a child (forked or
2640
+ // handed off) restores the parent's final markers and its delta tiles on
2641
+ // with no gap or cross-branch duplication.
2642
+ /**
2643
+ * Per-(service, stream) count of newline-terminated log lines already
2644
+ * captured by an ancestor case. Key is `"<service><stream>"`. The
2645
+ * next case on this branch captures only `lines[marker..]`. Module-scope
2646
+ * so it travels with the snapshot into every fork.
2647
+ */
2648
+ const LOG_MARKERS = new Map();
2649
+ /** Per-(service, stream) delta byte cap. Over this we keep head+tail and
2650
+ * elide the middle — the head preserves the continuation from the parent,
2651
+ * the tail preserves the newest output — while still advancing the marker
2652
+ * to the true line count so the chain stays aligned. */
2653
+ const LOG_DELTA_MAX_BYTES = 2 * 1024 * 1024;
2654
+ /**
2655
+ * Inspect a container's run state for {@link ServiceLogDelta}: `{}` while
2656
+ * running (or when inspect fails — a removed container has no state left to
2657
+ * report), `{ stopped, exitCode }` once it has exited/died.
2658
+ */
2659
+ async function containerStopState(name) {
2660
+ const r = await docker(["inspect", "-f", "{{.State.Status}} {{.State.ExitCode}}", name], 15_000);
2661
+ if (r.code !== 0)
2662
+ return {};
2663
+ const [status, codeStr] = r.stdout.trim().split(/\s+/);
2664
+ if (status !== "exited" && status !== "dead")
2665
+ return {};
2666
+ const exitCode = Number.parseInt(codeStr ?? "", 10);
2667
+ return Number.isFinite(exitCode) ? { stopped: true, exitCode } : { stopped: true };
2668
+ }
2669
+ /** Keep the head and tail of `s`, eliding the middle when it exceeds
2670
+ * `max` (string length, a byte proxy as elsewhere here). Head+tail so an
2671
+ * over-long delta keeps both the parent-continuation and the newest
2672
+ * output. */
2673
+ function capMiddle(s, max) {
2674
+ if (s.length <= max)
2675
+ return { value: s, truncated: false };
2676
+ const half = Math.floor(max / 2);
2677
+ const elided = s.length - 2 * half;
2678
+ return {
2679
+ value: `${s.slice(0, half)}\n… [${elided} bytes elided] …\n${s.slice(s.length - half)}`,
2680
+ truncated: true,
2681
+ };
2682
+ }
2683
+ /**
2684
+ * Compute one stream's delta beyond `marker` complete lines.
2685
+ * - Counts only newline-terminated lines; a trailing partial line (no
2686
+ * `\n` yet) is held back from both the delta and the count, so a line
2687
+ * completed by a later capture isn't split across the fork boundary.
2688
+ * - Reset guard: if the stream shrank below `marker` (container recreated
2689
+ * or rotated) the whole current log is re-emitted and `reset` is set.
2690
+ */
2691
+ function streamDelta(full, marker) {
2692
+ const lastNl = full.lastIndexOf("\n");
2693
+ const complete = lastNl < 0 ? "" : full.slice(0, lastNl + 1);
2694
+ let total = 0;
2695
+ for (let i = 0; i < complete.length; i++) {
2696
+ if (complete.charCodeAt(i) === 10)
2697
+ total++;
2698
+ }
2699
+ let reset = false;
2700
+ let startLine = marker;
2701
+ if (total < marker) {
2702
+ reset = true;
2703
+ startLine = 0;
2704
+ }
2705
+ let delta;
2706
+ if (startLine <= 0) {
2707
+ delta = complete;
2708
+ }
2709
+ else if (startLine >= total) {
2710
+ delta = "";
2711
+ }
2712
+ else {
2713
+ // Byte offset just past the `startLine`-th newline.
2714
+ let seen = 0;
2715
+ let off = 0;
2716
+ for (let i = 0; i < complete.length; i++) {
2717
+ if (complete.charCodeAt(i) === 10 && ++seen === startLine) {
2718
+ off = i + 1;
2719
+ break;
2720
+ }
2721
+ }
2722
+ delta = complete.slice(off);
2723
+ }
2724
+ const capped = capMiddle(delta, LOG_DELTA_MAX_BYTES);
2725
+ return { delta: capped.value, total, reset, truncated: capped.truncated };
2726
+ }
2727
+ /**
2728
+ * Capture the per-service log delta for the current case and advance the
2729
+ * markers. Runs on EVERY case (pass or fail). Enumerates boot services
2730
+ * (`namedServices`) plus any runtime services this fork started
2731
+ * (`RUNTIME_SERVICES`), deduped by name. A `docker logs` failure surfaces
2732
+ * as the service's `stderr` WITHOUT advancing the markers — a transient
2733
+ * failure must never desync the chain.
2734
+ */
2735
+ async function captureServiceLogDeltas() {
2736
+ const l = loaded;
2737
+ if (!l)
2738
+ return [];
2739
+ const byName = new Map();
2740
+ for (const s of namedServices(l.project.environment))
2741
+ byName.set(s.name, s);
2742
+ for (const [name, s] of RUNTIME_SERVICES)
2743
+ byName.set(name, s);
2744
+ const services = [...byName.values()];
2745
+ return Promise.all(services.map(async (svc) => {
2746
+ const [r, stop] = await Promise.all([
2747
+ docker(["logs", "--timestamps", svc.name], 30_000),
2748
+ containerStopState(svc.name),
2749
+ ]);
2750
+ if (r.code !== 0) {
2751
+ // Container gone/renamed — surface the CLI error, leave markers put.
2752
+ const err = capMiddle(r.stderr || r.stdout, LOG_DELTA_MAX_BYTES);
2753
+ return {
2754
+ service: svc.name,
2755
+ stdout: "",
2756
+ stdoutTruncated: false,
2757
+ stdoutReset: false,
2758
+ stderr: err.value,
2759
+ stderrTruncated: err.truncated,
2760
+ stderrReset: false,
2761
+ ...stop,
2762
+ };
2763
+ }
2764
+ const outKey = `${svc.name}stdout`;
2765
+ const errKey = `${svc.name}stderr`;
2766
+ const out = streamDelta(r.stdout, LOG_MARKERS.get(outKey)?.lines ?? 0);
2767
+ const err = streamDelta(r.stderr, LOG_MARKERS.get(errKey)?.lines ?? 0);
2768
+ LOG_MARKERS.set(outKey, { lines: out.total });
2769
+ LOG_MARKERS.set(errKey, { lines: err.total });
2770
+ return {
2771
+ service: svc.name,
2772
+ stdout: out.delta,
2773
+ stdoutTruncated: out.truncated,
2774
+ stdoutReset: out.reset,
2775
+ stderr: err.delta,
2776
+ stderrTruncated: err.truncated,
2777
+ stderrReset: err.reset,
2778
+ ...stop,
2779
+ };
2780
+ }));
2781
+ }
2782
+ /**
2783
+ * Mint a session id. `idScope` (the running test's case id; `"eval"`
2784
+ * for eval-context sessions) is baked in because `randomUUID()` alone
2785
+ * is NOT unique across test forks: sibling cases resume from the same
2786
+ * snapshot, so the daemon process — and the guest kernel CSPRNG it
2787
+ * draws from — restores identical RNG state in every clone, and the
2788
+ * first UUID minted after the fork collides across siblings (observed
2789
+ * in practice, not hypothetical). Persistence keys sessions by
2790
+ * (run, case, session) so the collision never lost data, but anything
2791
+ * that ever aggregates sessions across cases would conflate them.
2792
+ * Sibling forks run different cases by construction, so the case id is
2793
+ * exactly the entropy the clones are missing.
2794
+ */
2795
+ function newSessionId(idScope) {
2796
+ return idScope ? `${idScope}:${randomUUID()}` : randomUUID();
2797
+ }
2798
+ /**
2799
+ * Cumulative raw-byte cap on one eval's artifacts. The bytes ride the
2800
+ * `/eval` JSON reply base64'd (~1.33x), and the vm-agent hard-errors on
2801
+ * proxied daemon responses over 16 MB (the same cap that pushed /run's
2802
+ * replay bundles out-of-band — see REPLAY_BUNDLES). 8 MiB raw ≈ 10.7 MB
2803
+ * encoded leaves headroom for the reply's sessions/log; screenshots are
2804
+ * typically well under 2 MiB each. If artifacts ever need to grow past
2805
+ * this, move them to the parked-chunk side channel instead of raising it.
2806
+ */
2807
+ const MAX_EVAL_ARTIFACT_BYTES = 8 * 1024 * 1024;
2808
+ /**
2809
+ * Per-eval artifact sink. `register` throws (failing the screenshot() call,
2810
+ * never the eval) once the byte budget is exhausted.
2811
+ */
2812
+ function newArtifactCollector() {
2813
+ const artifacts = [];
2814
+ let total = 0;
2815
+ return {
2816
+ artifacts,
2817
+ register(artifact) {
2818
+ total += artifact.sizeBytes;
2819
+ if (total > MAX_EVAL_ARTIFACT_BYTES) {
2820
+ throw new Error(`artifact byte budget for this eval exceeded (${MAX_EVAL_ARTIFACT_BYTES / (1024 * 1024)} MiB) — ` +
2821
+ "capture fewer screenshots per eval");
2822
+ }
2823
+ artifacts.push(artifact);
2824
+ },
2825
+ };
2826
+ }
2827
+ /**
2828
+ * Build the recorder sink + bookkeeping for a single Browser session.
2829
+ * The returned `recorder` is what `openBrowser` writes into; the
2830
+ * returned `record` is the in-flight session object the daemon owns.
2831
+ * `artifacts` (eval-only) wires `screenshot()`'s artifact registration —
2832
+ * test-run sessions don't pass it, which is exactly what makes
2833
+ * `screenshot()` throw outside eval.
2834
+ */
2835
+ function newBrowserSession(testStart, idScope, frame = "browser", artifacts) {
2836
+ const record = {
2837
+ sessionId: newSessionId(idScope),
2838
+ openedAtMs: Date.now() - testStart,
2839
+ frame,
2840
+ steps: [],
2841
+ };
2842
+ let closed = false;
2843
+ return {
2844
+ record,
2845
+ recorder: {
2846
+ sessionId: record.sessionId,
2847
+ recordStep(step) {
2848
+ if (closed)
2849
+ return;
2850
+ record.steps.push(step);
2851
+ },
2852
+ noteNavigation(url) {
2853
+ if (closed)
2854
+ return;
2855
+ if (record.initialUrl === undefined)
2856
+ record.initialUrl = url;
2857
+ },
2858
+ ...(artifacts
2859
+ ? { registerArtifact: (a) => artifacts.register(a) }
2860
+ : {}),
2861
+ },
2862
+ markClosed() {
2863
+ if (closed)
2864
+ return;
2865
+ closed = true;
2866
+ record.closedAtMs = Date.now() - testStart;
2867
+ },
2868
+ };
2869
+ }
2870
+ /** New terminal session bookkeeping for a single `ctx.terminal(...)` call. */
2871
+ function newTerminalSession(testStart, service, command, cols, rows, idScope) {
2872
+ const record = {
2873
+ sessionId: newSessionId(idScope),
2874
+ openedAtMs: Date.now() - testStart,
2875
+ service,
2876
+ command,
2877
+ cols,
2878
+ rows,
2879
+ frames: [],
2880
+ };
2881
+ let closed = false;
2882
+ return {
2883
+ record,
2884
+ pushFrame(tSec, data) {
2885
+ if (closed)
2886
+ return;
2887
+ record.frames.push([tSec, "o", data]);
2888
+ },
2889
+ markClosed() {
2890
+ if (closed)
2891
+ return;
2892
+ closed = true;
2893
+ record.closedAtMs = Date.now() - testStart;
2894
+ },
2895
+ };
2896
+ }
2897
+ /** Grid a recorded `ctx.exec` asciicast claims. There's no PTY behind
2898
+ * an exec so no real size exists — 80×24 matches the `ctx.terminal`
2899
+ * default, and the player hard-wraps longer lines the way an actual
2900
+ * 80-col terminal would. */
2901
+ const EXEC_CAST_COLS = 80;
2902
+ const EXEC_CAST_ROWS = 24;
2903
+ /** Cumulative cap on asciicast frame bytes per recorded exec. The exec
2904
+ * *event* caps its stdout/stderr separately (256 KiB each); this bounds
2905
+ * the recording, which would otherwise duplicate a huge output in the
2906
+ * run payload and the DB. On overflow the cast gets one trailing
2907
+ * notice frame and stops growing; the ExecResult is unaffected. */
2908
+ const EXEC_FRAME_CAP_BYTES = 1024 * 1024;
2909
+ /**
2910
+ * Build the bookkeeping for a fresh `openTerminal` session and return
2911
+ * the open Terminal handle alongside a frame sink the factory drains
2912
+ * into. The daemon owns the `TerminalSessionRecord`; the factory just
2913
+ * pushes frames and tells us when the session ends.
2914
+ *
2915
+ * Used by both:
2916
+ * - the long-lived `ctx.openTerminal(...)` API, where the test owns
2917
+ * the handle and decides when to close;
2918
+ * - the one-shot `ctx.terminal(...)` wrapper below, which opens a
2919
+ * terminal with `opts.command`, waits for the embedded program to
2920
+ * exit, then closes — same code path, just an immediate await.
2921
+ *
2922
+ * Heads-up for test authors: TTY-detecting CLIs may invoke a pager
2923
+ * (psql → less, git → less, etc.) and block waiting for input now that
2924
+ * stdin *is* a TTY. Disable paging in the command itself (e.g.
2925
+ * `psql -P pager=off`) or pass `PAGER=cat` / `PSQL_PAGER=` via
2926
+ * `opts.env`.
2927
+ */
2928
+ async function openInstrumentedTerminal(service, opts, testStart, sessions, recordEvents, idScope) {
2929
+ assertKnownOpts("ctx.terminal", opts, TERMINAL_OPT_KEYS);
2930
+ const cols = opts?.cols ?? 80;
2931
+ const rows = opts?.rows ?? 24;
2932
+ const session = newTerminalSession(testStart, service, opts?.command ?? "(interactive)", cols, rows, idScope);
2933
+ sessions.push(session.record);
2934
+ const sink = {
2935
+ pushFrame: (t, data) => session.pushFrame(t, data),
2936
+ markClosed: () => session.markClosed(),
2937
+ };
2938
+ return await openTerminal({
2939
+ service,
2940
+ opts,
2941
+ sink,
2942
+ sessionId: session.record.sessionId,
2943
+ recordEvents,
2944
+ });
2945
+ }
2946
+ // Return value of each test that has completed in this daemon's lifetime.
2947
+ // Lives in daemon memory and is captured by every post-test snapshot, so
2948
+ // when a child case forks from its parent's snapshot it sees the same Map
2949
+ // already populated. Carries arbitrary JS values — no JSON round-trip.
2950
+ const TEST_DATA = new Map();
2951
+ // ────────────────────────────────────────────────────────────────────────
2952
+ // Component context — the exec / project-file surface handed to service
2953
+ // `setup` hooks and `helpers` factories (`ComponentContext` in index.ts),
2954
+ // so components don't hand-roll child_process docker execs or hard-code
2955
+ // control-plane paths like /workspace.
2956
+ // ────────────────────────────────────────────────────────────────────────
2957
+ const COMPONENT_EXEC_DEFAULT_TIMEOUT_MS = 120_000;
2958
+ /** Raw `docker exec` with optional piped stdin. Array command = exact
2959
+ * argv (no shell); string = `sh -lc`. Non-zero exit is reported via
2960
+ * `exitCode`, never thrown. Unlike `execInService` this records nothing —
2961
+ * setup/helpers-factory time has no test timeline. */
2962
+ function componentExec(service, command, opts) {
2963
+ assertKnownOpts("ctx.exec", opts, EXEC_OPT_KEYS);
2964
+ const argv = ["exec", "-i"];
2965
+ if (opts?.cwd)
2966
+ argv.push("-w", opts.cwd);
2967
+ argv.push(service);
2968
+ if (typeof command === "string")
2969
+ argv.push("sh", "-lc", command);
2970
+ else
2971
+ argv.push(...command);
2972
+ const timeoutMs = opts?.timeoutMs ?? COMPONENT_EXEC_DEFAULT_TIMEOUT_MS;
2973
+ return new Promise((resolve, reject) => {
2974
+ const child = spawn("docker", argv, {
2975
+ stdio: [opts?.stdin !== undefined ? "pipe" : "ignore", "pipe", "pipe"],
2976
+ });
2977
+ const out = [];
2978
+ const err = [];
2979
+ child.stdout.on("data", (c) => out.push(c));
2980
+ child.stderr.on("data", (c) => err.push(c));
2981
+ let timedOut = false;
2982
+ const timer = setTimeout(() => {
2983
+ timedOut = true;
2984
+ child.kill("SIGKILL");
2985
+ }, timeoutMs);
2986
+ child.on("error", (e) => {
2987
+ clearTimeout(timer);
2988
+ reject(e);
2989
+ });
2990
+ child.on("close", (code) => {
2991
+ clearTimeout(timer);
2992
+ // A SIGKILL'd child reports 137, which reads like an OOM kill.
2993
+ // Report the `timeout(1)` convention plus a note instead, so a
2994
+ // wedged command is diagnosable from the result alone.
2995
+ const stderrText = Buffer.concat(err).toString("utf8");
2996
+ resolve({
2997
+ stdout: Buffer.concat(out).toString("utf8"),
2998
+ stderr: timedOut
2999
+ ? `${stderrText}\ntimeout after ${timeoutMs}ms`
3000
+ : stderrText,
3001
+ exitCode: timedOut ? EXEC_TIMEOUT_EXIT_CODE : (code ?? -1),
3002
+ });
3003
+ });
3004
+ feedStdin(child, opts?.stdin);
3005
+ });
3006
+ }
3007
+ function componentContext() {
3008
+ return {
3009
+ projectRoot: WORKSPACE,
3010
+ readProjectFile: (p) => fs.readFile(path.isAbsolute(p) ? p : path.join(WORKSPACE, p), "utf8"),
3011
+ exec: componentExec,
3012
+ };
3013
+ }
3014
+ // Cached helper namespaces produced by `ServiceDefinition.helpers`
3015
+ // factories. Built lazily on first access and reused for the daemon's
3016
+ // lifetime — Bun.SQL pools and similar resources are happy to live a
3017
+ // long time, and the underlying TCP connections survive snapshot/fork
3018
+ // along with the rest of daemon memory. Cleared on /load and /reload
3019
+ // (project change invalidates any cached state).
3020
+ const HELPERS_CACHE = new Map();
3021
+ /**
3022
+ * Build (or fetch from cache) the helpers record for a single service.
3023
+ * Returns an empty object if the service doesn't ship a `helpers`
3024
+ * factory — symmetric with what gets passed to `setup`.
3025
+ */
3026
+ async function ensureHelpers(name, def) {
3027
+ if (!def.helpers)
3028
+ return {};
3029
+ if (!HELPERS_CACHE.has(name)) {
3030
+ HELPERS_CACHE.set(name, await def.helpers({ name, ...componentContext() }));
3031
+ }
3032
+ return HELPERS_CACHE.get(name);
3033
+ }
3034
+ /**
3035
+ * Build the `svc` map for one test/eval. The value at `svc[name]` is
3036
+ * exactly the record the service's `helpers` factory returned (e.g.
3037
+ * `{ client: SqlClient }` for `postgres(...)`). Services without a
3038
+ * `helpers` factory don't appear in the map at all.
3039
+ */
3040
+ async function buildServiceHandles(cfg) {
3041
+ const handles = {};
3042
+ for (const [name, rawDef] of Object.entries(cfg.services)) {
3043
+ // The wire type drops the `helpers` function (JSON.stringify ignores
3044
+ // functions), but in the daemon we hold the in-memory definition
3045
+ // from the user's module — `helpers` is still there when present.
3046
+ const def = rawDef;
3047
+ if (!def.helpers)
3048
+ continue;
3049
+ handles[name] = await ensureHelpers(name, def);
3050
+ }
3051
+ return handles;
3052
+ }
3053
+ // ────────────────────────────────────────────────────────────────────────
3054
+ // Instrumentation helpers
3055
+ // ────────────────────────────────────────────────────────────────────────
3056
+ function describeFetchInput(input) {
3057
+ if (typeof input === "string")
3058
+ return { url: input };
3059
+ if (input instanceof URL)
3060
+ return { url: input.toString() };
3061
+ // Request instance
3062
+ const req = input;
3063
+ return { url: req.url, methodFromInput: req.method };
3064
+ }
3065
+ function describeRequestBody(input, init) {
3066
+ // For Request objects, body has already been consumed into the request;
3067
+ // we can't read it back without cloning, which costs. Skip unless init.body
3068
+ // is provided directly.
3069
+ const body = init?.body;
3070
+ if (body === undefined || body === null) {
3071
+ if (input instanceof Request && input.bodyUsed === false) {
3072
+ // Don't drain the request's body here — leaving it for the actual
3073
+ // fetch. Return a marker.
3074
+ return { body: "[Request body not captured]", truncated: false };
3075
+ }
3076
+ return {};
3077
+ }
3078
+ if (typeof body === "string") {
3079
+ const t = truncateUtf8(body);
3080
+ return { body: t.value, truncated: t.truncated };
3081
+ }
3082
+ if (body instanceof URLSearchParams) {
3083
+ const t = truncateUtf8(body.toString());
3084
+ return { body: t.value, truncated: t.truncated };
3085
+ }
3086
+ return { body: `[non-text body: ${body.constructor?.name ?? typeof body}]` };
3087
+ }
3088
+ /**
3089
+ * Install a fetch wrapper on `globalThis` that emits HTTP events into the
3090
+ * active recorder. Returns a restore function. Calls outside of a running
3091
+ * test still hit the original fetch (the recorder is null then; the
3092
+ * wrapper just adds a tiny amount of overhead — but we restore after each
3093
+ * test anyway, so this only matters mid-test).
3094
+ */
3095
+ function installFetchWrapper() {
3096
+ const original = globalThis.fetch;
3097
+ const wrappedFn = async (input, init) => {
3098
+ const start = Date.now();
3099
+ const resv = reserveEvent();
3100
+ const { url, methodFromInput } = describeFetchInput(input);
3101
+ const method = (init?.method ?? methodFromInput ?? "GET").toUpperCase();
3102
+ const reqBody = describeRequestBody(input, init);
3103
+ try {
3104
+ const res = await original(input, init);
3105
+ let responseBody;
3106
+ let responseBodyTruncated;
3107
+ try {
3108
+ const cloned = res.clone();
3109
+ const text = await cloned.text();
3110
+ const t = truncateUtf8(text);
3111
+ responseBody = t.value;
3112
+ responseBodyTruncated = t.truncated;
3113
+ }
3114
+ catch {
3115
+ // Binary or unreadable body — leave undefined.
3116
+ }
3117
+ const seq = recordHttp({
3118
+ method,
3119
+ url,
3120
+ requestBody: reqBody.body,
3121
+ requestBodyTruncated: reqBody.truncated,
3122
+ status: res.status,
3123
+ responseBody,
3124
+ responseBodyTruncated,
3125
+ durationMs: Date.now() - start,
3126
+ }, resv);
3127
+ return wrapResponse(res, seq);
3128
+ }
3129
+ catch (err) {
3130
+ const e = err;
3131
+ recordHttp({
3132
+ method,
3133
+ url,
3134
+ requestBody: reqBody.body,
3135
+ requestBodyTruncated: reqBody.truncated,
3136
+ durationMs: Date.now() - start,
3137
+ error: e?.message ?? String(err),
3138
+ }, resv);
3139
+ throw err;
3140
+ }
3141
+ };
3142
+ // Preserve any provider-specific statics on `fetch` (e.g. Bun's
3143
+ // `fetch.preconnect`) so consumers that touch them keep working.
3144
+ const wrapped = wrappedFn;
3145
+ for (const key of Object.keys(original)) {
3146
+ wrapped[key] = original[key];
3147
+ }
3148
+ globalThis.fetch = wrapped;
3149
+ return () => {
3150
+ globalThis.fetch = original;
3151
+ };
3152
+ }
3153
+ /** Build the `docker exec` argv for a service command. An optional `cwd`
3154
+ * becomes `-w <cwd>` (the working-directory option of `ctx.exec`), so the
3155
+ * command runs from that directory without it being baked into the command
3156
+ * string. `-i` is passed whenever stdin will be piped — without it docker
3157
+ * attaches no stdin and the payload is silently discarded. Used by both the
3158
+ * buffered and streaming variants so they stay in lockstep. */
3159
+ function dockerExecArgs(service, command, opts) {
3160
+ const args = ["exec"];
3161
+ if (opts?.stdin !== undefined)
3162
+ args.push("-i");
3163
+ if (opts?.cwd)
3164
+ args.push("-w", opts.cwd);
3165
+ args.push(service, "sh", "-lc", command);
3166
+ return args;
3167
+ }
3168
+ /** Exit code reported when an exec is killed by its own `timeoutMs`
3169
+ * (the `timeout(1)` convention, matching `shx`). */
3170
+ const EXEC_TIMEOUT_EXIT_CODE = 124;
3171
+ /**
3172
+ * Reject option objects carrying properties we don't implement, instead
3173
+ * of ignoring them. TypeScript's excess-property check already flags
3174
+ * these on an object literal, but `spectest test`'s typecheck is
3175
+ * advisory and never gates a run — so a typo'd or unsupported option
3176
+ * (`{ stdin }` before it was supported, `{ timeout }` for `timeoutMs`)
3177
+ * would otherwise vanish without a trace and surface much later as
3178
+ * "the command got no input".
3179
+ */
3180
+ function assertKnownOpts(fnLabel, opts, allowed) {
3181
+ if (!opts || typeof opts !== "object")
3182
+ return;
3183
+ const unknown = Object.keys(opts).filter((k) => !allowed.includes(k));
3184
+ if (unknown.length === 0)
3185
+ return;
3186
+ throw new Error(`${fnLabel}: unsupported option${unknown.length > 1 ? "s" : ""} ` +
3187
+ `${unknown.map((k) => JSON.stringify(k)).join(", ")} — supported: ` +
3188
+ `${allowed.map((k) => JSON.stringify(k)).join(", ")}`);
3189
+ }
3190
+ const EXEC_OPT_KEYS = ["cwd", "stdin", "timeoutMs"];
3191
+ const TERMINAL_OPT_KEYS = ["cols", "rows", "env", "command", "timeoutMs"];
3192
+ const POLL_OPT_KEYS = ["timeoutMs", "intervalMs"];
3193
+ /** Write `stdin` to the child and close the stream. A command that never
3194
+ * reads stdin makes the write fail with EPIPE — harmless, and it must not
3195
+ * take down the daemon as an unhandled 'error' event. */
3196
+ function feedStdin(child, stdin) {
3197
+ if (stdin === undefined)
3198
+ return;
3199
+ const s = child.stdin;
3200
+ if (!s)
3201
+ return;
3202
+ s.on("error", () => { });
3203
+ s.end(stdin);
3204
+ }
3205
+ function execInService(service, command, opts) {
3206
+ return new Promise((resolve) => {
3207
+ const child = execFile("docker", dockerExecArgs(service, command, opts), { maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
3208
+ if (timer)
3209
+ clearTimeout(timer);
3210
+ if (timedOut) {
3211
+ resolve({
3212
+ stdout: stdout.toString(),
3213
+ stderr: `${stderr.toString()}\ntimeout after ${opts.timeoutMs}ms`,
3214
+ exitCode: EXEC_TIMEOUT_EXIT_CODE,
3215
+ });
3216
+ return;
3217
+ }
3218
+ const exitCode = err && typeof err.code === "number"
3219
+ ? Number(err.code)
3220
+ : err
3221
+ ? 1
3222
+ : 0;
3223
+ resolve({
3224
+ stdout: stdout.toString(),
3225
+ stderr: stderr.toString(),
3226
+ exitCode,
3227
+ });
3228
+ });
3229
+ let timedOut = false;
3230
+ const timer = opts?.timeoutMs && opts.timeoutMs > 0
3231
+ ? setTimeout(() => {
3232
+ timedOut = true;
3233
+ child.kill("SIGKILL");
3234
+ }, opts.timeoutMs)
3235
+ : undefined;
3236
+ feedStdin(child, opts?.stdin);
3237
+ });
3238
+ }
3239
+ /** `execInService` for the no-recorder contexts (`setup`/`eval`): wraps the
3240
+ * result so `.unwrap()` is available and the ctx's wrapped `exec` type is
3241
+ * honest at runtime, but with no provenance (there's no event to link to).
3242
+ * The recorded `ctx.exec` used during tests is `recordedExec` below. */
3243
+ async function execInServiceWrapped(service, command, opts) {
3244
+ assertKnownOpts("ctx.exec", opts, EXEC_OPT_KEYS);
3245
+ const res = await execInService(service, command, opts);
3246
+ return wrap(res, undefined);
3247
+ }
3248
+ /** Per-stream cap on the ExecResult strings the streaming variant
3249
+ * accumulates — parity with the buffered `execInService`'s `maxBuffer`.
3250
+ * Past the cap we keep draining (so the child never blocks on a full
3251
+ * pipe) but stop appending; unlike `execFile` we don't kill the
3252
+ * process, which only makes over-cap runs *more* survivable. */
3253
+ const EXEC_RESULT_CAP_BYTES = 16 * 1024 * 1024;
3254
+ /**
3255
+ * Streaming variant of `execInService` for the recorded `ctx.exec`:
3256
+ * the same `docker exec <svc> sh -lc <cmd>` invocation with the same
3257
+ * result shape, but stdout/stderr are drained incrementally so the
3258
+ * caller can timestamp each chunk into an asciicast frame as it
3259
+ * arrives. No PTY is involved — the program still sees plain pipes
3260
+ * (`isatty` false), so the streams stay byte-identical to what `exec`
3261
+ * has always returned; the recording adds arrival *timing* only.
3262
+ * `onChunk` fires in arrival order across both streams — the closest
3263
+ * analogue of what a terminal would have shown — while the returned
3264
+ * `ExecResult` keeps them separate as before.
3265
+ */
3266
+ function execInServiceStreaming(service, command, onChunk, opts) {
3267
+ return new Promise((resolve) => {
3268
+ const child = spawn("docker", dockerExecArgs(service, command, opts), {
3269
+ stdio: [opts?.stdin !== undefined ? "pipe" : "ignore", "pipe", "pipe"],
3270
+ });
3271
+ const acc = { stdout: "", stderr: "" };
3272
+ const decoders = {
3273
+ stdout: new TextDecoder("utf-8", { fatal: false }),
3274
+ stderr: new TextDecoder("utf-8", { fatal: false }),
3275
+ };
3276
+ const drain = (which, chunk) => {
3277
+ const data = decoders[which].decode(chunk, { stream: true });
3278
+ if (data.length === 0)
3279
+ return;
3280
+ if (acc[which].length < EXEC_RESULT_CAP_BYTES) {
3281
+ const room = EXEC_RESULT_CAP_BYTES - acc[which].length;
3282
+ acc[which] += data.length > room ? data.slice(0, room) : data;
3283
+ }
3284
+ try {
3285
+ onChunk(which, data);
3286
+ }
3287
+ catch {
3288
+ // frame capture must never break the exec itself
3289
+ }
3290
+ };
3291
+ child.stdout?.on("data", (c) => drain("stdout", c));
3292
+ child.stderr?.on("data", (c) => drain("stderr", c));
3293
+ let settled = false;
3294
+ let timedOut = false;
3295
+ const finish = (exitCode) => {
3296
+ if (settled)
3297
+ return;
3298
+ settled = true;
3299
+ if (timer)
3300
+ clearTimeout(timer);
3301
+ // Flush any multi-byte tail the decoders are still holding.
3302
+ acc.stdout += decoders.stdout.decode();
3303
+ acc.stderr += decoders.stderr.decode();
3304
+ if (timedOut) {
3305
+ const note = `\ntimeout after ${opts.timeoutMs}ms`;
3306
+ acc.stderr += note;
3307
+ onChunk("stderr", note);
3308
+ }
3309
+ resolve({
3310
+ stdout: acc.stdout,
3311
+ stderr: acc.stderr,
3312
+ exitCode: timedOut ? EXEC_TIMEOUT_EXIT_CODE : exitCode,
3313
+ });
3314
+ };
3315
+ const timer = opts?.timeoutMs && opts.timeoutMs > 0
3316
+ ? setTimeout(() => {
3317
+ timedOut = true;
3318
+ child.kill("SIGKILL");
3319
+ }, opts.timeoutMs)
3320
+ : undefined;
3321
+ // `close` (not `exit`) so both pipes are fully drained first.
3322
+ child.on("close", (code) => finish(code ?? 1));
3323
+ child.on("error", () => finish(1));
3324
+ feedStdin(child, opts?.stdin);
3325
+ });
3326
+ }
3327
+ async function pollCall(description, fn, opts) {
3328
+ assertKnownOpts("ctx.poll", opts, POLL_OPT_KEYS);
3329
+ const timeoutMs = opts?.timeoutMs ?? 30_000;
3330
+ const intervalMs = opts?.intervalMs ?? 1_000;
3331
+ const start = Date.now();
3332
+ // Reserve the wait's slot up front so it sorts at the poll's *start*,
3333
+ // ahead of the iteration events it nests (which record as the poll runs).
3334
+ const resv = reserveEvent();
3335
+ let attempts = 0;
3336
+ let value;
3337
+ let success = false;
3338
+ let predicateError;
3339
+ // Record all iterations normally. Falsy iterations get truncated
3340
+ // from the recorder so the timeline doesn't fill with polling
3341
+ // noise; the LAST iteration's events stay, then get marked as
3342
+ // children of the wait event so the UI can render them nested.
3343
+ const beforePollIdx = recorderEventCount();
3344
+ let lastIterStartIdx = beforePollIdx;
3345
+ let keptIterStartIdx = beforePollIdx;
3346
+ while (Date.now() - start < timeoutMs) {
3347
+ attempts += 1;
3348
+ lastIterStartIdx = recorderEventCount();
3349
+ try {
3350
+ const v = await fn();
3351
+ if (v !== null && v !== undefined && v !== false) {
3352
+ value = v;
3353
+ success = true;
3354
+ keptIterStartIdx = lastIterStartIdx;
3355
+ break;
3356
+ }
3357
+ }
3358
+ catch (err) {
3359
+ predicateError = err;
3360
+ break;
3361
+ }
3362
+ // Failed iteration — drop the events it emitted.
3363
+ recorderTruncate(lastIterStartIdx);
3364
+ if (Date.now() - start + intervalMs > timeoutMs)
3365
+ break;
3366
+ await new Promise((r) => setTimeout(r, intervalMs));
3367
+ }
3368
+ if (!success) {
3369
+ // Timeout or predicate error: drop every attempt's events. The
3370
+ // wait event we emit below is the only trace.
3371
+ recorderTruncate(beforePollIdx);
3372
+ }
3373
+ const errMsg = predicateError !== undefined
3374
+ ? (predicateError?.message ?? String(predicateError))
3375
+ : success
3376
+ ? undefined
3377
+ : `timed out after ${timeoutMs}ms`;
3378
+ const seq = recordWait({
3379
+ description,
3380
+ attempts,
3381
+ durationMs: Date.now() - start,
3382
+ passed: success,
3383
+ ...(errMsg !== undefined ? { error: errMsg } : {}),
3384
+ }, resv);
3385
+ if (success && seq !== undefined) {
3386
+ // Group the kept iteration's events under the wait so the UI can
3387
+ // render them inside the wait card. The wait event itself is the
3388
+ // very last entry; markChildren skips it via the seq match.
3389
+ recorderMarkChildren(keptIterStartIdx, seq);
3390
+ }
3391
+ if (predicateError !== undefined)
3392
+ throw predicateError;
3393
+ if (success) {
3394
+ return wrap(value, seq);
3395
+ }
3396
+ throw new Error(`poll ${JSON.stringify(description)} timed out after ${timeoutMs}ms (${attempts} attempts)`);
3397
+ }
3398
+ function captureConsole(chunks) {
3399
+ const methods = ["log", "info", "warn", "error", "debug"];
3400
+ const orig = new Map();
3401
+ for (const m of methods) {
3402
+ const fn = console[m].bind(console);
3403
+ orig.set(m, console[m]);
3404
+ console[m] = (...a) => {
3405
+ try {
3406
+ chunks.push(a.map((x) => (typeof x === "string" ? x : Bun.inspect(x))).join(" ") + "\n");
3407
+ }
3408
+ catch {
3409
+ // capture must never break the test
3410
+ }
3411
+ fn(...a);
3412
+ };
3413
+ }
3414
+ return () => {
3415
+ for (const m of methods)
3416
+ console[m] = orig.get(m);
3417
+ };
3418
+ }
3419
+ async function runOne(testCase) {
3420
+ const start = Date.now();
3421
+ const chunks = [];
3422
+ const origStdout = process.stdout.write.bind(process.stdout);
3423
+ const origStderr = process.stderr.write.bind(process.stderr);
3424
+ const capture = (s) => {
3425
+ chunks.push(typeof s === "string" ? s : Buffer.from(s).toString("utf8"));
3426
+ return true;
3427
+ };
3428
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
3429
+ process.stdout.write = capture;
3430
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
3431
+ process.stderr.write = capture;
3432
+ const restoreConsole = captureConsole(chunks);
3433
+ // Terminal sessions: each `ctx.exec` / `ctx.terminal(...)` /
3434
+ // `ctx.openTerminal(...)` call opens its own session (asciicast frames
3435
+ // live on the record; the inline TestEvent just carries metadata + a
3436
+ // sessionId pointer). Drained at the end of the test and shipped on
3437
+ // RunResult.terminalSessions.
3438
+ //
3439
+ // Unlike browsers, we don't auto-close terminals at test end. A
3440
+ // `docker exec` subprocess is cheap to keep alive (a few KB), and
3441
+ // Freestyle's snapshot captures it cleanly along with the container
3442
+ // — so leaving it running between tests doesn't leak in any
3443
+ // meaningful sense. Auto-closing only added a noisy `close` step
3444
+ // at the end of every test that used `openTerminal`.
3445
+ const terminalSessions = [];
3446
+ // Wrap exec so each call shows up in the event log alongside its
3447
+ // result. We do this here (not on `execInService` itself) so the
3448
+ // bootstrap path stays uninstrumented. Each call also captures the
3449
+ // full CLI run as an asciicast — one exec step = one run = one
3450
+ // recording: output chunks are timestamped as they stream in, so slow
3451
+ // or animated output replays with real timing in the web UI. The
3452
+ // frames are presentation-only; the ExecResult (and any assertions on
3453
+ // it) still sees the plain separated stdout/stderr.
3454
+ const recordedExec = async (service, command, opts) => {
3455
+ assertKnownOpts("ctx.exec", opts, EXEC_OPT_KEYS);
3456
+ const t = Date.now();
3457
+ const cwd = opts?.cwd;
3458
+ const resv = reserveEvent();
3459
+ const session = newTerminalSession(start, service, command, EXEC_CAST_COLS, EXEC_CAST_ROWS, testCase.id);
3460
+ terminalSessions.push(session.record);
3461
+ // Synthetic prompt frame so the replay is self-describing — the
3462
+ // program's own output starts on the next line, like a real shell. A
3463
+ // working directory rides in the prompt sigil (`svc:/dir $`) the way a
3464
+ // real shell prompt shows it, so the recording stays self-describing.
3465
+ const sigil = cwd ? `${service}:${cwd}` : service;
3466
+ session.pushFrame(0, `\x1b[32m${sigil} $\x1b[0m \x1b[1m${command}\x1b[0m\r\n`);
3467
+ let frameBytes = 0;
3468
+ let frameCapped = false;
3469
+ const res = await execInServiceStreaming(service, command, (_stream, data) => {
3470
+ if (frameCapped)
3471
+ return;
3472
+ if (frameBytes + data.length > EXEC_FRAME_CAP_BYTES) {
3473
+ frameCapped = true;
3474
+ session.pushFrame((Date.now() - t) / 1000, "\r\n\x1b[2m[spectest: recording truncated — output exceeded the cast cap]\x1b[0m\r\n");
3475
+ return;
3476
+ }
3477
+ frameBytes += data.length;
3478
+ // Pipes deliver bare `\n`; a terminal renderer needs `\r\n` or
3479
+ // every line starts at the previous line's end column
3480
+ // (stair-stepping). PTY output is ONLCR-cooked by the kernel —
3481
+ // pipe output is not, so cook it here. Normalising existing
3482
+ // `\r\n` too keeps a CR|LF split across chunk boundaries
3483
+ // harmless (`\r\r\n` renders identically).
3484
+ session.pushFrame((Date.now() - t) / 1000, data.replace(/\r?\n/g, "\r\n"));
3485
+ }, opts);
3486
+ session.markClosed();
3487
+ const stdout = truncateUtf8(res.stdout);
3488
+ const stderr = truncateUtf8(res.stderr);
3489
+ // The piped payload is never echoed by the process, so the asciicast
3490
+ // can't show it — carry it on the event instead (truncated like the
3491
+ // output streams) so the timeline shows what the command was fed.
3492
+ const stdin = opts?.stdin !== undefined ? truncateUtf8(opts.stdin) : undefined;
3493
+ const seq = recordExec({
3494
+ service,
3495
+ command,
3496
+ cwd,
3497
+ ...(stdin
3498
+ ? { stdin: stdin.value, stdinTruncated: stdin.truncated }
3499
+ : {}),
3500
+ exitCode: res.exitCode,
3501
+ stdout: stdout.value,
3502
+ stdoutTruncated: stdout.truncated,
3503
+ stderr: stderr.value,
3504
+ stderrTruncated: stderr.truncated,
3505
+ durationMs: Date.now() - t,
3506
+ sessionId: session.record.sessionId,
3507
+ }, resv);
3508
+ return wrap(res, seq);
3509
+ };
3510
+ // One-shot: open a terminal with `command` as the entrypoint, wait
3511
+ // for it to exit, close, and return the existing TerminalResult
3512
+ // shape. The asciicast and one TerminalEvent line up exactly with
3513
+ // the pre-interactive implementation, just routed through the new
3514
+ // factory.
3515
+ const recordedTerminal = async (service, command, opts) => {
3516
+ const timeoutMs = opts?.timeoutMs ?? DEFAULT_TEST_TIMEOUT_MS;
3517
+ const startedAt = Date.now();
3518
+ const resv = reserveEvent();
3519
+ const term = await openInstrumentedTerminal(service, { ...opts, command, timeoutMs }, start, terminalSessions, false, // one-shot doesn't emit per-op step events
3520
+ testCase.id);
3521
+ // `term.exited` resolves to a wrapped result; this one-shot path needs
3522
+ // the plain `exitCode` number, so `.unwrap()` the result first.
3523
+ const { exitCode } = (await term.exited).unwrap();
3524
+ await term.close();
3525
+ const output = term.rawOutput();
3526
+ const preview = truncateUtf8(output);
3527
+ const seq = recordTerminal({
3528
+ service,
3529
+ command,
3530
+ exitCode,
3531
+ durationMs: Date.now() - startedAt,
3532
+ sessionId: term.sessionId,
3533
+ cols: term.cols,
3534
+ rows: term.rows,
3535
+ outputPreview: preview.value,
3536
+ outputTruncated: preview.truncated,
3537
+ }, resv);
3538
+ const result = {
3539
+ output,
3540
+ exitCode,
3541
+ durationMs: Date.now() - startedAt,
3542
+ sessionId: term.sessionId,
3543
+ };
3544
+ return wrap(result, seq);
3545
+ };
3546
+ // Long-lived: open an interactive terminal. Each method on the
3547
+ // returned Terminal records a `terminal-step` event tied back to
3548
+ // this session id; the per-op screen previews are written into the
3549
+ // event so the UI can show "what the user saw after sendLine 'ls'".
3550
+ const recordedOpenTerminal = async (service, opts) => {
3551
+ const term = await openInstrumentedTerminal(service, opts, start, terminalSessions, true, testCase.id);
3552
+ // Emit a one-time `terminal` event so the session shows up in the
3553
+ // sidebar even before any step lands. `exitCode` is filled in by
3554
+ // the eventual `exit`/`close` step event; the inline summary here
3555
+ // uses -1 as a sentinel until then.
3556
+ const preview = truncateUtf8(term.rawOutput());
3557
+ recordTerminal({
3558
+ service,
3559
+ command: opts?.command ?? "(interactive)",
3560
+ exitCode: -1,
3561
+ durationMs: 0,
3562
+ sessionId: term.sessionId,
3563
+ cols: term.cols,
3564
+ rows: term.rows,
3565
+ outputPreview: preview.value,
3566
+ outputTruncated: preview.truncated,
3567
+ });
3568
+ return term;
3569
+ };
3570
+ startRecording();
3571
+ const restoreFetch = installFetchWrapper();
3572
+ // Look up the parent's stored return value (if any). The parent ran in
3573
+ // an ancestor fork; its TEST_DATA entry travels with the snapshot.
3574
+ const parentId = testCase.dependsOn?.id;
3575
+ const parent = parentId !== undefined ? TEST_DATA.get(parentId) : undefined;
3576
+ // Browser/mobile sessions are PERSISTENT: `ctx.browser()` acquires THE
3577
+ // shared desktop browser and `ctx.mobile(app)` the one session for that
3578
+ // app (browser.ts's module-scoped registry, which forks with the
3579
+ // snapshot like fake state). At test end we DETACH — final rrweb drain,
3580
+ // stop writing to this test's recorder — but deliberately keep the
3581
+ // Chromium alive so the post-test snapshot captures it and dependsOn
3582
+ // children resume the live page (cookies, localStorage, signed-in SPA
3583
+ // state) instead of re-navigating. Each test still gets its own session
3584
+ // record (attach re-arms rrweb with a fresh full snapshot, so replays
3585
+ // stay per-case self-contained); records flow back to the control plane
3586
+ // on RunResult.browserSessions and are archived to S3 as the case's
3587
+ // replay bundle. Within one test repeated ctx.browser()/ctx.mobile(app)
3588
+ // calls return the same handle (memoized below) so one test = one
3589
+ // session per device. An explicit `.close()` destroys the shared
3590
+ // instance — the memo is cleared so a later call starts fresh.
3591
+ const browserDetaches = [];
3592
+ const sessions = [];
3593
+ let sharedBrowser = null;
3594
+ const sharedMobiles = new Map();
3595
+ const trackedOpenBrowser = async (opts) => {
3596
+ if (sharedBrowser)
3597
+ return sharedBrowser;
3598
+ const session = newBrowserSession(start, testCase.id);
3599
+ sessions.push(session);
3600
+ const { browser, attached, detach } = await acquirePersistentBrowser({
3601
+ ...(opts ?? {}),
3602
+ recorder: session.recorder,
3603
+ });
3604
+ if (browser.safeAreaInsets) {
3605
+ session.record.safeAreaInsets = browser.safeAreaInsets;
3606
+ }
3607
+ // An attached session starts mid-page (no navigate event will fire) —
3608
+ // stamp the inherited URL so the dashboard can still label the replay.
3609
+ if (attached && session.record.initialUrl === undefined) {
3610
+ session.record.initialUrl = browser.url();
3611
+ }
3612
+ browserDetaches.push(async () => {
3613
+ await detach();
3614
+ session.markClosed();
3615
+ });
3616
+ const innerClose = browser.close.bind(browser);
3617
+ browser.close = async () => {
3618
+ await innerClose();
3619
+ if (sharedBrowser === browser)
3620
+ sharedBrowser = null;
3621
+ };
3622
+ sharedBrowser = browser;
3623
+ return browser;
3624
+ };
3625
+ const trackedOpenMobile = async (app) => {
3626
+ if (!isMobileApp(app)) {
3627
+ throw new Error("ctx.mobile(app): pass a mobile-app handle from ctx.svc, e.g. ctx.mobile(ctx.svc.app) for a service declared with expo().");
3628
+ }
3629
+ const existing = sharedMobiles.get(app.url);
3630
+ if (existing)
3631
+ return existing;
3632
+ const session = newBrowserSession(start, testCase.id, "mobile");
3633
+ sessions.push(session);
3634
+ const { mobile, attached, detach, safeAreaInsets } = await openPersistentMobile({
3635
+ url: app.url,
3636
+ recorder: session.recorder,
3637
+ initScript: app.initScript,
3638
+ });
3639
+ if (safeAreaInsets)
3640
+ session.record.safeAreaInsets = safeAreaInsets;
3641
+ if (attached && session.record.initialUrl === undefined) {
3642
+ session.record.initialUrl = mobile.url();
3643
+ }
3644
+ browserDetaches.push(async () => {
3645
+ await detach();
3646
+ session.markClosed();
3647
+ });
3648
+ const innerClose = mobile.close.bind(mobile);
3649
+ mobile.close = async () => {
3650
+ await innerClose();
3651
+ if (sharedMobiles.get(app.url) === mobile)
3652
+ sharedMobiles.delete(app.url);
3653
+ };
3654
+ sharedMobiles.set(app.url, mobile);
3655
+ return mobile;
3656
+ };
3657
+ // Build convenience handles (e.g. ctx.svc.db.client) from the loaded
3658
+ // project. Done before installing the timeout so a slow client factory
3659
+ // surfaces as a real error rather than getting attributed to the test.
3660
+ const svc = await buildServiceHandles(requireLoaded().project.environment);
3661
+ const fakes = await buildFakeHandles();
3662
+ const ctx = {
3663
+ // installFetchWrapper just swapped globalThis.fetch for the wrapped
3664
+ // version, so capturing it here gets us instrumentation on ctx.fetch
3665
+ // for free. The recorder is active for the test, so responses come
3666
+ // back wrapped — hence the SpectestFetch type.
3667
+ fetch: globalThis.fetch,
3668
+ // exec/terminal/poll wrap their results at runtime (the recorder is
3669
+ // active), so the ctx interface types them wrapped — same bridge as
3670
+ // `fetch` above. The impls' own return types stay raw.
3671
+ exec: recordedExec,
3672
+ terminal: recordedTerminal,
3673
+ openTerminal: recordedOpenTerminal,
3674
+ browser: trackedOpenBrowser,
3675
+ mobile: trackedOpenMobile,
3676
+ testName: testCase.name,
3677
+ parent,
3678
+ svc,
3679
+ fakes,
3680
+ poll: pollCall,
3681
+ dnsName: registerDnsName,
3682
+ certificate: mintCertificate,
3683
+ startService: startRuntimeService,
3684
+ stopService: stopRuntimeService,
3685
+ };
3686
+ const timeoutMs = testCase.timeoutMs ?? DEFAULT_TEST_TIMEOUT_MS;
3687
+ let timer;
3688
+ const timedOut = new Promise((_, reject) => {
3689
+ timer = setTimeout(() => reject(new Error(`test timed out after ${timeoutMs}ms`)), timeoutMs);
3690
+ });
3691
+ // Result events are gathered inside finally (after the final browser
3692
+ // drains) so we hoist these out of the try/catch.
3693
+ let outcome;
3694
+ try {
3695
+ const value = await Promise.race([
3696
+ Promise.resolve(testCase.run(ctx)),
3697
+ timedOut,
3698
+ ]);
3699
+ // Stash the return value so child cases — which fork from the snapshot
3700
+ // we're about to capture — can read it off ctx.parent.
3701
+ TEST_DATA.set(testCase.id, value);
3702
+ outcome = { status: "passed" };
3703
+ }
3704
+ catch (err) {
3705
+ const e = err;
3706
+ outcome = {
3707
+ status: "failed",
3708
+ error: { message: e.message ?? String(err), stack: e.stack },
3709
+ };
3710
+ }
3711
+ finally {
3712
+ if (timer)
3713
+ clearTimeout(timer);
3714
+ restoreFetch();
3715
+ restoreConsole();
3716
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
3717
+ process.stdout.write = origStdout;
3718
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
3719
+ process.stderr.write = origStderr;
3720
+ // Detach every browser/mobile session: final rrweb drain (must be
3721
+ // awaited before collecting session records), then stop writing to
3722
+ // this test's recorder. The Chromium itself deliberately stays alive
3723
+ // — it's part of the state the post-test snapshot captures for
3724
+ // dependsOn children (see the acquire comment above).
3725
+ for (const detach of browserDetaches) {
3726
+ try {
3727
+ await detach();
3728
+ }
3729
+ catch {
3730
+ /* ignore */
3731
+ }
3732
+ }
3733
+ for (const s of sessions)
3734
+ s.markClosed();
3735
+ }
3736
+ const durationMs = Date.now() - start;
3737
+ // Capture each service's log delta (the lines THIS case added beyond its
3738
+ // ancestors) on every case, pass or fail. Runs after the duration clock
3739
+ // stops so the `docker logs` round trips aren't billed to the test.
3740
+ // Advancing the markers here — before /run returns and the control plane
3741
+ // snapshots the fork — is what lets children tile their own deltas on
3742
+ // seamlessly. Shipped to S3: the dashboard reconstructs the full
3743
+ // cumulative log for a branch, the CLI failure post-mortem shows the
3744
+ // failing case's own delta.
3745
+ let serviceLogDeltas = [];
3746
+ try {
3747
+ serviceLogDeltas = await captureServiceLogDeltas();
3748
+ }
3749
+ catch (err) {
3750
+ // eslint-disable-next-line no-console
3751
+ console.warn("[service-logs] delta capture failed:", err);
3752
+ }
3753
+ const events = stopRecording();
3754
+ // Drop sessions whose linking event didn't survive — an exec/terminal
3755
+ // inside a failed `ctx.poll` iteration has its events removed by
3756
+ // recorderTruncate, so its recording would be an unreachable orphan in
3757
+ // the UI (and a polled exec would ship one dead cast per attempt).
3758
+ const referencedSessions = new Set();
3759
+ for (const ev of events) {
3760
+ const sid = ev.sessionId;
3761
+ if (typeof sid === "string")
3762
+ referencedSessions.add(sid);
3763
+ }
3764
+ return {
3765
+ status: outcome.status,
3766
+ durationMs,
3767
+ log: chunks.join(""),
3768
+ events,
3769
+ browserSessions: sessions.map((s) => s.record),
3770
+ terminalSessions: terminalSessions.filter((s) => referencedSessions.has(s.sessionId)),
3771
+ serviceLogDeltas,
3772
+ error: outcome.error,
3773
+ };
3774
+ }
3775
+ const EVAL_DIR = path.join(APP_DIR, ".spectest-eval");
3776
+ // Persistent state across eval calls. Mutated by snippets via the
3777
+ // `state` global; survives until the daemon process restarts.
3778
+ const EVAL_STATE = {};
3779
+ // Transpiler instance reused for `scanImports`. We don't transpile the
3780
+ // user code — Bun runs the .ts file directly — but scanImports gives us
3781
+ // the imports so we can auto-install missing deps.
3782
+ const SCAN_TRANSPILER = new Bun.Transpiler({ loader: "ts" });
3783
+ function safeSerialize(v) {
3784
+ if (v === undefined)
3785
+ return undefined;
3786
+ try {
3787
+ return JSON.parse(JSON.stringify(v));
3788
+ }
3789
+ catch {
3790
+ return String(v);
3791
+ }
3792
+ }
3793
+ /** Top-level package name from an import specifier. */
3794
+ function packageName(spec) {
3795
+ if (spec.startsWith("@")) {
3796
+ return spec.split("/").slice(0, 2).join("/");
3797
+ }
3798
+ return spec.split("/")[0];
3799
+ }
3800
+ /**
3801
+ * Scan the snippet's imports and `bun add` anything that doesn't already
3802
+ * resolve. Skips relative paths, absolute paths, `node:`/`bun:` built-ins,
3803
+ * and HTTP(S)/file: URLs.
3804
+ */
3805
+ async function ensureDeps(code) {
3806
+ let scanned;
3807
+ try {
3808
+ scanned = SCAN_TRANSPILER.scanImports(code);
3809
+ }
3810
+ catch {
3811
+ // Invalid syntax — let the import call surface the real error.
3812
+ return [];
3813
+ }
3814
+ const seen = new Set();
3815
+ const missing = [];
3816
+ for (const imp of scanned) {
3817
+ const p = imp.path;
3818
+ if (p.startsWith(".") ||
3819
+ p.startsWith("/") ||
3820
+ p.startsWith("node:") ||
3821
+ p.startsWith("bun:") ||
3822
+ p.startsWith("http:") ||
3823
+ p.startsWith("https:") ||
3824
+ p.startsWith("file:")) {
3825
+ continue;
3826
+ }
3827
+ const pkg = packageName(p);
3828
+ if (seen.has(pkg))
3829
+ continue;
3830
+ seen.add(pkg);
3831
+ try {
3832
+ Bun.resolveSync(p, APP_DIR);
3833
+ }
3834
+ catch {
3835
+ missing.push(pkg);
3836
+ }
3837
+ }
3838
+ if (missing.length === 0)
3839
+ return [];
3840
+ await new Promise((resolve, reject) => {
3841
+ execFile("/usr/local/bin/bun", ["add", ...missing], { cwd: APP_DIR, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
3842
+ if (err) {
3843
+ reject(new Error(`bun add ${missing.join(" ")} failed:\n${String(stderr).trim()}\n${String(stdout).trim()}`));
3844
+ }
3845
+ else {
3846
+ resolve();
3847
+ }
3848
+ });
3849
+ });
3850
+ return missing;
3851
+ }
3852
+ /**
3853
+ * Turn the bare parser error an `export default` misuse produces
3854
+ * ("Unexpected export") into something actionable.
3855
+ *
3856
+ * An eval snippet is imported as a **real ESM module**, so `export
3857
+ * default` is a module-level declaration: there can be exactly one, and
3858
+ * it cannot sit inside an `if`/`try`/loop body. Early-return style —
3859
+ * `if (!x) { out.y = z; export default out; }` — is therefore a syntax
3860
+ * error, and the raw message says nothing about why or what to do.
3861
+ *
3862
+ * We only ever *augment* a message that already failed, and only when
3863
+ * the snippet actually uses `export default`, so a genuine unrelated
3864
+ * syntax error keeps its own text.
3865
+ */
3866
+ function explainEvalExportError(code, message) {
3867
+ if (!/unexpected export|export declarations?|'export'|"export"/i.test(message)) {
3868
+ return message;
3869
+ }
3870
+ // Not line-anchored: the early-return idiom this exists to explain puts
3871
+ // the offending export mid-line (`if (!x) { …; export default out; }`),
3872
+ // which an `^`-anchored match would miss entirely.
3873
+ const matches = [...code.matchAll(/\bexport\s+default\s/g)];
3874
+ if (matches.length === 0)
3875
+ return message;
3876
+ // Anything but whitespace before it on its own line means it's nested in
3877
+ // a block or statement rather than declared at the top level.
3878
+ const nested = matches.some((m) => {
3879
+ const lineStart = code.lastIndexOf("\n", m.index) + 1;
3880
+ return code.slice(lineStart, m.index).trim().length > 0;
3881
+ });
3882
+ const counted = matches.length > 1
3883
+ ? `this snippet has ${matches.length} \`export default\` statements, but a module may only have one`
3884
+ : null;
3885
+ const placed = nested
3886
+ ? "`export default` appears inside a block (`if`/`try`/loop), where a module-level declaration isn't allowed"
3887
+ : null;
3888
+ const reason = counted && placed
3889
+ ? `${counted}, and ${placed}`
3890
+ : (counted ?? placed ?? "`export default` isn't at the module's top level");
3891
+ return (`${message}\n\n` +
3892
+ `spectest: an eval snippet is imported as a real ES module, and ${reason}. ` +
3893
+ "For early-return style, compute the value in a function and export its result once:\n\n" +
3894
+ " export default await (async () => {\n" +
3895
+ " const out = {};\n" +
3896
+ " if (!x) return { ...out, y: z }; // early return, not early export\n" +
3897
+ " out.more = await ctx.exec(\"web\", \"…\");\n" +
3898
+ " return out;\n" +
3899
+ " })();\n");
3900
+ }
3901
+ async function evalCode(code, secrets) {
3902
+ const start = Date.now();
3903
+ // Eval-scoped secret channel for record-mode fakes — set before the
3904
+ // snippet runs, cleared in the `finally` below so a secret never
3905
+ // persists into daemon memory (and thus into a forkable snapshot) past
3906
+ // the eval that supplied it. See record-secrets.ts.
3907
+ setRecordSecrets(secrets);
3908
+ const chunks = [];
3909
+ const origStdout = process.stdout.write.bind(process.stdout);
3910
+ const origStderr = process.stderr.write.bind(process.stderr);
3911
+ const capture = (s) => {
3912
+ chunks.push(typeof s === "string" ? s : Buffer.from(s).toString("utf8"));
3913
+ return true;
3914
+ };
3915
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
3916
+ process.stdout.write = capture;
3917
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
3918
+ process.stderr.write = capture;
3919
+ // Bun's console.* bypasses process.stdout.write — tee it too.
3920
+ const restoreConsole = captureConsole(chunks);
3921
+ // Wrap fetch for the snippet's duration so `ctx.fetch` returns a wrapped
3922
+ // Response just like in a test (no recorder here, so no provenance — but the
3923
+ // wrapped type is honest at runtime). Restored in the `finally` below.
3924
+ const restoreFetch = installFetchWrapper();
3925
+ // Same persistent acquire/detach as a test run (see runOne): the browser
3926
+ // survives the eval, so successive `spectest env eval` calls continue one
3927
+ // live session — and a snapshot taken afterwards carries it.
3928
+ const browserDetaches = [];
3929
+ const sessions = [];
3930
+ // Eval-only artifact sink — wiring it here (and nowhere in runOne) is
3931
+ // what gates screenshot() to eval context.
3932
+ const artifactCollector = newArtifactCollector();
3933
+ let sharedBrowser = null;
3934
+ const sharedMobiles = new Map();
3935
+ const trackedOpenBrowser = async (opts) => {
3936
+ if (sharedBrowser)
3937
+ return sharedBrowser;
3938
+ const session = newBrowserSession(start, "eval", "browser", artifactCollector);
3939
+ sessions.push(session);
3940
+ const { browser, attached, detach } = await acquirePersistentBrowser({
3941
+ ...(opts ?? {}),
3942
+ recorder: session.recorder,
3943
+ });
3944
+ if (browser.safeAreaInsets) {
3945
+ session.record.safeAreaInsets = browser.safeAreaInsets;
3946
+ }
3947
+ if (attached && session.record.initialUrl === undefined) {
3948
+ session.record.initialUrl = browser.url();
3949
+ }
3950
+ browserDetaches.push(async () => {
3951
+ await detach();
3952
+ session.markClosed();
3953
+ });
3954
+ const innerClose = browser.close.bind(browser);
3955
+ browser.close = async () => {
3956
+ await innerClose();
3957
+ if (sharedBrowser === browser)
3958
+ sharedBrowser = null;
3959
+ };
3960
+ sharedBrowser = browser;
3961
+ return browser;
3962
+ };
3963
+ const trackedOpenMobile = async (app) => {
3964
+ if (!isMobileApp(app)) {
3965
+ throw new Error("ctx.mobile(app): pass a mobile-app handle from ctx.svc, e.g. ctx.mobile(ctx.svc.app) for a service declared with expo().");
3966
+ }
3967
+ const existing = sharedMobiles.get(app.url);
3968
+ if (existing)
3969
+ return existing;
3970
+ const session = newBrowserSession(start, "eval", "mobile", artifactCollector);
3971
+ sessions.push(session);
3972
+ const { mobile, attached, detach, safeAreaInsets } = await openPersistentMobile({
3973
+ url: app.url,
3974
+ recorder: session.recorder,
3975
+ initScript: app.initScript,
3976
+ });
3977
+ if (safeAreaInsets)
3978
+ session.record.safeAreaInsets = safeAreaInsets;
3979
+ if (attached && session.record.initialUrl === undefined) {
3980
+ session.record.initialUrl = mobile.url();
3981
+ }
3982
+ browserDetaches.push(async () => {
3983
+ await detach();
3984
+ session.markClosed();
3985
+ });
3986
+ const innerClose = mobile.close.bind(mobile);
3987
+ mobile.close = async () => {
3988
+ await innerClose();
3989
+ if (sharedMobiles.get(app.url) === mobile)
3990
+ sharedMobiles.delete(app.url);
3991
+ };
3992
+ sharedMobiles.set(app.url, mobile);
3993
+ return mobile;
3994
+ };
3995
+ // Terminal sessions — same shape as runOne, but eval has no active
3996
+ // recorder so we don't emit inline events; the asciicast frames
3997
+ // still ship back on EvalResult.terminalSessions and the web UI
3998
+ // renders the player.
3999
+ const terminalSessions = [];
4000
+ const evalTerminal = async (service, command, opts) => {
4001
+ const timeoutMs = opts?.timeoutMs ?? DEFAULT_TEST_TIMEOUT_MS;
4002
+ const startedAt = Date.now();
4003
+ const term = await openInstrumentedTerminal(service, { ...opts, command, timeoutMs }, start, terminalSessions, false, "eval");
4004
+ const { exitCode } = (await term.exited).unwrap();
4005
+ await term.close();
4006
+ return {
4007
+ output: term.rawOutput(),
4008
+ exitCode,
4009
+ durationMs: Date.now() - startedAt,
4010
+ sessionId: term.sessionId,
4011
+ };
4012
+ };
4013
+ const evalOpenTerminal = async (service, opts) => {
4014
+ return openInstrumentedTerminal(service, opts, start, terminalSessions, false, "eval");
4015
+ };
4016
+ // Convenience handles are best-effort for eval — if the project isn't
4017
+ // loaded yet, fall back to an empty map so quick `await fetch(...)`
4018
+ // snippets don't require a /load round-trip first.
4019
+ const svc = loaded
4020
+ ? await buildServiceHandles(loaded.project.environment)
4021
+ : {};
4022
+ const fakes = loaded ? await buildFakeHandles() : {};
4023
+ const ctx = {
4024
+ // installFetchWrapper swapped globalThis.fetch above, so this captures the
4025
+ // wrapped version — eval results are wrapped just like in a test.
4026
+ fetch: globalThis.fetch,
4027
+ // Wraps its result the same way (no recorder under eval, so no provenance —
4028
+ // but the wrapped type is honest at runtime, so `.unwrap()` works).
4029
+ exec: execInServiceWrapped,
4030
+ terminal: evalTerminal,
4031
+ openTerminal: evalOpenTerminal,
4032
+ browser: trackedOpenBrowser,
4033
+ mobile: trackedOpenMobile,
4034
+ testName: "eval",
4035
+ parent: undefined,
4036
+ svc,
4037
+ fakes,
4038
+ poll: pollCall,
4039
+ dnsName: registerDnsName,
4040
+ certificate: mintCertificate,
4041
+ startService: startRuntimeService,
4042
+ stopService: stopRuntimeService,
4043
+ };
4044
+ // Expose the test context, matchers, and persistent state as globals
4045
+ // so the snippet can use them without an explicit import. The user code
4046
+ // is real ESM, so `import { Client } from "pg"` and top-level `await`
4047
+ // work natively.
4048
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
4049
+ const g = globalThis;
4050
+ g.ctx = ctx;
4051
+ g.expect = expect;
4052
+ g.expectRaw = expectRaw;
4053
+ g.assert = assert;
4054
+ g.state = EVAL_STATE;
4055
+ let installed = [];
4056
+ let filePath;
4057
+ let outcome;
4058
+ try {
4059
+ installed = await ensureDeps(code);
4060
+ await fs.mkdir(EVAL_DIR, { recursive: true });
4061
+ filePath = path.join(EVAL_DIR, `${randomUUID()}.ts`);
4062
+ await fs.writeFile(filePath, code);
4063
+ const mod = (await import(pathToFileURL(filePath).href));
4064
+ outcome = { ok: true, result: safeSerialize(mod.default) };
4065
+ }
4066
+ catch (err) {
4067
+ const e = err;
4068
+ const message = e.message ?? String(err);
4069
+ outcome = {
4070
+ ok: false,
4071
+ error: { message: explainEvalExportError(code, message), stack: e.stack },
4072
+ };
4073
+ }
4074
+ finally {
4075
+ clearRecordSecrets();
4076
+ restoreFetch();
4077
+ restoreConsole();
4078
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
4079
+ process.stdout.write = origStdout;
4080
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
4081
+ process.stderr.write = origStderr;
4082
+ // Detach (final rrweb drain) — the browser itself stays alive; see
4083
+ // the acquire comment above.
4084
+ for (const detach of browserDetaches) {
4085
+ try {
4086
+ await detach();
4087
+ }
4088
+ catch {
4089
+ /* ignore */
4090
+ }
4091
+ }
4092
+ for (const s of sessions)
4093
+ s.markClosed();
4094
+ if (filePath) {
4095
+ fs.unlink(filePath).catch(() => {
4096
+ /* best-effort cleanup */
4097
+ });
4098
+ }
4099
+ }
4100
+ // `artifacts` ships on the error branch too — a screenshot captured
4101
+ // before a snippet crash is exactly the evidence the caller wants.
4102
+ return outcome.ok
4103
+ ? {
4104
+ ok: true,
4105
+ durationMs: Date.now() - start,
4106
+ log: chunks.join(""),
4107
+ installed,
4108
+ result: outcome.result,
4109
+ browserSessions: sessions.map((s) => s.record),
4110
+ terminalSessions,
4111
+ artifacts: artifactCollector.artifacts,
4112
+ }
4113
+ : {
4114
+ ok: false,
4115
+ durationMs: Date.now() - start,
4116
+ log: chunks.join(""),
4117
+ installed,
4118
+ browserSessions: sessions.map((s) => s.record),
4119
+ terminalSessions,
4120
+ artifacts: artifactCollector.artifacts,
4121
+ error: outcome.error,
4122
+ };
4123
+ }
4124
+ // ────────────────────────────────────────────────────────────────────────
4125
+ // Typecheck
4126
+ //
4127
+ // `tsc --noEmit` over the user's spectest/ code (APP_DIR — the only tree the
4128
+ // daemon imports; app-under-test source has its own toolchain). Kicked off in
4129
+ // the background at /load-tests so it never sits on the start/run critical
4130
+ // path; the control plane collects the result via POST /typecheck (which
4131
+ // awaits the in-flight run) and attaches it to the SuiteResult as an
4132
+ // advisory report. It must never gate a run: the runtime accepts patterns
4133
+ // the types reject (e.g. bare global `fetch` is monkey-patched to record,
4134
+ // but its *type* stays raw), so a type error is a strong hint, not proof.
4135
+ //
4136
+ // The compiler is the native tsc baked into the base snapshot at
4137
+ // /opt/spectest/typecheck (see base.rs BASE_SETUP_SH); a project that ships
4138
+ // its own `typescript` in spectest/package.json wins. A project tsconfig.json
4139
+ // wins over the generated one the same way.
4140
+ // ────────────────────────────────────────────────────────────────────────
4141
+ const TYPECHECK_DIR = "/opt/spectest/typecheck";
4142
+ /** Cap on errors shipped in the report; `totalErrors` carries the true count. */
4143
+ const TYPECHECK_ERROR_CAP = 50;
4144
+ let TYPECHECK = null;
4145
+ /** Fire-and-forget kickoff; the promise is parked for POST /typecheck. */
4146
+ function startTypecheck() {
4147
+ TYPECHECK = runTypecheck().catch((err) => ({
4148
+ status: "failed",
4149
+ errors: [],
4150
+ totalErrors: 0,
4151
+ durationMs: 0,
4152
+ detail: err instanceof Error ? err.message : String(err),
4153
+ }));
4154
+ // Parked promises must never surface as unhandled rejections.
4155
+ TYPECHECK.catch(() => { });
4156
+ }
4157
+ /** The tsconfig used when the project doesn't ship its own. `lib` is left to
4158
+ * the target default (which includes DOM — browser.evaluate callbacks
4159
+ * reference `document`); `types` pulls Bun's ambient globals from the baked
4160
+ * install via `typeRoots` (the walk-up from APP_DIR never reaches it). */
4161
+ function generatedTsconfig() {
4162
+ return JSON.stringify({
4163
+ compilerOptions: {
4164
+ target: "esnext",
4165
+ module: "esnext",
4166
+ moduleResolution: "bundler",
4167
+ strict: true,
4168
+ noEmit: true,
4169
+ skipLibCheck: true,
4170
+ esModuleInterop: true,
4171
+ resolveJsonModule: true,
4172
+ jsx: "react-jsx",
4173
+ types: ["bun"],
4174
+ typeRoots: [
4175
+ path.join(APP_DIR, "node_modules", "@types"),
4176
+ path.join(TYPECHECK_DIR, "node_modules", "@types"),
4177
+ ],
4178
+ },
4179
+ include: ["**/*.ts", "**/*.tsx"],
4180
+ exclude: ["node_modules"],
4181
+ }, null, 2);
4182
+ }
4183
+ /** Parse `--pretty false` tsc output: `file(line,col): error TScode: msg`,
4184
+ * with indented elaboration lines folded into the preceding error. */
4185
+ function parseTscOutput(output) {
4186
+ const errors = [];
4187
+ let total = 0;
4188
+ let suppressed = 0;
4189
+ let last = null;
4190
+ for (const line of output.split("\n")) {
4191
+ const m = /^(.+?)\((\d+),(\d+)\): error (TS\d+): (.*)$/.exec(line);
4192
+ if (!m) {
4193
+ // Elaboration lines are indented; fold them into the last kept error.
4194
+ if (last && /^\s+\S/.test(line))
4195
+ last.message += `\n${line.trimEnd()}`;
4196
+ continue;
4197
+ }
4198
+ const [, rawFile, ln, col, code, message] = m;
4199
+ // Keep only diagnostics in the user's files. tsc runs with cwd=APP_DIR
4200
+ // (the daemon's cwd), so project files come out relative ("tests/x.ts")
4201
+ // and anything outside — the SDK at /opt/spectest/sdk, node_modules —
4202
+ // is `../…` or absolute. Errors in our own SDK are ours to fix, not the
4203
+ // user's to read.
4204
+ const abs = path.resolve(APP_DIR, rawFile);
4205
+ const inApp = abs.startsWith(APP_DIR + path.sep) && !abs.includes(`${path.sep}node_modules${path.sep}`);
4206
+ if (!inApp) {
4207
+ suppressed += 1;
4208
+ last = null;
4209
+ continue;
4210
+ }
4211
+ total += 1;
4212
+ const err = {
4213
+ file: path.relative(APP_DIR, abs),
4214
+ line: Number(ln),
4215
+ column: Number(col),
4216
+ code: code,
4217
+ message: message,
4218
+ };
4219
+ if (errors.length < TYPECHECK_ERROR_CAP) {
4220
+ errors.push(err);
4221
+ last = err;
4222
+ }
4223
+ else {
4224
+ last = null;
4225
+ }
4226
+ }
4227
+ return { errors, total, suppressed };
4228
+ }
4229
+ async function runTypecheck() {
4230
+ const started = Date.now();
4231
+ // The project's own typescript wins over the baked copy.
4232
+ const tsc = [
4233
+ path.join(APP_DIR, "node_modules", "typescript", "bin", "tsc"),
4234
+ path.join(TYPECHECK_DIR, "node_modules", "typescript", "bin", "tsc"),
4235
+ ].find((p) => existsSync(p));
4236
+ if (!tsc) {
4237
+ return {
4238
+ status: "skipped",
4239
+ errors: [],
4240
+ totalErrors: 0,
4241
+ durationMs: 0,
4242
+ detail: "typescript is not installed (base image predates the baked typechecker)",
4243
+ };
4244
+ }
4245
+ let config = path.join(APP_DIR, "tsconfig.json");
4246
+ if (!existsSync(config)) {
4247
+ config = path.join(APP_DIR, ".spectest-tsconfig.json");
4248
+ await fs.writeFile(config, generatedTsconfig());
4249
+ }
4250
+ const res = await shx(process.execPath, [tsc, "--noEmit", "--pretty", "false", "-p", config], 120_000);
4251
+ const durationMs = Date.now() - started;
4252
+ if (res.code === 124) {
4253
+ return { status: "failed", errors: [], totalErrors: 0, durationMs, detail: "typecheck timed out" };
4254
+ }
4255
+ const { errors, total, suppressed } = parseTscOutput(res.stdout + res.stderr);
4256
+ if (errors.length === 0) {
4257
+ // Exit 0 → clean. Non-zero with no *user-file* diagnostics is either
4258
+ // all-suppressed (still ok from the user's perspective) or a compiler
4259
+ // crash (config not found, OOM) — surface the latter.
4260
+ if (res.code !== 0 && suppressed === 0) {
4261
+ const tail = (res.stderr || res.stdout).trim().split("\n").slice(-5).join("\n");
4262
+ return { status: "failed", errors: [], totalErrors: 0, durationMs, detail: tail || `tsc exited ${res.code}` };
4263
+ }
4264
+ if (suppressed > 0) {
4265
+ console.warn(`[typecheck] ${suppressed} diagnostic(s) outside the project suppressed`);
4266
+ }
4267
+ return { status: "ok", errors: [], totalErrors: 0, durationMs };
4268
+ }
4269
+ return { status: "errors", errors, totalErrors: total, durationMs };
4270
+ }
4271
+ // ────────────────────────────────────────────────────────────────────────
4272
+ // HTTP server
4273
+ // ────────────────────────────────────────────────────────────────────────
4274
+ async function readBody(req) {
4275
+ return new Promise((resolve, reject) => {
4276
+ const chunks = [];
4277
+ req.on("data", (c) => chunks.push(c));
4278
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
4279
+ req.on("error", reject);
4280
+ });
4281
+ }
4282
+ function jsonResponse(res, status, body) {
4283
+ const payload = Buffer.from(JSON.stringify(body));
4284
+ res.writeHead(status, {
4285
+ "content-type": "application/json",
4286
+ "content-length": payload.length,
4287
+ });
4288
+ res.end(payload);
4289
+ }
4290
+ async function handle(req, res, state) {
4291
+ const url = req.url ?? "";
4292
+ const method = req.method ?? "GET";
4293
+ if (method === "GET" && url === "/health") {
4294
+ res.writeHead(200, { "content-type": "text/plain" });
4295
+ res.end("ok\n");
4296
+ return;
4297
+ }
4298
+ if (method === "GET" && url === "/progress") {
4299
+ // Live bootstrap progress, polled by the control plane during
4300
+ // /bootstrap and streamed into the test-run row. `{}` before the
4301
+ // first bootstrap() of this daemon.
4302
+ jsonResponse(res, 200, BOOTSTRAP_PROGRESS ?? {});
4303
+ return;
4304
+ }
4305
+ if (method === "GET" && url.startsWith("/env-logs")) {
4306
+ // Live logs for a running environment: the daemon's own boot log
4307
+ // (bootstrap + every `setup` hook's console output) plus each
4308
+ // service's container logs. Unlike the per-case deltas this reads
4309
+ // without touching LOG_MARKERS, so calling it never perturbs a run.
4310
+ const q = new URL(url, "http://daemon").searchParams;
4311
+ const only = q.get("service") ?? undefined;
4312
+ const tail = Math.max(1, Math.min(10_000, Number(q.get("tail") ?? "200") || 200));
4313
+ const l = loaded;
4314
+ const byName = new Map();
4315
+ if (l)
4316
+ for (const s of namedServices(l.project.environment))
4317
+ byName.set(s.name, s);
4318
+ for (const [name, s] of RUNTIME_SERVICES)
4319
+ byName.set(name, s);
4320
+ const names = [...byName.keys()].filter((n) => !only || n === only);
4321
+ if (only && names.length === 0) {
4322
+ jsonResponse(res, 404, {
4323
+ error: `unknown service ${JSON.stringify(only)}; known: ${[...byName.keys()].join(", ") || "(none)"}`,
4324
+ });
4325
+ return;
4326
+ }
4327
+ const services = await Promise.all(names.map(async (name) => {
4328
+ const r = await docker(["logs", "--timestamps", `--tail=${tail}`, name], 30_000);
4329
+ const out = capMiddle(r.stdout, LOG_DELTA_MAX_BYTES);
4330
+ const err = capMiddle(r.code === 0 ? r.stderr : r.stderr || r.stdout, LOG_DELTA_MAX_BYTES);
4331
+ return {
4332
+ service: name,
4333
+ stdout: out.value,
4334
+ stdoutTruncated: out.truncated,
4335
+ stderr: err.value,
4336
+ stderrTruncated: err.truncated,
4337
+ };
4338
+ }));
4339
+ jsonResponse(res, 200, { bootLog: bootLogText(), services });
4340
+ return;
4341
+ }
4342
+ if (method === "POST" && url === "/load") {
4343
+ // Env only — services/fakes/setup. For the legacy single-file layout the
4344
+ // entry also defines the tests, so `cases` is populated here; for the
4345
+ // split layout `cases` is empty until /load-tests runs.
4346
+ const proj = await loadEnv();
4347
+ jsonResponse(res, 200, {
4348
+ environment: proj.environment,
4349
+ cases: casesMetadata(proj.tests),
4350
+ fakes: fakesSummary(proj),
4351
+ });
4352
+ return;
4353
+ }
4354
+ if (method === "POST" && url === "/load-tests") {
4355
+ // Import spectest/tests/** into the already-loaded env and return the
4356
+ // resulting catalogue. Called after the warm snapshot (cold path) or
4357
+ // against a freshly restored VM (warm path).
4358
+ await loadTests();
4359
+ // Background typecheck of the freshly-uploaded app tree — both paths
4360
+ // (cold and warm) funnel through here after the upload, so the check
4361
+ // always sees the current code. Never on the critical path: the reply
4362
+ // doesn't wait, POST /typecheck collects.
4363
+ startTypecheck();
4364
+ const l = requireLoaded();
4365
+ jsonResponse(res, 200, {
4366
+ environment: l.project.environment,
4367
+ cases: casesMetadata(l.project.tests),
4368
+ fakes: fakesSummary(l.project),
4369
+ });
4370
+ return;
4371
+ }
4372
+ if (method === "POST" && url === "/typecheck") {
4373
+ // Await the run kicked off at /load-tests (or start one on demand —
4374
+ // e.g. a legacy single-file project loaded before this daemon shipped
4375
+ // the kickoff, or a direct debug call).
4376
+ if (!TYPECHECK)
4377
+ startTypecheck();
4378
+ jsonResponse(res, 200, await TYPECHECK);
4379
+ return;
4380
+ }
4381
+ if (method === "POST" && url === "/unload") {
4382
+ loaded = null;
4383
+ jsonResponse(res, 200, { unloaded: true });
4384
+ return;
4385
+ }
4386
+ if (method === "POST" && url === "/reload") {
4387
+ // Full reload (debug aid): re-import the env, then the tests.
4388
+ await loadEnv();
4389
+ await loadTests();
4390
+ const l = requireLoaded();
4391
+ jsonResponse(res, 200, {
4392
+ environment: l.project.environment,
4393
+ cases: casesMetadata(l.project.tests),
4394
+ fakes: fakesSummary(l.project),
4395
+ });
4396
+ return;
4397
+ }
4398
+ if (method === "GET" && url === "/env-config") {
4399
+ const l = requireLoaded();
4400
+ jsonResponse(res, 200, l.project.environment);
4401
+ return;
4402
+ }
4403
+ if (method === "GET" && url === "/cases") {
4404
+ const l = requireLoaded();
4405
+ jsonResponse(res, 200, { cases: casesMetadata(l.project.tests) });
4406
+ return;
4407
+ }
4408
+ if (method === "GET" && url === "/record-secret-refs") {
4409
+ // Union of platform secret refs the loaded fakes declare (replayFake's
4410
+ // `secretRefs`). The control plane resolves these server-side and
4411
+ // pushes the values on the eval path only. Empty if nothing's loaded.
4412
+ const refs = new Set();
4413
+ for (const fake of FAKES.values()) {
4414
+ for (const ref of fake.def.secretRefs ?? [])
4415
+ refs.add(ref);
4416
+ }
4417
+ jsonResponse(res, 200, { refs: [...refs] });
4418
+ return;
4419
+ }
4420
+ if (method === "POST" && url === "/bootstrap") {
4421
+ if (state.inFlightBootstrap) {
4422
+ jsonResponse(res, 409, { error: "bootstrap already in progress" });
4423
+ return;
4424
+ }
4425
+ const job = bootstrap();
4426
+ state.inFlightBootstrap = job;
4427
+ try {
4428
+ const timings = await job;
4429
+ jsonResponse(res, 200, { ok: true, timings });
4430
+ }
4431
+ finally {
4432
+ state.inFlightBootstrap = null;
4433
+ }
4434
+ return;
4435
+ }
4436
+ if (method === "POST" && url === "/project-setup") {
4437
+ if (state.inFlightProjectSetup) {
4438
+ jsonResponse(res, 409, { error: "project-setup already in progress" });
4439
+ return;
4440
+ }
4441
+ const job = runProjectSetup();
4442
+ state.inFlightProjectSetup = job;
4443
+ try {
4444
+ const result = await job;
4445
+ jsonResponse(res, 200, result);
4446
+ }
4447
+ finally {
4448
+ state.inFlightProjectSetup = null;
4449
+ }
4450
+ return;
4451
+ }
4452
+ if (method === "POST" && url === "/eval") {
4453
+ if (state.inFlightTest) {
4454
+ jsonResponse(res, 409, { error: "a test or eval is already running" });
4455
+ return;
4456
+ }
4457
+ const body = await readBody(req);
4458
+ let parsed;
4459
+ try {
4460
+ parsed = JSON.parse(body || "{}");
4461
+ }
4462
+ catch {
4463
+ jsonResponse(res, 400, { error: "invalid JSON body" });
4464
+ return;
4465
+ }
4466
+ const code = parsed.code;
4467
+ if (typeof code !== "string" || code.length === 0) {
4468
+ jsonResponse(res, 400, { error: "code (string) is required" });
4469
+ return;
4470
+ }
4471
+ // `secrets` are eval-scoped: the control plane resolves the loaded
4472
+ // project's declared `replayFake` refs and pushes the values here on
4473
+ // the eval path only. Never present on the /run (test) path.
4474
+ const exec = evalCode(code, parsed.secrets);
4475
+ state.inFlightTest = exec;
4476
+ try {
4477
+ const result = await exec;
4478
+ jsonResponse(res, 200, result);
4479
+ }
4480
+ finally {
4481
+ state.inFlightTest = null;
4482
+ }
4483
+ return;
4484
+ }
4485
+ if (method === "POST" && url === "/capture-log-baseline") {
4486
+ // Snapshot each service's log output produced during env bring-up
4487
+ // (container startup + project setup), advancing the log markers so the
4488
+ // subsequent per-test deltas start AFTER setup. The control plane calls
4489
+ // this once, on the main env, before the first test runs — see
4490
+ // tests.rs::capture_log_baseline. Best-effort: on failure the setup lines
4491
+ // simply fold into the first test's delta as before.
4492
+ let serviceLogDeltas = [];
4493
+ try {
4494
+ serviceLogDeltas = await captureServiceLogDeltas();
4495
+ }
4496
+ catch (err) {
4497
+ // eslint-disable-next-line no-console
4498
+ console.warn("[service-logs] baseline capture failed:", err);
4499
+ }
4500
+ jsonResponse(res, 200, { serviceLogDeltas });
4501
+ return;
4502
+ }
4503
+ if (method === "POST" && url === "/run") {
4504
+ if (state.inFlightTest) {
4505
+ jsonResponse(res, 409, { error: "another test is already running" });
4506
+ return;
4507
+ }
4508
+ const body = await readBody(req);
4509
+ let parsed;
4510
+ try {
4511
+ parsed = JSON.parse(body || "{}");
4512
+ }
4513
+ catch {
4514
+ jsonResponse(res, 400, { error: "invalid JSON body" });
4515
+ return;
4516
+ }
4517
+ const caseId = parsed.caseId;
4518
+ if (!caseId) {
4519
+ jsonResponse(res, 400, { error: "caseId is required" });
4520
+ return;
4521
+ }
4522
+ const l = requireLoaded();
4523
+ const tc = l.byId.get(caseId);
4524
+ if (!tc) {
4525
+ jsonResponse(res, 404, { error: `unknown caseId: ${caseId}` });
4526
+ return;
4527
+ }
4528
+ const exec = runOne(tc);
4529
+ state.inFlightTest = exec;
4530
+ try {
4531
+ const result = await exec;
4532
+ // Recordings go out-of-band: encode + park the bundle, reply with a
4533
+ // size ref only (see the REPLAY_BUNDLES comment). A bundle-encoding
4534
+ // failure drops the recordings (warn) rather than failing the case —
4535
+ // and never falls back to inlining, which is exactly the >16 MB
4536
+ // response this path exists to avoid.
4537
+ const { browserSessions, ...wire } = result;
4538
+ let replay;
4539
+ if (browserSessions.length > 0) {
4540
+ try {
4541
+ const gz = encodeReplayBundle(caseId, browserSessions);
4542
+ stashReplayBundle(caseId, gz);
4543
+ replay = { bytes: gz.length };
4544
+ }
4545
+ catch (err) {
4546
+ // eslint-disable-next-line no-console
4547
+ console.warn("[replay] bundle encode failed; dropping recordings:", err);
4548
+ }
4549
+ }
4550
+ jsonResponse(res, 200, { ...wire, replay });
4551
+ }
4552
+ finally {
4553
+ state.inFlightTest = null;
4554
+ }
4555
+ return;
4556
+ }
4557
+ if (method === "POST" && url === "/replay-chunk") {
4558
+ // One chunk of a parked replay bundle (see REPLAY_BUNDLES). POST with
4559
+ // a JSON body — case ids are arbitrary user strings, and a body dodges
4560
+ // URL-encoding across both providers' daemon_http transports.
4561
+ const body = await readBody(req);
4562
+ let parsed;
4563
+ try {
4564
+ parsed = JSON.parse(body || "{}");
4565
+ }
4566
+ catch {
4567
+ jsonResponse(res, 400, { error: "invalid JSON body" });
4568
+ return;
4569
+ }
4570
+ if (!parsed.caseId) {
4571
+ jsonResponse(res, 400, { error: "caseId is required" });
4572
+ return;
4573
+ }
4574
+ const gz = REPLAY_BUNDLES.get(parsed.caseId);
4575
+ if (!gz) {
4576
+ jsonResponse(res, 404, { error: `no replay bundle for case: ${parsed.caseId}` });
4577
+ return;
4578
+ }
4579
+ jsonResponse(res, 200, replayChunk(gz, parsed.offset, parsed.limit));
4580
+ return;
4581
+ }
4582
+ jsonResponse(res, 404, { error: "not found" });
4583
+ }
4584
+ async function main() {
4585
+ const state = {
4586
+ inFlightTest: null,
4587
+ inFlightBootstrap: null,
4588
+ inFlightProjectSetup: null,
4589
+ };
4590
+ const server = http.createServer((req, res) => {
4591
+ handle(req, res, state).catch((err) => {
4592
+ const e = err;
4593
+ try {
4594
+ jsonResponse(res, 500, { error: e.message ?? String(err), stack: e.stack });
4595
+ }
4596
+ catch {
4597
+ // headers already sent or socket dead
4598
+ }
4599
+ });
4600
+ });
4601
+ const port = Number(process.env.SPECTEST_DAEMON_PORT ?? DEFAULT_PORT);
4602
+ server.listen(port, "0.0.0.0", () => {
4603
+ // eslint-disable-next-line no-console
4604
+ console.log(`spectest-daemon listening on :${port} (idle; awaiting POST /load)`);
4605
+ });
4606
+ }
4607
+ main().catch((err) => {
4608
+ // eslint-disable-next-line no-console
4609
+ console.error("spectest-daemon: fatal:", err);
4610
+ process.exit(1);
4611
+ });