@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.
- package/dist/aws-sigv4.d.ts +42 -0
- package/dist/aws-sigv4.js +166 -0
- package/dist/browser.d.ts +314 -0
- package/dist/browser.js +1320 -0
- package/dist/components/email.d.ts +135 -0
- package/dist/components/email.js +271 -0
- package/dist/components/expo.d.ts +69 -0
- package/dist/components/expo.js +125 -0
- package/dist/components/index.d.ts +8 -0
- package/dist/components/index.js +18 -0
- package/dist/components/k3s.d.ts +172 -0
- package/dist/components/k3s.js +1124 -0
- package/dist/components/postgres.d.ts +93 -0
- package/dist/components/postgres.js +58 -0
- package/dist/components/replayFake.d.ts +169 -0
- package/dist/components/replayFake.js +738 -0
- package/dist/components/s3.d.ts +99 -0
- package/dist/components/s3.js +81 -0
- package/dist/components/supabase.d.ts +197 -0
- package/dist/components/supabase.js +1003 -0
- package/dist/daemon.d.ts +1 -0
- package/dist/daemon.js +4611 -0
- package/dist/ids.d.ts +2 -0
- package/{src/ids.ts → dist/ids.js} +46 -50
- package/dist/index.d.ts +1328 -0
- package/dist/index.js +769 -0
- package/dist/ingress.d.ts +114 -0
- package/dist/ingress.js +210 -0
- package/dist/inspect.d.ts +228 -0
- package/dist/inspect.js +429 -0
- package/dist/locator.d.ts +260 -0
- package/dist/locator.js +293 -0
- package/dist/mobile.d.ts +71 -0
- package/dist/mobile.js +65 -0
- package/dist/record-secrets.d.ts +9 -0
- package/{src/record-secrets.ts → dist/record-secrets.js} +13 -15
- package/dist/recorder.d.ts +527 -0
- package/dist/recorder.js +219 -0
- package/dist/redis.d.ts +54 -0
- package/dist/redis.js +126 -0
- package/dist/replay-bundle.d.ts +38 -0
- package/{src/replay-bundle.ts → dist/replay-bundle.js} +29 -47
- package/dist/resolver.d.ts +1 -0
- package/dist/resolver.js +309 -0
- package/dist/s3.d.ts +89 -0
- package/dist/s3.js +198 -0
- package/dist/sql.d.ts +74 -0
- package/dist/sql.js +151 -0
- package/dist/terminal.d.ts +161 -0
- package/dist/terminal.js +538 -0
- package/package.json +24 -9
- package/src/browser.ts +0 -1819
- package/src/components/email.ts +0 -398
- package/src/components/expo.ts +0 -167
- package/src/components/index.ts +0 -63
- package/src/components/k3s.ts +0 -1312
- package/src/components/postgres.ts +0 -105
- package/src/components/replayFake.ts +0 -848
- package/src/components/s3.ts +0 -132
- package/src/components/supabase.ts +0 -1299
- package/src/daemon.ts +0 -4969
- package/src/index.ts +0 -2350
- package/src/ingress.ts +0 -288
- package/src/inspect.ts +0 -673
- package/src/locator.ts +0 -594
- package/src/mobile.ts +0 -133
- package/src/recorder.ts +0 -817
- package/src/redis.ts +0 -202
- package/src/resolver.ts +0 -351
- package/src/s3.ts +0 -333
- package/src/sql.ts +0 -243
- package/src/terminal.ts +0 -740
- package/src/vendor/rrweb-plugin-console-record.umd.js +0 -521
- 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
|
+
});
|