@jterrazz/test 7.1.0 → 8.0.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/index.cjs CHANGED
@@ -1,246 +1,191 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  require("./chunk.cjs");
3
- const require_sqlite_adapter = require("./sqlite.adapter.cjs");
3
+ const require_sqlite = require("./sqlite.cjs");
4
4
  const require_mock_of = require("./mock-of.cjs");
5
5
  let node_child_process = require("node:child_process");
6
+ let node_path = require("node:path");
6
7
  let node_fs = require("node:fs");
8
+ let yaml = require("yaml");
7
9
  let node_os = require("node:os");
8
- let node_path = require("node:path");
9
10
  let node_fs_promises = require("node:fs/promises");
10
- let yaml = require("yaml");
11
- //#region src/builder/cli/adapters/exec.adapter.ts
11
+ //#region src/infra/docker/docker-assertion.ts
12
12
  /**
13
- * Build a child-process env from the parent env plus user overrides.
14
- * `null` overrides delete keys (e.g. `INIT_CWD: null`).
13
+ * Fluent assertion builder for Docker containers.
14
+ * Chain async assertions like `toBeRunning()`, `toHaveFile()`, `toHaveMount()`.
15
15
  */
16
- function buildEnv(extra) {
17
- const env = {
18
- ...process.env,
19
- INIT_CWD: void 0
20
- };
21
- if (extra) for (const [key, value] of Object.entries(extra)) if (value === null) delete env[key];
22
- else env[key] = value;
23
- return env;
24
- }
16
+ var DockerAssertion = class {
17
+ container;
18
+ constructor(container) {
19
+ this.container = container;
20
+ }
21
+ /** Assert the container is running */
22
+ async toBeRunning() {
23
+ if (!await this.container.isRunning()) throw new Error("Expected container to be running");
24
+ return this;
25
+ }
26
+ /** Assert the container is NOT running / doesn't exist */
27
+ async toNotExist() {
28
+ if (await this.container.isRunning()) throw new Error("Expected container to not exist or not be running");
29
+ return this;
30
+ }
31
+ /** Assert a file exists inside the container */
32
+ async toHaveFile(path, opts) {
33
+ const file = await this.container.file(path);
34
+ if (!file.exists) throw new Error(`Expected file ${path} to exist in container`);
35
+ if (opts?.containing && !file.content.includes(opts.containing)) throw new Error(`Expected file ${path} to contain "${opts.containing}", got: ${file.content.slice(0, 200)}`);
36
+ return this;
37
+ }
38
+ /** Assert a file does NOT exist */
39
+ async toNotHaveFile(path) {
40
+ if (await this.container.exists(path)) throw new Error(`Expected ${path} to not exist in container`);
41
+ return this;
42
+ }
43
+ /** Assert a directory exists */
44
+ async toHaveDirectory(path) {
45
+ if (!await this.container.exists(path)) throw new Error(`Expected directory ${path} to exist in container`);
46
+ return this;
47
+ }
48
+ /** Assert a mount exists */
49
+ async toHaveMount(destination) {
50
+ const info = await this.container.inspect();
51
+ if (!info.hostConfig.mounts.find((m) => m.destination === destination)) {
52
+ const available = info.hostConfig.mounts.map((m) => m.destination).join(", ");
53
+ throw new Error(`Expected mount at ${destination}, found: [${available}]`);
54
+ }
55
+ return this;
56
+ }
57
+ /** Assert network mode */
58
+ async toHaveNetwork(mode) {
59
+ const info = await this.container.inspect();
60
+ if (!info.hostConfig.networkMode.includes(mode)) throw new Error(`Expected network mode "${mode}", got "${info.hostConfig.networkMode}"`);
61
+ return this;
62
+ }
63
+ /** Assert memory limit */
64
+ async toHaveMemoryLimit(bytes) {
65
+ const info = await this.container.inspect();
66
+ if (info.hostConfig.memory !== bytes) throw new Error(`Expected memory limit ${bytes}, got ${info.hostConfig.memory}`);
67
+ return this;
68
+ }
69
+ /** Assert CPU quota */
70
+ async toHaveCpuQuota(quota) {
71
+ const info = await this.container.inspect();
72
+ if (info.hostConfig.cpuQuota !== quota) throw new Error(`Expected CPU quota ${quota}, got ${info.hostConfig.cpuQuota}`);
73
+ return this;
74
+ }
75
+ /** Execute a command and return output for custom assertions */
76
+ async exec(cmd) {
77
+ return this.container.exec(cmd);
78
+ }
79
+ /** Read a file for custom assertions */
80
+ async readFile(path) {
81
+ const file = await this.container.file(path);
82
+ if (!file.exists) throw new Error(`File ${path} does not exist`);
83
+ return file.content;
84
+ }
85
+ /** Get logs for custom assertions */
86
+ async getLogs(tail) {
87
+ return this.container.logs(tail);
88
+ }
89
+ };
90
+ //#endregion
91
+ //#region src/infra/docker/docker.ts
25
92
  /**
26
- * Executes CLI commands via Node.js child_process.
27
- * Uses `execSync` for one-shot commands and `spawn` for long-running processes.
28
- * Used by the `cli()` specification runner.
93
+ * Implements {@link DockerContainerPort} by shelling out to the Docker CLI.
94
+ * Each instance is bound to a single container ID.
29
95
  */
30
- var ExecAdapter = class {
31
- command;
32
- constructor(command) {
33
- this.command = command;
96
+ var DockerAdapter = class {
97
+ containerId;
98
+ constructor(containerId) {
99
+ this.containerId = containerId;
34
100
  }
35
- async exec(args, cwd, extraEnv) {
36
- const env = buildEnv(extraEnv);
101
+ async exec(cmd) {
102
+ return (0, node_child_process.execSync)(`docker exec ${this.containerId} ${cmd.map((c) => `'${c}'`).join(" ")}`, {
103
+ encoding: "utf8",
104
+ timeout: 1e4
105
+ }).trim();
106
+ }
107
+ async file(path) {
37
108
  try {
38
109
  return {
39
- exitCode: 0,
40
- stdout: (0, node_child_process.execSync)(`${this.command} ${args}`, {
41
- cwd,
42
- encoding: "utf8",
43
- env,
44
- stdio: [
45
- "pipe",
46
- "pipe",
47
- "pipe"
48
- ]
49
- }),
50
- stderr: ""
110
+ exists: true,
111
+ content: await this.exec(["cat", path])
51
112
  };
52
- } catch (error) {
113
+ } catch {
53
114
  return {
54
- exitCode: error.status ?? 1,
55
- stdout: error.stdout?.toString() ?? "",
56
- stderr: error.stderr?.toString() ?? ""
115
+ exists: false,
116
+ content: ""
57
117
  };
58
118
  }
59
119
  }
60
- async spawn(args, cwd, options, extraEnv) {
61
- const env = buildEnv(extraEnv);
62
- return new Promise((resolve) => {
63
- let stdout = "";
64
- let stderr = "";
65
- let resolved = false;
66
- const child = (0, node_child_process.spawn)(this.command, args.split(/\s+/).filter(Boolean), {
67
- cwd,
68
- env,
69
- stdio: [
70
- "pipe",
71
- "pipe",
72
- "pipe"
73
- ]
74
- });
75
- const finish = (exitCode) => {
76
- if (resolved) return;
77
- resolved = true;
78
- child.kill("SIGTERM");
79
- resolve({
80
- exitCode,
81
- stdout,
82
- stderr
83
- });
84
- };
85
- let patternMatched = false;
86
- const checkPattern = () => {
87
- if (!patternMatched && (stdout.includes(options.waitFor) || stderr.includes(options.waitFor))) {
88
- patternMatched = true;
89
- finish(0);
90
- }
91
- };
92
- child.stdout?.on("data", (data) => {
93
- stdout += data.toString();
94
- checkPattern();
95
- });
96
- child.stderr?.on("data", (data) => {
97
- stderr += data.toString();
98
- checkPattern();
99
- });
100
- child.on("exit", (code) => {
101
- if (!patternMatched) finish(code === 0 ? 1 : code ?? 1);
102
- });
103
- setTimeout(() => finish(124), options.timeout);
104
- });
120
+ async isRunning() {
121
+ try {
122
+ return (0, node_child_process.execSync)(`docker inspect --format='{{.State.Running}}' ${this.containerId}`, {
123
+ encoding: "utf8",
124
+ timeout: 5e3
125
+ }).trim() === "true";
126
+ } catch {
127
+ return false;
128
+ }
105
129
  }
106
- };
107
- //#endregion
108
- //#region src/builder/http/adapters/fetch.adapter.ts
109
- /**
110
- * Server adapter that sends real HTTP requests via the Fetch API.
111
- * Used by the `e2e()` specification runner to hit a live server.
112
- */
113
- var FetchAdapter = class {
114
- baseUrl;
115
- constructor(url) {
116
- this.baseUrl = url.replace(/\/$/, "");
130
+ async logs(tail) {
131
+ return (0, node_child_process.execSync)(`docker logs ${tail ? `--tail ${tail}` : ""} ${this.containerId}`, {
132
+ encoding: "utf8",
133
+ timeout: 1e4
134
+ });
117
135
  }
118
- async request(method, path, body, headers) {
119
- const init = {
120
- method,
121
- headers: {
122
- "Content-Type": "application/json",
123
- ...headers
124
- }
125
- };
126
- if (body !== void 0) init.body = JSON.stringify(body);
127
- const response = await fetch(`${this.baseUrl}${path}`, init);
128
- const responseBody = await response.json().catch(() => null);
129
- const responseHeaders = {};
130
- response.headers.forEach((value, key) => {
131
- responseHeaders[key] = value;
136
+ async inspect() {
137
+ const raw = (0, node_child_process.execSync)(`docker inspect ${this.containerId}`, {
138
+ encoding: "utf8",
139
+ timeout: 5e3
132
140
  });
141
+ const data = JSON.parse(raw)[0];
133
142
  return {
134
- status: response.status,
135
- body: responseBody,
136
- headers: responseHeaders
143
+ id: data.Id,
144
+ name: data.Name,
145
+ state: {
146
+ running: data.State.Running,
147
+ exitCode: data.State.ExitCode,
148
+ status: data.State.Status
149
+ },
150
+ config: {
151
+ image: data.Config.Image,
152
+ env: data.Config.Env || []
153
+ },
154
+ hostConfig: {
155
+ memory: data.HostConfig.Memory || 0,
156
+ cpuQuota: data.HostConfig.CpuQuota || 0,
157
+ networkMode: data.HostConfig.NetworkMode || "",
158
+ mounts: (data.Mounts || []).map((m) => ({
159
+ source: m.Source,
160
+ destination: m.Destination,
161
+ type: m.Type
162
+ }))
163
+ },
164
+ networkSettings: { networks: Object.fromEntries(Object.entries(data.NetworkSettings.Networks || {}).map(([name, net]) => [name, {
165
+ gateway: net.Gateway,
166
+ ipAddress: net.IPAddress
167
+ }])) }
137
168
  };
138
169
  }
170
+ async exists(path) {
171
+ try {
172
+ await this.exec([
173
+ "test",
174
+ "-e",
175
+ path
176
+ ]);
177
+ return true;
178
+ } catch {
179
+ return false;
180
+ }
181
+ }
139
182
  };
183
+ /** Create a {@link DockerContainerPort} bound to an existing container by ID. */
184
+ function dockerContainer(containerId) {
185
+ return new DockerAdapter(containerId);
186
+ }
140
187
  //#endregion
141
- //#region src/builder/http/adapters/hono.adapter.ts
142
- /**
143
- * Server adapter that dispatches requests in-process through a Hono app instance.
144
- * Used by the `integration()` specification runner -- no network overhead.
145
- */
146
- var HonoAdapter = class {
147
- app;
148
- constructor(app) {
149
- this.app = app;
150
- }
151
- async request(method, path, body, headers) {
152
- const init = {
153
- method,
154
- headers: {
155
- "Content-Type": "application/json",
156
- ...headers
157
- }
158
- };
159
- if (body !== void 0) init.body = JSON.stringify(body);
160
- const response = await this.app.request(path, init);
161
- const responseBody = await response.json().catch(() => null);
162
- const responseHeaders = {};
163
- response.headers.forEach((value, key) => {
164
- responseHeaders[key] = value;
165
- });
166
- return {
167
- status: response.status,
168
- body: responseBody,
169
- headers: responseHeaders
170
- };
171
- }
172
- };
173
- //#endregion
174
- //#region src/builder/common/directory.ts
175
- /**
176
- * Default ignore patterns — paths that should never appear in a tracked snapshot.
177
- * Each entry is matched against any path segment OR a path prefix.
178
- */
179
- const DEFAULT_IGNORES = [
180
- ".git",
181
- ".DS_Store",
182
- "node_modules",
183
- ".next",
184
- "dist",
185
- ".turbo",
186
- ".cache"
187
- ];
188
- /**
189
- * Recursively walk a directory, returning sorted relative paths of files only.
190
- * Ignored entries (default + caller-supplied) are skipped.
191
- */
192
- async function walkDirectory(root, options = {}) {
193
- const ignores = new Set([...DEFAULT_IGNORES, ...options.ignore ?? []]);
194
- const out = [];
195
- async function walk(current) {
196
- let entries;
197
- try {
198
- entries = await (0, node_fs_promises.readdir)(current);
199
- } catch {
200
- return;
201
- }
202
- for (const entry of entries) {
203
- if (ignores.has(entry)) continue;
204
- const abs = (0, node_path.resolve)(current, entry);
205
- const stat = (0, node_fs.statSync)(abs);
206
- if (stat.isDirectory()) await walk(abs);
207
- else if (stat.isFile()) out.push((0, node_path.relative)(root, abs).split(node_path.sep).join("/"));
208
- }
209
- }
210
- await walk(root);
211
- out.sort();
212
- return out;
213
- }
214
- /**
215
- * Compare two directory trees file-by-file.
216
- * Binary files are compared by byte equality but reported without inline diff.
217
- */
218
- async function diffDirectories(expectedRoot, actualRoot, options = {}) {
219
- const expectedFiles = await walkDirectory(expectedRoot, options);
220
- const actualFiles = await walkDirectory(actualRoot, options);
221
- const expectedSet = new Set(expectedFiles);
222
- const actualSet = new Set(actualFiles);
223
- const added = actualFiles.filter((f) => !expectedSet.has(f));
224
- const removed = expectedFiles.filter((f) => !actualSet.has(f));
225
- const changed = [];
226
- for (const file of expectedFiles) {
227
- if (!actualSet.has(file)) continue;
228
- const expected = (0, node_fs.readFileSync)((0, node_path.resolve)(expectedRoot, file), "utf8");
229
- const actual = (0, node_fs.readFileSync)((0, node_path.resolve)(actualRoot, file), "utf8");
230
- if (expected !== actual) changed.push({
231
- actual,
232
- expected,
233
- path: file
234
- });
235
- }
236
- return {
237
- added,
238
- changed,
239
- removed
240
- };
241
- }
242
- //#endregion
243
- //#region src/builder/common/reporter.ts
188
+ //#region src/spec/reporter.ts
244
189
  const GREEN = "\x1B[32m";
245
190
  const RED = "\x1B[31m";
246
191
  const DIM = "\x1B[2m";
@@ -322,6 +267,27 @@ function formatResponseDiff(file, expected, actual) {
322
267
  }
323
268
  return lines.join("\n");
324
269
  }
270
+ function formatStdoutDiff(file, expected, actual) {
271
+ const lines = [];
272
+ lines.push(`Output mismatch (${file})`);
273
+ lines.push("");
274
+ lines.push(`${GREEN}- Expected${RESET}`);
275
+ lines.push(`${RED}+ Received${RESET}`);
276
+ lines.push("");
277
+ const expectedLines = expected.split("\n");
278
+ const actualLines = actual.split("\n");
279
+ const maxLines = Math.max(expectedLines.length, actualLines.length);
280
+ for (let i = 0; i < maxLines; i++) {
281
+ const exp = expectedLines[i];
282
+ const act = actualLines[i];
283
+ if (exp === act) lines.push(` ${exp}`);
284
+ else {
285
+ if (exp !== void 0) lines.push(`${GREEN}- ${exp}${RESET}`);
286
+ if (act !== void 0) lines.push(`${RED}+ ${act}${RESET}`);
287
+ }
288
+ }
289
+ return lines.join("\n");
290
+ }
325
291
  function formatDirectoryDiff(fixtureName, diff, hint) {
326
292
  const lines = [];
327
293
  const total = diff.added.length + diff.removed.length + diff.changed.length;
@@ -369,1123 +335,1669 @@ function rowLabel(n) {
369
335
  function formatRow(row) {
370
336
  return row.map((v) => String(v ?? "null")).join(" | ");
371
337
  }
372
- /** Strip ANSI escape codes from a string. */
373
- function stripAnsi(str) {
374
- return str.replace(/\x1b\[[0-9;]*m/g, "");
338
+ //#endregion
339
+ //#region src/infra/containers/compose-parser.ts
340
+ /**
341
+ * Detect the service type from the image name.
342
+ */
343
+ function detectServiceType(image) {
344
+ if (!image) return "app";
345
+ const lower = image.toLowerCase();
346
+ if (lower.startsWith("postgres")) return "postgres";
347
+ if (lower.startsWith("redis")) return "redis";
348
+ return "unknown";
375
349
  }
376
- /** Strip ANSI codes and replace volatile tokens (ports, durations) with placeholders. */
377
- function normalizeOutput(str) {
378
- return stripAnsi(str).replace(/localhost:\d+/g, "localhost:PORT").replace(/\d+ms/g, "Xms").replace(/\d+\.\d+s/g, "X.Xs").trim();
350
+ /**
351
+ * Find the compose file in the project.
352
+ * Looks for docker/compose.test.yaml or docker-compose.test.yaml.
353
+ */
354
+ function findComposeFile(projectRoot) {
355
+ const candidates = [
356
+ (0, node_path.resolve)(projectRoot, "docker/compose.test.yaml"),
357
+ (0, node_path.resolve)(projectRoot, "docker/compose.test.yml"),
358
+ (0, node_path.resolve)(projectRoot, "docker-compose.test.yaml"),
359
+ (0, node_path.resolve)(projectRoot, "docker-compose.test.yml")
360
+ ];
361
+ for (const candidate of candidates) if ((0, node_fs.existsSync)(candidate)) return candidate;
362
+ return null;
379
363
  }
380
- //#endregion
381
- //#region src/builder/common/directory-accessor.ts
382
364
  /**
383
- * Detect whether the user wants to update snapshots — `true` for any of:
384
- * - vitest run with `-u` / `--update`
385
- * - JTERRAZZ_TEST_UPDATE=1
386
- * - UPDATE_SNAPSHOTS=1
365
+ * Parse a docker-compose file and extract service definitions.
387
366
  */
388
- function shouldUpdateSnapshots() {
389
- if (process.env.JTERRAZZ_TEST_UPDATE === "1") return true;
390
- if (process.env.UPDATE_SNAPSHOTS === "1") return true;
391
- if (process.argv.includes("-u") || process.argv.includes("--update")) return true;
392
- return false;
367
+ function parseComposeFile(filePath) {
368
+ const doc = (0, yaml.parse)((0, node_fs.readFileSync)(filePath, "utf8"));
369
+ if (!doc?.services) return {
370
+ services: [],
371
+ appService: null,
372
+ infraServices: []
373
+ };
374
+ const services = Object.entries(doc.services).map(([name, def]) => {
375
+ const ports = [];
376
+ if (def.ports) for (const port of def.ports) {
377
+ const str = String(port);
378
+ if (str.includes(":")) {
379
+ const [host, container] = str.split(":");
380
+ ports.push({
381
+ container: Number(container),
382
+ host: Number(host)
383
+ });
384
+ } else ports.push({ container: Number(str) });
385
+ }
386
+ const environment = {};
387
+ if (def.environment) if (Array.isArray(def.environment)) for (const env of def.environment) {
388
+ const [key, ...rest] = String(env).split("=");
389
+ environment[key] = rest.join("=");
390
+ }
391
+ else Object.assign(environment, def.environment);
392
+ const volumes = def.volumes ? def.volumes.map((v) => String(v)) : [];
393
+ let dependsOn = [];
394
+ if (def.depends_on) dependsOn = Array.isArray(def.depends_on) ? def.depends_on : Object.keys(def.depends_on);
395
+ return {
396
+ name,
397
+ image: def.image,
398
+ build: def.build,
399
+ ports,
400
+ environment,
401
+ volumes,
402
+ dependsOn
403
+ };
404
+ });
405
+ return {
406
+ services,
407
+ appService: services.find((s) => s.build !== void 0) ?? null,
408
+ infraServices: services.filter((s) => s.build === void 0)
409
+ };
393
410
  }
411
+ //#endregion
412
+ //#region src/infra/containers/compose.ts
394
413
  /**
395
- * Handle to a directory produced by a specification run.
396
- * Supports snapshot-based assertions via {@link toMatchFixture} and file listing via {@link files}.
414
+ * Start the full compose stack and stop it all on cleanup.
415
+ * Supports per-worker project names for parallel execution.
397
416
  */
398
- var DirectoryAccessor = class {
399
- absPath;
400
- testDir;
401
- constructor(absPath, testDir) {
402
- this.absPath = absPath;
403
- this.testDir = testDir;
417
+ var ComposeStackAdapter = class {
418
+ composeFile;
419
+ projectName;
420
+ started = false;
421
+ constructor(composeFile, projectName) {
422
+ this.composeFile = composeFile;
423
+ this.projectName = projectName ?? null;
404
424
  }
405
- /**
406
- * Compare the directory tree against `expected/{name}/` (relative to the test file).
407
- * On mismatch, throws with a structured diff. With update mode enabled, the
408
- * fixture is overwritten with the current contents instead.
409
- */
410
- async toMatchFixture(name, options = {}) {
411
- const fixtureDir = (0, node_path.resolve)(this.testDir, "expected", name);
412
- if (options.update ?? shouldUpdateSnapshots()) {
413
- (0, node_fs.rmSync)(fixtureDir, {
414
- force: true,
415
- recursive: true
416
- });
417
- (0, node_fs.mkdirSync)(fixtureDir, { recursive: true });
418
- (0, node_fs.cpSync)(this.absPath, fixtureDir, { recursive: true });
419
- return;
425
+ get projectFlag() {
426
+ return this.projectName ? `-p ${this.projectName}` : "";
427
+ }
428
+ run(command) {
429
+ try {
430
+ return (0, node_child_process.execSync)(command, {
431
+ cwd: (0, node_path.dirname)(this.composeFile),
432
+ encoding: "utf8",
433
+ timeout: 12e4
434
+ }).trim();
435
+ } catch (error) {
436
+ const stderr = error.stderr?.toString().trim() ?? error.message;
437
+ throw new Error(`docker compose failed: ${stderr}`, { cause: error });
420
438
  }
421
- if (!(0, node_fs.existsSync)(fixtureDir)) throw new Error(`Directory fixture "${name}" does not exist at ${fixtureDir}.\nRun with JTERRAZZ_TEST_UPDATE=1 (or vitest -u) to create it.`);
422
- const diff = await diffDirectories(fixtureDir, this.absPath, { ignore: options.ignore });
423
- if (diff.added.length === 0 && diff.removed.length === 0 && diff.changed.length === 0) return;
424
- throw new Error(formatDirectoryDiff(name, diff, "Run with JTERRAZZ_TEST_UPDATE=1 to update the fixture."));
425
439
  }
426
- /**
427
- * List all files in the directory (recursive, sorted, ignoring defaults).
428
- * Useful for ad-hoc assertions when you don't want a full snapshot.
429
- */
430
- async files(options = {}) {
431
- return walkDirectory(this.absPath, options);
440
+ async start() {
441
+ if (this.started) return;
442
+ this.run(`docker compose ${this.projectFlag} -f ${this.composeFile} up -d --wait`);
443
+ this.started = true;
444
+ }
445
+ async stop() {
446
+ if (!this.started) return;
447
+ this.run(`docker compose ${this.projectFlag} -f ${this.composeFile} down -v`);
448
+ this.started = false;
449
+ }
450
+ getMappedPort(serviceName, containerPort) {
451
+ const port = this.run(`docker compose ${this.projectFlag} -f ${this.composeFile} port ${serviceName} ${containerPort}`).split(":").pop();
452
+ return Number(port);
453
+ }
454
+ getHost() {
455
+ return "localhost";
432
456
  }
433
457
  };
434
458
  //#endregion
435
- //#region src/builder/common/grep.ts
459
+ //#region src/infra/containers/testcontainers.ts
436
460
  /**
437
- * Extract text blocks from output that contain a pattern.
438
- * Splits by blank lines (how linter/compiler output is structured),
439
- * returns only blocks matching the pattern.
440
- *
441
- * @example
442
- * expect(grep(result.stdout, "unused-var.ts")).toContain("no-unused-vars")
443
- * expect(grep(result.stdout, "valid/sorted.ts")).not.toContain("sort-imports")
444
- */
445
- function grep(output, pattern) {
446
- return output.replace(/\x1b\[[0-9;]*m/g, "").split(/\n\s*\n/).filter((block) => block.includes(pattern)).join("\n\n");
447
- }
448
- //#endregion
449
- //#region src/builder/common/response-accessor.ts
450
- /** Accessor for an HTTP response body with file-based assertion support. */
451
- var ResponseAccessor = class {
452
- body;
453
- testDir;
454
- constructor(body, testDir) {
455
- this.body = body;
456
- this.testDir = testDir;
457
- }
458
- /**
459
- * Assert that the response body matches the JSON in `responses/{file}`.
460
- *
461
- * @example
462
- * result.response.toMatchFile("expected-items.json");
463
- */
464
- toMatchFile(file) {
465
- const expected = JSON.parse((0, node_fs.readFileSync)((0, node_path.resolve)(this.testDir, "responses", file), "utf8"));
466
- if (JSON.stringify(this.body) !== JSON.stringify(expected)) throw new Error(formatResponseDiff(file, expected, this.body));
467
- }
468
- };
469
- //#endregion
470
- //#region src/builder/common/table-assertion.ts
471
- /** Assertion helper for verifying database table contents after a specification run. */
472
- var TableAssertion = class {
473
- tableName;
474
- db;
475
- constructor(tableName, db) {
476
- this.tableName = tableName;
477
- this.db = db;
478
- }
479
- /**
480
- * Assert that the table contains exactly the expected rows for the given columns.
481
- *
482
- * @example
483
- * await result.table("users").toMatch({
484
- * columns: ["name", "email"],
485
- * rows: [["Alice", "alice@example.com"]],
486
- * });
487
- */
488
- async toMatch(expected) {
489
- const actual = await this.db.query(this.tableName, expected.columns);
490
- if (JSON.stringify(actual) !== JSON.stringify(expected.rows)) throw new Error(formatTableDiff(this.tableName, expected.columns, expected.rows, actual));
491
- }
492
- /** Assert that the table has zero rows. */
493
- async toBeEmpty() {
494
- const actual = await this.db.query(this.tableName, ["*"]);
495
- if (actual.length !== 0) throw new Error(`Expected table "${this.tableName}" to be empty, but it has ${actual.length} rows`);
496
- }
497
- };
498
- //#endregion
499
- //#region src/builder/common/result.ts
500
- /**
501
- * The outcome of a single specification run.
502
- * Provides accessors for CLI output, HTTP responses, files, directories, and database tables.
461
+ * Container adapter using testcontainers.
462
+ * Wraps a GenericContainer for programmatic container lifecycle.
503
463
  */
504
- var SpecificationResult = class {
505
- commandResult;
506
- config;
507
- requestInfo;
508
- responseData;
509
- testDir;
510
- workDir;
464
+ var TestcontainersAdapter = class {
465
+ image;
466
+ containerPort;
467
+ env;
468
+ reuse;
469
+ container = null;
511
470
  constructor(options) {
512
- this.responseData = options.response;
513
- this.commandResult = options.commandResult;
514
- this.config = options.config;
515
- this.testDir = options.testDir;
516
- this.requestInfo = options.requestInfo;
517
- this.workDir = options.workDir;
518
- }
519
- /** The process exit code. Only available after a CLI action. */
520
- get exitCode() {
521
- if (!this.commandResult) throw new Error(".exitCode requires a CLI action (.exec())");
522
- return this.commandResult.exitCode;
523
- }
524
- /** The HTTP response status code. Only available after an HTTP action. */
525
- get status() {
526
- if (!this.responseData) throw new Error(".status requires an HTTP action (.get(), .post(), etc.)");
527
- return this.responseData.status;
528
- }
529
- /** The captured standard output. Only available after a CLI action. */
530
- get stdout() {
531
- if (!this.commandResult) throw new Error(".stdout requires a CLI action (.exec())");
532
- return this.commandResult.stdout;
533
- }
534
- /** The captured standard error. Only available after a CLI action. */
535
- get stderr() {
536
- if (!this.commandResult) throw new Error(".stderr requires a CLI action (.exec())");
537
- return this.commandResult.stderr;
471
+ this.image = options.image;
472
+ this.containerPort = options.port;
473
+ this.env = options.env ?? {};
474
+ this.reuse = options.reuse ?? false;
538
475
  }
539
- /**
540
- * Extract text blocks from stdout that contain a pattern.
541
- * Useful for parsing structured CLI output (linters, compilers).
542
- *
543
- * @example
544
- * expect(result.grep('error.ts')).toContain('no-unused-vars');
545
- */
546
- grep(pattern) {
547
- return grep(this.stdout, pattern);
476
+ async start() {
477
+ const { GenericContainer, Wait } = await import("testcontainers");
478
+ let builder = new GenericContainer(this.image).withExposedPorts(this.containerPort);
479
+ for (const [key, value] of Object.entries(this.env)) builder = builder.withEnvironment({ [key]: value });
480
+ if (this.image.startsWith("postgres")) builder = builder.withWaitStrategy(Wait.forLogMessage(/database system is ready to accept connections/, 2));
481
+ if (this.reuse) builder = builder.withReuse();
482
+ this.container = await builder.start();
548
483
  }
549
- /** Access the HTTP response body for assertions. Only available after an HTTP action. */
550
- get response() {
551
- if (!this.responseData) throw new Error(".response requires an HTTP action (.get(), .post(), etc.)");
552
- return new ResponseAccessor(this.responseData.body, this.testDir);
484
+ async stop() {
485
+ if (this.container && !this.reuse) {
486
+ await this.container.stop();
487
+ this.container = null;
488
+ }
553
489
  }
554
- /** Access a directory (relative to the working directory) for snapshot assertions. */
555
- directory(path = ".") {
556
- return new DirectoryAccessor((0, node_path.resolve)(this.workDir ?? this.testDir, path), this.testDir);
490
+ getMappedPort(containerPort) {
491
+ if (!this.container) throw new Error("Container not started");
492
+ return this.container.getMappedPort(containerPort);
557
493
  }
558
- /** Access a single file (relative to the working directory) for content assertions. */
559
- file(path) {
560
- const resolvedPath = (0, node_path.resolve)(this.workDir ?? this.testDir, path);
561
- const exists = (0, node_fs.existsSync)(resolvedPath);
562
- return {
563
- get content() {
564
- if (!exists) throw new Error(`File not found: ${path}`);
565
- return (0, node_fs.readFileSync)(resolvedPath, "utf8");
566
- },
567
- exists
568
- };
494
+ getHost() {
495
+ if (!this.container) throw new Error("Container not started");
496
+ return this.container.getHost();
569
497
  }
570
- /** Access a database table for row-level assertions. */
571
- table(tableName, options) {
572
- const db = this.resolveDatabase(options?.service);
573
- if (!db) throw new Error(options?.service ? `table("${tableName}") requires database "${options.service}" but it was not found` : `table("${tableName}") requires a database adapter`);
574
- return new TableAssertion(tableName, db);
498
+ getConnectionString() {
499
+ return `${this.getHost()}:${this.getMappedPort(this.containerPort)}`;
575
500
  }
576
- resolveDatabase(serviceName) {
577
- if (serviceName && this.config.databases) return this.config.databases.get(serviceName);
578
- return this.config.database;
501
+ async getLogs() {
502
+ if (!this.container) return "";
503
+ const stream = await this.container.logs();
504
+ return new Promise((resolve) => {
505
+ let output = "";
506
+ stream.on("data", (chunk) => {
507
+ output += chunk.toString();
508
+ });
509
+ stream.on("end", () => {
510
+ resolve(output);
511
+ });
512
+ setTimeout(() => {
513
+ resolve(output);
514
+ }, 1e3);
515
+ });
579
516
  }
580
517
  };
581
518
  //#endregion
582
- //#region src/builder/specification-builder.ts
519
+ //#region src/infra/orchestrator.ts
583
520
  /**
584
- * Fluent builder for declaring a single test specification.
585
- *
586
- * Chain setup methods ({@link seed}, {@link fixture}, {@link env}), an action
587
- * ({@link get}, {@link post}, {@link exec}), then call {@link run} to execute
588
- * and receive a {@link SpecificationResult} for assertions.
521
+ * Orchestrator for test infrastructure.
522
+ * Integration: starts services via testcontainers.
523
+ * E2E: runs full docker compose up.
589
524
  */
590
- var SpecificationBuilder = class {
591
- commandArgs = null;
592
- commandEnv = {};
593
- config;
594
- fixtures = [];
595
- intercepts = [];
596
- jobName = null;
597
- label;
598
- mocks = [];
599
- projectName = null;
600
- request = null;
601
- requestHeaders = {};
602
- seeds = [];
603
- spawnConfig = null;
604
- testDir;
605
- constructor(config, testDir, label) {
606
- this.config = config;
607
- this.testDir = testDir;
608
- this.label = label;
609
- }
610
- /**
611
- * Queue a SQL seed file to run before the action.
612
- *
613
- * @example
614
- * spec("creates user").seed("users.sql").exec("list-users").run();
615
- */
616
- seed(file, options) {
617
- this.seeds.push({
618
- file,
619
- service: options?.service
620
- });
621
- return this;
622
- }
623
- /** Copy a file or directory from `fixtures/` into the working directory before execution. */
624
- fixture(file) {
625
- this.fixtures.push({ file });
626
- return this;
627
- }
628
- /** Copy an entire project fixture from `fixturesRoot` into the working directory. */
629
- project(name) {
630
- this.projectName = name;
631
- return this;
632
- }
633
- /** Register an MSW mock definition file from `mock/` to intercept HTTP calls. */
634
- mock(file) {
635
- this.mocks.push({ file });
636
- return this;
637
- }
638
- /**
639
- * Set environment variables for the CLI process. Merged on top of process.env.
640
- * Use `null` to unset a variable. Multiple calls merge.
641
- *
642
- * The token `$WORKDIR` (in any value) is replaced with the actual working
643
- * directory at run-time — useful for tests that need a fully isolated `HOME`.
644
- *
645
- * @example
646
- * spec("...").env({ HOME: "$WORKDIR", TZ: "UTC" }).exec("status").run();
647
- */
648
- env(env) {
649
- this.commandEnv = {
650
- ...this.commandEnv,
651
- ...env
652
- };
653
- return this;
525
+ var Orchestrator = class {
526
+ services;
527
+ mode;
528
+ root;
529
+ projectName;
530
+ running = [];
531
+ composeStack = null;
532
+ composeHandles = [];
533
+ started = false;
534
+ constructor(options) {
535
+ this.services = options.services;
536
+ this.mode = options.mode;
537
+ this.root = options.root ?? process.cwd();
538
+ this.projectName = options.projectName;
654
539
  }
655
540
  /**
656
- * Set HTTP headers for the request. Multiple calls merge.
657
- *
658
- * @example
659
- * spec("french").headers({ 'Accept-Language': 'fr' }).get("/articles").run();
541
+ * Start declared services via testcontainers (integration mode).
542
+ * Phase 1: start all containers in parallel (the slow part).
543
+ * Phase 2: wire connections, healthcheck, and init sequentially (fast).
660
544
  */
661
- headers(headers) {
662
- this.requestHeaders = {
663
- ...this.requestHeaders,
664
- ...headers
665
- };
666
- return this;
667
- }
668
- /**
669
- * Intercept an outgoing HTTP request and return a controlled response.
670
- * Uses MSW under the hood. Intercepts are queued — multiple calls with the
671
- * same trigger fire sequentially (first match consumed first).
672
- *
673
- * @param trigger - What to match (use openai.agent(), http.get(), etc.)
674
- * @param response - What to return: an InterceptResponse object, or a filename
675
- * from `intercepts/` (JSON loaded and auto-wrapped by the trigger).
676
- *
677
- * @example
678
- * // With inline response
679
- * .intercept(openai.agent({...}), openai.reply({ categories: ['TECH'] }))
680
- *
681
- * // With JSON fixture file (loaded from intercepts/ directory)
682
- * .intercept(openai.agent({...}), 'ingest-tech.json')
683
- * .intercept(http.get(url), 'world-news-tech.json')
684
- */
685
- intercept(trigger, response) {
686
- if (typeof response === "string") {
687
- const slashIndex = response.indexOf("/");
688
- if (slashIndex === -1) throw new Error(`.intercept(): file path must be 'adapter/filename.json' (e.g. 'openai/ingest-tech.json'), got '${response}'`);
689
- const adapterName = response.slice(0, slashIndex);
690
- if (adapterName !== trigger.adapter) throw new Error(`.intercept(): adapter mismatch - trigger uses '${trigger.adapter}' but file path starts with '${adapterName}/'`);
691
- const filePath = (0, node_path.resolve)(this.testDir, "intercepts", response);
692
- const data = JSON.parse((0, node_fs.readFileSync)(filePath, "utf8"));
693
- const resolved = trigger.wrap(data);
694
- this.intercepts.push({
695
- trigger,
696
- response: resolved
545
+ async start() {
546
+ if (this.started) return;
547
+ const composePath = findComposeFile(this.root);
548
+ const composeDir = composePath ? (0, node_path.dirname)(composePath) : this.root;
549
+ const composeConfig = composePath ? parseComposeFile(composePath) : null;
550
+ const containerServices = [];
551
+ const embeddedServices = [];
552
+ for (const handle of this.services) {
553
+ if (handle.defaultPort === 0) {
554
+ embeddedServices.push(handle);
555
+ continue;
556
+ }
557
+ let image = handle.defaultImage;
558
+ let env = { ...handle.environment };
559
+ if (handle.composeName && composeConfig) {
560
+ const composeService = composeConfig.services.find((s) => s.name === handle.composeName);
561
+ if (composeService) {
562
+ image = composeService.image ?? image;
563
+ env = {
564
+ ...env,
565
+ ...composeService.environment
566
+ };
567
+ Object.assign(handle.environment, composeService.environment);
568
+ }
569
+ }
570
+ const container = new TestcontainersAdapter({
571
+ image,
572
+ port: handle.defaultPort,
573
+ env
697
574
  });
698
- } else this.intercepts.push({
699
- trigger,
700
- response
701
- });
702
- return this;
575
+ containerServices.push({
576
+ container,
577
+ handle
578
+ });
579
+ }
580
+ await Promise.all([...containerServices.map(({ container }) => container.start()), ...embeddedServices.map(async (handle) => {
581
+ await handle.initialize(composeDir);
582
+ handle.started = true;
583
+ this.running.push({
584
+ handle,
585
+ container: null
586
+ });
587
+ })]);
588
+ const reports = [];
589
+ for (const { container, handle } of containerServices) {
590
+ const serviceStartTime = Date.now();
591
+ try {
592
+ const host = container.getHost();
593
+ const port = container.getMappedPort(handle.defaultPort);
594
+ handle.connectionString = handle.buildConnectionString(host, port);
595
+ await handle.healthcheck();
596
+ await handle.initialize(composeDir);
597
+ handle.started = true;
598
+ reports.push({
599
+ name: handle.composeName ?? handle.type,
600
+ type: handle.type,
601
+ connectionString: handle.connectionString,
602
+ durationMs: Date.now() - serviceStartTime
603
+ });
604
+ this.running.push({
605
+ handle,
606
+ container
607
+ });
608
+ } catch (error) {
609
+ let logs = "";
610
+ try {
611
+ logs = await container.getLogs();
612
+ } catch {}
613
+ try {
614
+ await container.stop();
615
+ } catch {}
616
+ reports.push({
617
+ name: handle.composeName ?? handle.type,
618
+ type: handle.type,
619
+ durationMs: Date.now() - serviceStartTime,
620
+ error: error.message,
621
+ logs
622
+ });
623
+ const output = formatStartupReport("integration", reports, { type: "in-process" });
624
+ console.error(output);
625
+ throw error;
626
+ }
627
+ }
628
+ this.started = true;
629
+ const output = formatStartupReport("integration", reports, { type: "in-process" });
630
+ console.log(output);
703
631
  }
704
632
  /**
705
- * Send a GET request to the server adapter.
706
- *
707
- * @example
708
- * spec("list items").get("/api/items").run();
633
+ * Stop testcontainers (integration mode).
709
634
  */
710
- get(path) {
711
- this.request = {
712
- method: "GET",
713
- path
714
- };
715
- return this;
635
+ async stop() {
636
+ for (const { container } of this.running) if (container) await container.stop();
637
+ this.running = [];
638
+ this.started = false;
716
639
  }
717
640
  /**
718
- * Send a POST request to the server adapter.
719
- *
720
- * @param bodyFile - Optional JSON file from `requests/` to use as the request body.
721
- * @example
722
- * spec("create item").post("/api/items", "new-item.json").run();
641
+ * Start full docker compose stack (e2e mode).
642
+ * Auto-detects infra services and creates handles for them.
723
643
  */
724
- post(path, bodyFile) {
725
- this.request = {
726
- bodyFile,
727
- method: "POST",
728
- path
729
- };
730
- return this;
731
- }
732
- /** Send a PUT request to the server adapter. */
733
- put(path, bodyFile) {
734
- this.request = {
735
- bodyFile,
736
- method: "PUT",
737
- path
738
- };
739
- return this;
740
- }
741
- /** Send a DELETE request to the server adapter. */
742
- delete(path) {
743
- this.request = {
744
- method: "DELETE",
745
- path
746
- };
747
- return this;
644
+ async startCompose() {
645
+ const composePath = findComposeFile(this.root);
646
+ if (!composePath) throw new Error(`E2E: no compose file found in ${this.root}`);
647
+ const startTime = Date.now();
648
+ const composeDir = (0, node_path.dirname)(composePath);
649
+ const composeConfig = parseComposeFile(composePath);
650
+ this.composeStack = new ComposeStackAdapter(composePath, this.projectName);
651
+ await this.composeStack.start();
652
+ for (const service of composeConfig.infraServices) {
653
+ const type = detectServiceType(service.image);
654
+ if (type === "postgres") {
655
+ const handle = require_sqlite.postgres({
656
+ compose: service.name,
657
+ env: service.environment
658
+ });
659
+ const port = this.composeStack.getMappedPort(service.name, 5432);
660
+ handle.connectionString = handle.buildConnectionString("localhost", port);
661
+ await handle.initialize(composeDir);
662
+ handle.started = true;
663
+ this.composeHandles.push(handle);
664
+ } else if (type === "redis") {
665
+ const handle = require_sqlite.redis({ compose: service.name });
666
+ const port = this.composeStack.getMappedPort(service.name, 6379);
667
+ handle.connectionString = handle.buildConnectionString("localhost", port);
668
+ handle.started = true;
669
+ this.composeHandles.push(handle);
670
+ }
671
+ }
672
+ const durationMs = Date.now() - startTime;
673
+ const output = formatStartupReport("e2e", this.composeHandles.map((h) => ({
674
+ name: h.composeName ?? h.type,
675
+ type: h.type,
676
+ connectionString: h.connectionString,
677
+ durationMs
678
+ })), {
679
+ type: "http",
680
+ url: this.getAppUrl() ?? void 0
681
+ });
682
+ console.log(output);
748
683
  }
749
684
  /**
750
- * Execute a CLI command (or a sequence of commands) in an isolated working directory.
751
- * When an array is passed, commands run sequentially and stop on the first non-zero exit code.
752
- *
753
- * @example
754
- * spec("init project").exec("init --name demo").run();
755
- * spec("multi-step").exec(["init", "build"]).run();
685
+ * Stop docker compose stack (e2e mode).
756
686
  */
757
- exec(args) {
758
- this.commandArgs = args;
759
- return this;
760
- }
761
- /** Spawn a long-running CLI process with custom spawn options (e.g. timeout, signal). */
762
- spawn(args, options) {
763
- this.spawnConfig = {
764
- args,
765
- options
766
- };
767
- return this;
687
+ async stopCompose() {
688
+ if (this.composeStack) {
689
+ await this.composeStack.stop();
690
+ this.composeStack = null;
691
+ }
692
+ this.composeHandles = [];
768
693
  }
769
694
  /**
770
- * Execute a named job registered via the app() factory.
771
- *
772
- * @param name - The job name to trigger (must match a registered JobHandle.name).
773
- *
774
- * @example
775
- * spec('pipeline').intercept(openai.chat(), openai.response({...})).job('report-refresh').run();
695
+ * Get a database service by compose name, or the first one if no name given.
776
696
  */
777
- job(name) {
778
- this.jobName = name;
779
- return this;
697
+ getDatabase(serviceName) {
698
+ for (const handle of [...this.services, ...this.composeHandles]) {
699
+ if (serviceName && handle.composeName !== serviceName) continue;
700
+ const adapter = handle.createDatabaseAdapter();
701
+ if (adapter) return adapter;
702
+ }
703
+ return null;
780
704
  }
781
705
  /**
782
- * Execute the specification: run seeds, copy fixtures, then perform the
783
- * configured action (HTTP or CLI).
784
- *
785
- * @returns The result object used for assertions.
786
- * @example
787
- * const result = await spec("test").exec("status").run();
788
- * expect(result.exitCode).toBe(0);
706
+ * Get all database services keyed by compose name.
789
707
  */
790
- async run() {
791
- const hasHttpAction = this.request !== null;
792
- const hasCliAction = this.commandArgs !== null || this.spawnConfig !== null;
793
- const hasJobAction = this.jobName !== null;
794
- const actionCount = [
795
- hasHttpAction,
796
- hasCliAction,
797
- hasJobAction
798
- ].filter(Boolean).length;
799
- if (actionCount === 0) throw new Error(`Specification "${this.label}": no action defined. Call .get(), .post(), .exec(), .job(), etc. before .run()`);
800
- if (actionCount > 1) throw new Error(`Specification "${this.label}": cannot mix action types (.get/.post, .exec/.spawn, .job)`);
801
- let workDir = null;
802
- if (hasCliAction) workDir = this.prepareWorkDir();
803
- if (this.config.databases) for (const db of this.config.databases.values()) await db.reset();
804
- else if (this.config.database) await this.config.database.reset();
805
- for (const entry of this.seeds) {
806
- let db;
807
- if (entry.service && this.config.databases) {
808
- db = this.config.databases.get(entry.service);
809
- if (!db) throw new Error(`seed() targets database "${entry.service}" but it was not found. Available: ${[...this.config.databases.keys()].join(", ")}`);
810
- } else db = this.config.database;
811
- if (!db) throw new Error("seed() requires a database adapter");
812
- const sql = (0, node_fs.readFileSync)((0, node_path.resolve)(this.testDir, "seeds", entry.file), "utf8");
813
- await db.seed(sql);
814
- }
815
- if (this.fixtures.length > 0 && workDir) for (const entry of this.fixtures) (0, node_fs.cpSync)((0, node_path.resolve)(this.testDir, "fixtures", entry.file), (0, node_path.resolve)(workDir, entry.file), { recursive: true });
816
- let cleanupIntercepts = null;
817
- if (this.intercepts.length > 0) {
818
- const { registerIntercepts } = await Promise.resolve().then(() => require("./intercept2.cjs"));
819
- cleanupIntercepts = await registerIntercepts(this.intercepts);
820
- }
821
- try {
822
- if (hasHttpAction) return await this.runHttpAction();
823
- if (hasJobAction) return await this.runJobAction();
824
- return await this.runCliAction(workDir);
825
- } finally {
826
- if (cleanupIntercepts) cleanupIntercepts();
708
+ getDatabases() {
709
+ const map = /* @__PURE__ */ new Map();
710
+ for (const handle of [...this.services, ...this.composeHandles]) {
711
+ const adapter = handle.createDatabaseAdapter();
712
+ if (adapter && handle.composeName) map.set(handle.composeName, adapter);
827
713
  }
714
+ return map;
828
715
  }
829
- resolveEnv(workDir) {
830
- const keys = Object.keys(this.commandEnv);
831
- if (keys.length === 0) return;
832
- const resolved = {};
833
- for (const key of keys) {
834
- const value = this.commandEnv[key];
835
- resolved[key] = typeof value === "string" ? value.replace(/\$WORKDIR/g, workDir) : value;
836
- }
837
- return resolved;
716
+ /**
717
+ * Get app URL from compose (e2e mode).
718
+ */
719
+ getAppUrl() {
720
+ const composePath = findComposeFile(this.root);
721
+ if (!composePath || !this.composeStack) return null;
722
+ const appService = parseComposeFile(composePath).appService;
723
+ if (!appService || appService.ports.length === 0) return null;
724
+ return `http://localhost:${this.composeStack.getMappedPort(appService.name, appService.ports[0].container)}`;
838
725
  }
839
- prepareWorkDir() {
840
- const tempDir = (0, node_fs.mkdtempSync)((0, node_path.resolve)((0, node_os.tmpdir)(), "spec-cli-"));
841
- if (this.projectName && this.config.fixturesRoot) {
842
- const projectDir = (0, node_path.resolve)(this.config.fixturesRoot, this.projectName);
843
- if (!(0, node_fs.existsSync)(projectDir)) throw new Error(`project("${this.projectName}"): fixture project not found at ${projectDir}`);
844
- (0, node_fs.cpSync)(projectDir, tempDir, { recursive: true });
726
+ };
727
+ //#endregion
728
+ //#region src/spec/result/directory.ts
729
+ const DEFAULT_IGNORES = [
730
+ ".git",
731
+ ".DS_Store",
732
+ "node_modules",
733
+ ".next",
734
+ "dist",
735
+ ".turbo",
736
+ ".cache"
737
+ ];
738
+ /**
739
+ * Recursively walk a directory, returning sorted relative paths of files only.
740
+ */
741
+ async function walkDirectory(root, options = {}) {
742
+ const ignores = new Set([...DEFAULT_IGNORES, ...options.ignore ?? []]);
743
+ const out = [];
744
+ async function walk(current) {
745
+ let entries;
746
+ try {
747
+ entries = await (0, node_fs_promises.readdir)(current);
748
+ } catch {
749
+ return;
750
+ }
751
+ for (const entry of entries) {
752
+ if (ignores.has(entry)) continue;
753
+ const abs = (0, node_path.resolve)(current, entry);
754
+ const stat = (0, node_fs.statSync)(abs);
755
+ if (stat.isDirectory()) await walk(abs);
756
+ else if (stat.isFile()) out.push((0, node_path.relative)(root, abs).split(node_path.sep).join("/"));
845
757
  }
846
- return tempDir;
847
758
  }
848
- async runHttpAction() {
849
- if (!this.config.server) throw new Error("HTTP actions require a server adapter (use integration() or e2e())");
850
- let body;
851
- if (this.request.bodyFile) body = JSON.parse((0, node_fs.readFileSync)((0, node_path.resolve)(this.testDir, "requests", this.request.bodyFile), "utf8"));
852
- const headers = Object.keys(this.requestHeaders).length > 0 ? this.requestHeaders : void 0;
853
- const response = await this.config.server.request(this.request.method, this.request.path, body, headers);
854
- return new SpecificationResult({
855
- config: this.config,
856
- requestInfo: {
857
- body,
858
- method: this.request.method,
859
- path: this.request.path
860
- },
861
- response,
862
- testDir: this.testDir
759
+ await walk(root);
760
+ out.sort();
761
+ return out;
762
+ }
763
+ /**
764
+ * Compare two directory trees file-by-file.
765
+ */
766
+ async function diffDirectories(expectedRoot, actualRoot, options = {}) {
767
+ const expectedFiles = await walkDirectory(expectedRoot, options);
768
+ const actualFiles = await walkDirectory(actualRoot, options);
769
+ const expectedSet = new Set(expectedFiles);
770
+ const actualSet = new Set(actualFiles);
771
+ const added = actualFiles.filter((f) => !expectedSet.has(f));
772
+ const removed = expectedFiles.filter((f) => !actualSet.has(f));
773
+ const changed = [];
774
+ for (const file of expectedFiles) {
775
+ if (!actualSet.has(file)) continue;
776
+ const expected = (0, node_fs.readFileSync)((0, node_path.resolve)(expectedRoot, file), "utf8");
777
+ const actual = (0, node_fs.readFileSync)((0, node_path.resolve)(actualRoot, file), "utf8");
778
+ if (expected !== actual) changed.push({
779
+ actual,
780
+ expected,
781
+ path: file
863
782
  });
864
783
  }
865
- async runJobAction() {
866
- if (!this.config.jobs?.length) throw new Error("Job actions require jobs registered via app(() => ({ server, jobs }))");
867
- const job = this.config.jobs.find((j) => j.name === this.jobName);
868
- if (!job) {
869
- const available = this.config.jobs.map((j) => j.name).join(", ");
870
- throw new Error(`job("${this.jobName}"): not found. Available: ${available}`);
784
+ return {
785
+ added,
786
+ changed,
787
+ removed
788
+ };
789
+ }
790
+ function shouldUpdateSnapshots$3() {
791
+ if (process.env.JTERRAZZ_TEST_UPDATE === "1") return true;
792
+ if (process.env.UPDATE_SNAPSHOTS === "1") return true;
793
+ if (process.argv.includes("-u") || process.argv.includes("--update")) return true;
794
+ return false;
795
+ }
796
+ var DirectoryAccessor = class {
797
+ absPath;
798
+ testDir;
799
+ constructor(absPath, testDir) {
800
+ this.absPath = absPath;
801
+ this.testDir = testDir;
802
+ }
803
+ async toMatchFixture(name, options = {}) {
804
+ const fixtureDir = (0, node_path.resolve)(this.testDir, "expected", name);
805
+ if (options.update ?? shouldUpdateSnapshots$3()) {
806
+ (0, node_fs.rmSync)(fixtureDir, {
807
+ force: true,
808
+ recursive: true
809
+ });
810
+ (0, node_fs.mkdirSync)(fixtureDir, { recursive: true });
811
+ (0, node_fs.cpSync)(this.absPath, fixtureDir, { recursive: true });
812
+ return;
871
813
  }
872
- await job.execute();
873
- return new SpecificationResult({
874
- config: this.config,
875
- testDir: this.testDir
876
- });
814
+ if (!(0, node_fs.existsSync)(fixtureDir)) throw new Error(`Directory fixture "${name}" does not exist at ${fixtureDir}.\nRun with JTERRAZZ_TEST_UPDATE=1 (or vitest -u) to create it.`);
815
+ const diff = await diffDirectories(fixtureDir, this.absPath, { ignore: options.ignore });
816
+ if (diff.added.length === 0 && diff.removed.length === 0 && diff.changed.length === 0) return;
817
+ throw new Error(formatDirectoryDiff(name, diff, "Run with JTERRAZZ_TEST_UPDATE=1 to update the fixture."));
877
818
  }
878
- async runCliAction(workDir) {
879
- if (!this.config.command) throw new Error("CLI actions require a command adapter (use cli())");
880
- const env = this.resolveEnv(workDir);
881
- let commandResult;
882
- if (this.spawnConfig) commandResult = await this.config.command.spawn(this.spawnConfig.args, workDir, this.spawnConfig.options, env);
883
- else if (Array.isArray(this.commandArgs)) {
884
- commandResult = {
885
- exitCode: 0,
886
- stderr: "",
887
- stdout: ""
888
- };
889
- for (const args of this.commandArgs) {
890
- commandResult = await this.config.command.exec(args, workDir, env);
891
- if (commandResult.exitCode !== 0) break;
892
- }
893
- } else commandResult = await this.config.command.exec(this.commandArgs, workDir, env);
894
- return new SpecificationResult({
895
- commandResult,
896
- config: this.config,
897
- testDir: this.testDir,
898
- workDir
899
- });
819
+ async files(options = {}) {
820
+ return walkDirectory(this.absPath, options);
900
821
  }
901
822
  };
902
- function getCallerDir() {
903
- const stack = (/* @__PURE__ */ new Error("caller detection")).stack;
904
- if (!stack) throw new Error("Cannot detect caller directory: no stack trace");
905
- const lines = stack.split("\n");
906
- for (const line of lines) {
907
- const match = line.match(/at\s+(?:.*?\()?(?:file:\/\/)?([^:)]+):\d+:\d+/);
908
- if (!match) continue;
909
- const filePath = match[1];
910
- if (filePath.includes("node_modules")) continue;
911
- if (filePath.includes("/src/builder/") || filePath.includes("/src/spec/")) continue;
912
- return (0, node_path.resolve)(filePath, "..");
913
- }
914
- throw new Error("Cannot detect caller directory from stack trace");
823
+ //#endregion
824
+ //#region src/spec/result/filesystem.ts
825
+ function shouldUpdateSnapshots$2() {
826
+ if (process.env.JTERRAZZ_TEST_UPDATE === "1") return true;
827
+ if (process.env.UPDATE_SNAPSHOTS === "1") return true;
828
+ if (process.argv.includes("-u") || process.argv.includes("--update")) return true;
829
+ return false;
915
830
  }
916
831
  /**
917
- * Create a {@link SpecificationRunner} bound to the given adapter configuration.
918
- * The test file directory is auto-detected from the call stack.
832
+ * Accessor for the whole temporary working directory used by a CLI spec.
833
+ *
834
+ * Generalization of {@link DirectoryAccessor} that snapshots the entire CLI
835
+ * working directory into `<test-file-dir>/expected/filesystem/<name>/`.
919
836
  */
920
- function createSpecificationRunner(config) {
921
- return (label) => {
922
- return new SpecificationBuilder(config, getCallerDir(), label);
923
- };
837
+ var FilesystemAccessor = class {
838
+ /** The absolute path of the temporary working directory. */
839
+ cwd;
840
+ testDir;
841
+ constructor(cwd, testDir) {
842
+ this.cwd = cwd;
843
+ this.testDir = testDir;
844
+ }
845
+ /** List all files (recursively) under the working directory, sorted. */
846
+ async files(options = {}) {
847
+ return walkDirectory(this.cwd, options);
848
+ }
849
+ /**
850
+ * Assert the working directory matches a convention-based fixture tree:
851
+ * `<test-file-dir>/expected/filesystem/<name>/`.
852
+ */
853
+ async toMatch(name, options = {}) {
854
+ const fixtureDir = (0, node_path.resolve)(this.testDir, "expected", "filesystem", name);
855
+ if (options.update ?? shouldUpdateSnapshots$2()) {
856
+ (0, node_fs.rmSync)(fixtureDir, {
857
+ force: true,
858
+ recursive: true
859
+ });
860
+ (0, node_fs.mkdirSync)(fixtureDir, { recursive: true });
861
+ (0, node_fs.cpSync)(this.cwd, fixtureDir, { recursive: true });
862
+ return;
863
+ }
864
+ if (!(0, node_fs.existsSync)(fixtureDir)) throw new Error(`Filesystem fixture "${name}" does not exist at ${fixtureDir}.\nRun with JTERRAZZ_TEST_UPDATE=1 (or vitest -u) to create it.`);
865
+ const diff = await diffDirectories(fixtureDir, this.cwd, { ignore: options.ignore });
866
+ if (diff.added.length === 0 && diff.removed.length === 0 && diff.changed.length === 0) return;
867
+ throw new Error(formatDirectoryDiff(`filesystem/${name}`, diff, "Run with JTERRAZZ_TEST_UPDATE=1 to update the fixture."));
868
+ }
869
+ };
870
+ //#endregion
871
+ //#region src/spec/result/grep.ts
872
+ /**
873
+ * Extract text blocks from output that contain a pattern.
874
+ * Splits by blank lines (how linter/compiler output is structured),
875
+ * returns only blocks matching the pattern.
876
+ *
877
+ * @example
878
+ * expect(grep(result.stdout, "unused-var.ts")).toContain("no-unused-vars")
879
+ * expect(grep(result.stdout, "valid/sorted.ts")).not.toContain("sort-imports")
880
+ */
881
+ function grep(output, pattern) {
882
+ return output.replace(/\x1b\[[0-9;]*m/g, "").split(/\n\s*\n/).filter((block) => block.includes(pattern)).join("\n\n");
924
883
  }
925
884
  //#endregion
926
- //#region src/docker/docker-adapter.ts
885
+ //#region src/spec/result/json.ts
886
+ function shouldUpdateSnapshots$1() {
887
+ if (process.env.JTERRAZZ_TEST_UPDATE === "1") return true;
888
+ if (process.env.UPDATE_SNAPSHOTS === "1") return true;
889
+ if (process.argv.includes("-u") || process.argv.includes("--update")) return true;
890
+ return false;
891
+ }
892
+ function formatJson(value) {
893
+ return `${JSON.stringify(value, null, 4)}\n`;
894
+ }
927
895
  /**
928
- * Implements {@link DockerContainerPort} by shelling out to the Docker CLI.
929
- * Each instance is bound to a single container ID.
896
+ * Accessor for a JSON payload parsed from a text stream (stdout).
897
+ *
898
+ * Lazily parses the JSON on first use; parse errors are deferred until an
899
+ * assertion is performed so that callers can still read the raw stream text
900
+ * without triggering a throw.
901
+ *
902
+ * When a `transform` is configured on the spec runner, it is applied to the
903
+ * raw stdout text BEFORE `JSON.parse`. This lets consumers strip ANSI codes,
904
+ * scrub machine-specific paths, etc. before structural comparison.
930
905
  */
931
- var DockerAdapter = class {
932
- containerId;
933
- constructor(containerId) {
934
- this.containerId = containerId;
906
+ var JsonAccessor = class {
907
+ rawText;
908
+ testDir;
909
+ transform;
910
+ constructor(rawText, testDir, transform) {
911
+ this.rawText = rawText;
912
+ this.testDir = testDir;
913
+ this.transform = transform;
935
914
  }
936
- async exec(cmd) {
937
- return (0, node_child_process.execSync)(`docker exec ${this.containerId} ${cmd.map((c) => `'${c}'`).join(" ")}`, {
938
- encoding: "utf8",
939
- timeout: 1e4
940
- }).trim();
915
+ /** The parsed JSON value. Throws if the text is not valid JSON. */
916
+ get value() {
917
+ return this.parse();
941
918
  }
942
- async file(path) {
943
- try {
944
- return {
945
- exists: true,
946
- content: await this.exec(["cat", path])
947
- };
948
- } catch {
949
- return {
950
- exists: false,
951
- content: ""
952
- };
919
+ /**
920
+ * Assert the parsed JSON deep-equals the JSON in the given file on disk.
921
+ *
922
+ * Path is resolved relative to the test file directory unless absolute.
923
+ * If `JTERRAZZ_TEST_UPDATE=1` (or vitest `-u`), the file is (re)written
924
+ * with the pretty-printed parsed value.
925
+ */
926
+ toMatchFile(path, options = {}) {
927
+ const absPath = (0, node_path.isAbsolute)(path) ? path : (0, node_path.resolve)(this.testDir, path);
928
+ const update = options.update ?? shouldUpdateSnapshots$1();
929
+ const actual = this.parse();
930
+ if (update) {
931
+ (0, node_fs.mkdirSync)((0, node_path.dirname)(absPath), { recursive: true });
932
+ (0, node_fs.writeFileSync)(absPath, formatJson(actual));
933
+ return;
953
934
  }
935
+ if (!(0, node_fs.existsSync)(absPath)) throw new Error(`JSON fixture "${path}" does not exist at ${absPath}.\nRun with JTERRAZZ_TEST_UPDATE=1 (or vitest -u) to create it.`);
936
+ const expected = JSON.parse((0, node_fs.readFileSync)(absPath, "utf8"));
937
+ if (JSON.stringify(expected) !== JSON.stringify(actual)) throw new Error(formatResponseDiff(path, expected, actual));
954
938
  }
955
- async isRunning() {
939
+ /**
940
+ * Assert the parsed JSON matches a convention-based fixture:
941
+ * `<test-file-dir>/expected/json/<name>`.
942
+ *
943
+ * The extension is part of the name and must be included by the caller —
944
+ * e.g. `toMatch('error.json')`, not `toMatch('error')`. Explicit extensions
945
+ * are clearer at the call site and remove magic from the resolution.
946
+ */
947
+ toMatch(name, options = {}) {
948
+ const absPath = (0, node_path.resolve)(this.testDir, "expected", "json", name);
949
+ this.toMatchFile(absPath, options);
950
+ }
951
+ parse() {
952
+ const source = this.transform ? this.transform(this.rawText) : this.rawText;
956
953
  try {
957
- return (0, node_child_process.execSync)(`docker inspect --format='{{.State.Running}}' ${this.containerId}`, {
958
- encoding: "utf8",
959
- timeout: 5e3
960
- }).trim() === "true";
954
+ return JSON.parse(source);
961
955
  } catch {
962
- return false;
956
+ const preview = source.slice(0, 200);
957
+ throw new Error(`stdout is not valid JSON: ${preview}`);
963
958
  }
964
959
  }
965
- async logs(tail) {
966
- return (0, node_child_process.execSync)(`docker logs ${tail ? `--tail ${tail}` : ""} ${this.containerId}`, {
967
- encoding: "utf8",
968
- timeout: 1e4
969
- });
960
+ };
961
+ //#endregion
962
+ //#region src/spec/result/table.ts
963
+ /** Assertion helper for verifying database table contents after a specification run. */
964
+ var TableAssertion = class {
965
+ tableName;
966
+ db;
967
+ constructor(tableName, db) {
968
+ this.tableName = tableName;
969
+ this.db = db;
970
970
  }
971
- async inspect() {
972
- const raw = (0, node_child_process.execSync)(`docker inspect ${this.containerId}`, {
973
- encoding: "utf8",
974
- timeout: 5e3
975
- });
976
- const data = JSON.parse(raw)[0];
977
- return {
978
- id: data.Id,
979
- name: data.Name,
980
- state: {
981
- running: data.State.Running,
982
- exitCode: data.State.ExitCode,
983
- status: data.State.Status
984
- },
985
- config: {
986
- image: data.Config.Image,
987
- env: data.Config.Env || []
988
- },
989
- hostConfig: {
990
- memory: data.HostConfig.Memory || 0,
991
- cpuQuota: data.HostConfig.CpuQuota || 0,
992
- networkMode: data.HostConfig.NetworkMode || "",
993
- mounts: (data.Mounts || []).map((m) => ({
994
- source: m.Source,
995
- destination: m.Destination,
996
- type: m.Type
997
- }))
998
- },
999
- networkSettings: { networks: Object.fromEntries(Object.entries(data.NetworkSettings.Networks || {}).map(([name, net]) => [name, {
1000
- gateway: net.Gateway,
1001
- ipAddress: net.IPAddress
1002
- }])) }
1003
- };
971
+ /**
972
+ * Assert that the table contains exactly the expected rows for the given columns.
973
+ *
974
+ * @example
975
+ * await result.table("users").toMatch({
976
+ * columns: ["name", "email"],
977
+ * rows: [["Alice", "alice@example.com"]],
978
+ * });
979
+ */
980
+ async toMatch(expected) {
981
+ const actual = await this.db.query(this.tableName, expected.columns);
982
+ if (JSON.stringify(actual) !== JSON.stringify(expected.rows)) throw new Error(formatTableDiff(this.tableName, expected.columns, expected.rows, actual));
1004
983
  }
1005
- async exists(path) {
1006
- try {
1007
- await this.exec([
1008
- "test",
1009
- "-e",
1010
- path
1011
- ]);
1012
- return true;
1013
- } catch {
1014
- return false;
1015
- }
984
+ /** Assert that the table has zero rows. */
985
+ async toBeEmpty() {
986
+ const actual = await this.db.query(this.tableName, ["*"]);
987
+ if (actual.length !== 0) throw new Error(`Expected table "${this.tableName}" to be empty, but it has ${actual.length} rows`);
1016
988
  }
1017
989
  };
1018
- /** Create a {@link DockerContainerPort} bound to an existing container by ID. */
1019
- function dockerContainer(containerId) {
1020
- return new DockerAdapter(containerId);
1021
- }
1022
990
  //#endregion
1023
- //#region src/docker/docker-assertion.ts
991
+ //#region src/spec/result/result.ts
1024
992
  /**
1025
- * Fluent assertion builder for Docker containers.
1026
- * Chain async assertions like `toBeRunning()`, `toHaveFile()`, `toHaveMount()`.
993
+ * Base result - common accessors available after any action type.
994
+ * Extended by HttpResult, CliResult, and used directly by JobResult.
1027
995
  */
1028
- var DockerAssertion = class {
1029
- container;
1030
- constructor(container) {
1031
- this.container = container;
996
+ var BaseResult = class {
997
+ config;
998
+ testDir;
999
+ workDir;
1000
+ constructor(options) {
1001
+ this.config = options.config;
1002
+ this.testDir = options.testDir;
1003
+ this.workDir = options.workDir;
1032
1004
  }
1033
- /** Assert the container is running */
1034
- async toBeRunning() {
1035
- if (!await this.container.isRunning()) throw new Error("Expected container to be running");
1036
- return this;
1005
+ /** Access a directory (relative to the working directory) for snapshot assertions. */
1006
+ directory(path = ".") {
1007
+ return new DirectoryAccessor((0, node_path.resolve)(this.workDir ?? this.testDir, path), this.testDir);
1037
1008
  }
1038
- /** Assert the container is NOT running / doesn't exist */
1039
- async toNotExist() {
1040
- if (await this.container.isRunning()) throw new Error("Expected container to not exist or not be running");
1041
- return this;
1009
+ /** Access a single file (relative to the working directory) for content assertions. */
1010
+ file(path) {
1011
+ const resolvedPath = (0, node_path.resolve)(this.workDir ?? this.testDir, path);
1012
+ const exists = (0, node_fs.existsSync)(resolvedPath);
1013
+ return {
1014
+ get content() {
1015
+ if (!exists) throw new Error(`File not found: ${path}`);
1016
+ return (0, node_fs.readFileSync)(resolvedPath, "utf8");
1017
+ },
1018
+ exists
1019
+ };
1042
1020
  }
1043
- /** Assert a file exists inside the container */
1044
- async toHaveFile(path, opts) {
1045
- const file = await this.container.file(path);
1046
- if (!file.exists) throw new Error(`Expected file ${path} to exist in container`);
1047
- if (opts?.containing && !file.content.includes(opts.containing)) throw new Error(`Expected file ${path} to contain "${opts.containing}", got: ${file.content.slice(0, 200)}`);
1048
- return this;
1021
+ /** Access a database table for row-level assertions. */
1022
+ table(tableName, options) {
1023
+ const db = this.resolveDatabase(options?.service);
1024
+ if (!db) throw new Error(options?.service ? `table("${tableName}") requires database "${options.service}" but it was not found` : `table("${tableName}") requires a database adapter`);
1025
+ return new TableAssertion(tableName, db);
1049
1026
  }
1050
- /** Assert a file does NOT exist */
1051
- async toNotHaveFile(path) {
1052
- if (await this.container.exists(path)) throw new Error(`Expected ${path} to not exist in container`);
1053
- return this;
1027
+ resolveDatabase(serviceName) {
1028
+ if (serviceName && this.config.databases) return this.config.databases.get(serviceName);
1029
+ return this.config.database;
1054
1030
  }
1055
- /** Assert a directory exists */
1056
- async toHaveDirectory(path) {
1057
- if (!await this.container.exists(path)) throw new Error(`Expected directory ${path} to exist in container`);
1058
- return this;
1031
+ };
1032
+ //#endregion
1033
+ //#region src/spec/result/stream.ts
1034
+ function shouldUpdateSnapshots() {
1035
+ if (process.env.JTERRAZZ_TEST_UPDATE === "1") return true;
1036
+ if (process.env.UPDATE_SNAPSHOTS === "1") return true;
1037
+ if (process.argv.includes("-u") || process.argv.includes("--update")) return true;
1038
+ return false;
1039
+ }
1040
+ /**
1041
+ * Accessor for a captured text stream (stdout/stderr) with file-based
1042
+ * assertion support.
1043
+ *
1044
+ * Backed by a primitive string, exposed via {@link text}, but also provides
1045
+ * `toString()` / `valueOf()` for string coercion, so common patterns like
1046
+ * `String(result.stdout)` and template-literal interpolation still work.
1047
+ */
1048
+ var StreamAccessor = class {
1049
+ streamName;
1050
+ testDir;
1051
+ transform;
1052
+ text;
1053
+ constructor(text, streamName, testDir, transform) {
1054
+ this.text = text;
1055
+ this.streamName = streamName;
1056
+ this.testDir = testDir;
1057
+ this.transform = transform;
1059
1058
  }
1060
- /** Assert a mount exists */
1061
- async toHaveMount(destination) {
1062
- const info = await this.container.inspect();
1063
- if (!info.hostConfig.mounts.find((m) => m.destination === destination)) {
1064
- const available = info.hostConfig.mounts.map((m) => m.destination).join(", ");
1065
- throw new Error(`Expected mount at ${destination}, found: [${available}]`);
1059
+ /**
1060
+ * Assert the captured text matches the given file on disk.
1061
+ *
1062
+ * Path is resolved relative to the test file directory unless absolute.
1063
+ * If `JTERRAZZ_TEST_UPDATE=1` (or vitest `-u`), the file is (re)written
1064
+ * with the actual text.
1065
+ *
1066
+ * When a `transform` is configured on the spec runner, it is applied to
1067
+ * the actual text before comparison (and before writing in update mode).
1068
+ * The fixture file is treated as authoritative and is NOT transformed.
1069
+ */
1070
+ toMatchFile(path, options = {}) {
1071
+ const absPath = (0, node_path.isAbsolute)(path) ? path : (0, node_path.resolve)(this.testDir, path);
1072
+ const update = options.update ?? shouldUpdateSnapshots();
1073
+ const actual = this.transform ? this.transform(this.text) : this.text;
1074
+ if (update) {
1075
+ (0, node_fs.mkdirSync)((0, node_path.dirname)(absPath), { recursive: true });
1076
+ (0, node_fs.writeFileSync)(absPath, actual);
1077
+ return;
1066
1078
  }
1067
- return this;
1068
- }
1069
- /** Assert network mode */
1070
- async toHaveNetwork(mode) {
1071
- const info = await this.container.inspect();
1072
- if (!info.hostConfig.networkMode.includes(mode)) throw new Error(`Expected network mode "${mode}", got "${info.hostConfig.networkMode}"`);
1073
- return this;
1074
- }
1075
- /** Assert memory limit */
1076
- async toHaveMemoryLimit(bytes) {
1077
- const info = await this.container.inspect();
1078
- if (info.hostConfig.memory !== bytes) throw new Error(`Expected memory limit ${bytes}, got ${info.hostConfig.memory}`);
1079
- return this;
1079
+ if (!(0, node_fs.existsSync)(absPath)) throw new Error(`${this.streamName} fixture "${path}" does not exist at ${absPath}.\nRun with JTERRAZZ_TEST_UPDATE=1 (or vitest -u) to create it.`);
1080
+ const expected = (0, node_fs.readFileSync)(absPath, "utf8");
1081
+ if (expected !== actual) throw new Error(formatStdoutDiff(path, expected, actual));
1080
1082
  }
1081
- /** Assert CPU quota */
1082
- async toHaveCpuQuota(quota) {
1083
- const info = await this.container.inspect();
1084
- if (info.hostConfig.cpuQuota !== quota) throw new Error(`Expected CPU quota ${quota}, got ${info.hostConfig.cpuQuota}`);
1085
- return this;
1083
+ /**
1084
+ * Assert the captured text matches a convention-based fixture:
1085
+ * `<test-file-dir>/expected/<stream>/<name>`.
1086
+ *
1087
+ * The extension is part of the name and must be included by the caller —
1088
+ * e.g. `toMatch('valid.txt')`, not `toMatch('valid')`. Explicit extensions
1089
+ * are clearer at the call site and remove magic from the resolution.
1090
+ */
1091
+ toMatch(name, options = {}) {
1092
+ const absPath = (0, node_path.resolve)(this.testDir, "expected", this.streamName, name);
1093
+ this.toMatchFile(absPath, options);
1086
1094
  }
1087
- /** Execute a command and return output for custom assertions */
1088
- async exec(cmd) {
1089
- return this.container.exec(cmd);
1095
+ /**
1096
+ * Assert the captured text contains the given substring. The runner's
1097
+ * `transform` (if any) is applied before the check so callers can rely
1098
+ * on the same normalised view as {@link toMatch}.
1099
+ *
1100
+ * Throws with a tight diff-style error on miss so tests read uniformly
1101
+ * with the other accessor assertions (no reaching through `.text` into
1102
+ * `expect(...).toContain(...)`).
1103
+ */
1104
+ toContain(substring) {
1105
+ const actual = this.transform ? this.transform(this.text) : this.text;
1106
+ if (actual.includes(substring)) return;
1107
+ throw new Error(`${this.streamName} does not contain expected substring.\n expected to contain: ${JSON.stringify(substring)}\n actual: ${JSON.stringify(actual.length > 500 ? `${actual.slice(0, 500)}…` : actual)}`);
1090
1108
  }
1091
- /** Read a file for custom assertions */
1092
- async readFile(path) {
1093
- const file = await this.container.file(path);
1094
- if (!file.exists) throw new Error(`File ${path} does not exist`);
1095
- return file.content;
1109
+ toString() {
1110
+ return this.text;
1096
1111
  }
1097
- /** Get logs for custom assertions */
1098
- async getLogs(tail) {
1099
- return this.container.logs(tail);
1112
+ valueOf() {
1113
+ return this.text;
1100
1114
  }
1101
1115
  };
1102
1116
  //#endregion
1103
- //#region src/infra/adapters/compose.adapter.ts
1117
+ //#region src/spec/modes/cli/container-accessor.ts
1118
+ const EXEC_TIMEOUT = 1e4;
1119
+ function readInspectState(inspect) {
1120
+ const state = inspect?.State ?? inspect?.state;
1121
+ if (!state) return {
1122
+ running: false,
1123
+ status: "unknown"
1124
+ };
1125
+ return {
1126
+ running: Boolean(state.Running ?? state.running ?? false),
1127
+ status: String(state.Status ?? state.status ?? "unknown")
1128
+ };
1129
+ }
1104
1130
  /**
1105
- * Start the full compose stack and stop it all on cleanup.
1106
- * Supports per-worker project names for parallel execution.
1131
+ * Assertion accessor for a single Docker container captured by the docker()
1132
+ * spec mode. Mirrors the shape of {@link CliResult} so tests use the same
1133
+ * vocabulary (`stdout.toContain`, `file(...).content`, etc.) regardless of
1134
+ * where output came from.
1135
+ *
1136
+ * Sync state (`exists`, `running`, `status`) is derived from the `docker
1137
+ * inspect` payload captured at result-construction time. Logs are fetched
1138
+ * lazily on first access to `stdout`/`stderr`.
1107
1139
  */
1108
- var ComposeStackAdapter = class {
1109
- composeFile;
1110
- projectName;
1111
- started = false;
1112
- constructor(composeFile, projectName) {
1113
- this.composeFile = composeFile;
1114
- this.projectName = projectName ?? null;
1140
+ var ContainerAccessor = class {
1141
+ cachedLogs = null;
1142
+ containerId;
1143
+ exists;
1144
+ inspectData;
1145
+ running;
1146
+ status;
1147
+ testDir;
1148
+ transform;
1149
+ /**
1150
+ * Underlying Docker container ID, or `null` if no container was captured
1151
+ * for this name. Useful when a follow-up CLI command needs to reference
1152
+ * the container by id (e.g. `spwn world inspect <id>`). Prefer the other
1153
+ * accessors for common reads — pulling the raw id is the escape hatch.
1154
+ */
1155
+ get id() {
1156
+ return this.containerId;
1115
1157
  }
1116
- get projectFlag() {
1117
- return this.projectName ? `-p ${this.projectName}` : "";
1158
+ constructor(containerId, inspectData, testDir, transform) {
1159
+ this.containerId = containerId;
1160
+ this.inspectData = inspectData;
1161
+ this.testDir = testDir;
1162
+ this.transform = transform;
1163
+ this.exists = containerId !== null;
1164
+ if (this.exists) {
1165
+ const state = readInspectState(inspectData);
1166
+ this.running = state.running;
1167
+ this.status = state.status;
1168
+ } else {
1169
+ this.running = false;
1170
+ this.status = "absent";
1171
+ }
1118
1172
  }
1119
- run(command) {
1120
- try {
1121
- return (0, node_child_process.execSync)(command, {
1122
- cwd: (0, node_path.dirname)(this.composeFile),
1123
- encoding: "utf8",
1124
- timeout: 12e4
1125
- }).trim();
1126
- } catch (error) {
1127
- const stderr = error.stderr?.toString().trim() ?? error.message;
1128
- throw new Error(`docker compose failed: ${stderr}`, { cause: error });
1129
- }
1130
- }
1131
- async start() {
1132
- if (this.started) return;
1133
- this.run(`docker compose ${this.projectFlag} -f ${this.composeFile} up -d --wait`);
1134
- this.started = true;
1173
+ /**
1174
+ * Raw `docker inspect` object for this container. Throws if the container
1175
+ * was not captured (i.e. `.exists === false`).
1176
+ */
1177
+ get inspect() {
1178
+ this.requireExists("inspect");
1179
+ return new JsonAccessor(JSON.stringify(this.inspectData), this.testDir, this.transform);
1135
1180
  }
1136
- async stop() {
1137
- if (!this.started) return;
1138
- this.run(`docker compose ${this.projectFlag} -f ${this.composeFile} down -v`);
1139
- this.started = false;
1181
+ /** Captured logs (stdout+stderr combined for v1). */
1182
+ get stdout() {
1183
+ this.requireExists("stdout");
1184
+ return new StreamAccessor(this.loadLogs(), "stdout", this.testDir, this.transform);
1140
1185
  }
1141
- getMappedPort(serviceName, containerPort) {
1142
- const port = this.run(`docker compose ${this.projectFlag} -f ${this.composeFile} port ${serviceName} ${containerPort}`).split(":").pop();
1143
- return Number(port);
1186
+ /** Captured logs (stdout+stderr combined for v1). */
1187
+ get stderr() {
1188
+ this.requireExists("stderr");
1189
+ return new StreamAccessor(this.loadLogs(), "stderr", this.testDir, this.transform);
1144
1190
  }
1145
- getHost() {
1146
- return "localhost";
1191
+ /**
1192
+ * Read a file from inside the container via `docker exec cat`. The
1193
+ * returned object satisfies the same {@link FileAccessor} shape used by
1194
+ * the host filesystem accessor, so tests do not need to learn a new API.
1195
+ */
1196
+ file(path) {
1197
+ this.requireExists("file");
1198
+ const id = this.containerId;
1199
+ const exists = this.containerExec(id, [
1200
+ "test",
1201
+ "-e",
1202
+ path
1203
+ ]).exitCode === 0;
1204
+ return {
1205
+ get content() {
1206
+ if (!exists) throw new Error(`File not found in container: ${path}`);
1207
+ return (0, node_child_process.execSync)(`docker exec ${id} cat ${JSON.stringify(path)}`, {
1208
+ encoding: "utf8",
1209
+ timeout: EXEC_TIMEOUT
1210
+ });
1211
+ },
1212
+ exists
1213
+ };
1147
1214
  }
1148
- };
1149
- //#endregion
1150
- //#region src/infra/adapters/testcontainers.adapter.ts
1151
- /**
1152
- * Container adapter using testcontainers.
1153
- * Wraps a GenericContainer for programmatic container lifecycle.
1154
- */
1155
- var TestcontainersAdapter = class {
1156
- image;
1157
- containerPort;
1158
- env;
1159
- reuse;
1160
- container = null;
1161
- constructor(options) {
1162
- this.image = options.image;
1163
- this.containerPort = options.port;
1164
- this.env = options.env ?? {};
1165
- this.reuse = options.reuse ?? false;
1215
+ /**
1216
+ * Run a shell command inside the container and get back the same
1217
+ * {@link CliResult} used for host-side executions.
1218
+ */
1219
+ async exec(cmd) {
1220
+ this.requireExists("exec");
1221
+ return new CliResult({
1222
+ commandResult: this.containerExec(this.containerId, [
1223
+ "sh",
1224
+ "-c",
1225
+ cmd
1226
+ ]),
1227
+ config: {},
1228
+ testDir: this.testDir,
1229
+ transform: this.transform,
1230
+ workDir: this.testDir
1231
+ });
1166
1232
  }
1167
- async start() {
1168
- const { GenericContainer, Wait } = await import("testcontainers");
1169
- let builder = new GenericContainer(this.image).withExposedPorts(this.containerPort);
1170
- for (const [key, value] of Object.entries(this.env)) builder = builder.withEnvironment({ [key]: value });
1171
- if (this.image.startsWith("postgres")) builder = builder.withWaitStrategy(Wait.forLogMessage(/database system is ready to accept connections/, 2));
1172
- if (this.reuse) builder = builder.withReuse();
1173
- this.container = await builder.start();
1233
+ requireExists(member) {
1234
+ if (!this.exists) throw new Error(`ContainerAccessor.${member}: container does not exist (check .exists first)`);
1174
1235
  }
1175
- async stop() {
1176
- if (this.container && !this.reuse) {
1177
- await this.container.stop();
1178
- this.container = null;
1236
+ loadLogs() {
1237
+ if (this.cachedLogs !== null) return this.cachedLogs;
1238
+ try {
1239
+ this.cachedLogs = (0, node_child_process.execSync)(`docker logs ${this.containerId}`, {
1240
+ encoding: "utf8",
1241
+ stdio: [
1242
+ "ignore",
1243
+ "pipe",
1244
+ "pipe"
1245
+ ],
1246
+ timeout: EXEC_TIMEOUT
1247
+ });
1248
+ } catch (error) {
1249
+ this.cachedLogs = typeof error?.stdout === "string" ? error.stdout : String(error?.stderr ?? "");
1179
1250
  }
1251
+ return this.cachedLogs ?? "";
1180
1252
  }
1181
- getMappedPort(containerPort) {
1182
- if (!this.container) throw new Error("Container not started");
1183
- return this.container.getMappedPort(containerPort);
1184
- }
1185
- getHost() {
1186
- if (!this.container) throw new Error("Container not started");
1187
- return this.container.getHost();
1188
- }
1189
- getConnectionString() {
1190
- return `${this.getHost()}:${this.getMappedPort(this.containerPort)}`;
1191
- }
1192
- async getLogs() {
1193
- if (!this.container) return "";
1194
- const stream = await this.container.logs();
1195
- return new Promise((resolve) => {
1196
- let output = "";
1197
- stream.on("data", (chunk) => {
1198
- output += chunk.toString();
1199
- });
1200
- stream.on("end", () => {
1201
- resolve(output);
1202
- });
1203
- setTimeout(() => {
1204
- resolve(output);
1205
- }, 1e3);
1206
- });
1253
+ containerExec(id, argv) {
1254
+ try {
1255
+ return {
1256
+ exitCode: 0,
1257
+ stderr: "",
1258
+ stdout: (0, node_child_process.execSync)(`docker exec ${id} ${argv.map((a) => JSON.stringify(a)).join(" ")}`, {
1259
+ encoding: "utf8",
1260
+ stdio: [
1261
+ "ignore",
1262
+ "pipe",
1263
+ "pipe"
1264
+ ],
1265
+ timeout: EXEC_TIMEOUT
1266
+ })
1267
+ };
1268
+ } catch (error) {
1269
+ return {
1270
+ exitCode: typeof error?.status === "number" ? error.status : 1,
1271
+ stderr: typeof error?.stderr === "string" ? error.stderr : String(error?.message ?? ""),
1272
+ stdout: typeof error?.stdout === "string" ? error.stdout : ""
1273
+ };
1274
+ }
1207
1275
  }
1208
1276
  };
1209
1277
  //#endregion
1210
- //#region src/infra/compose-parser.ts
1211
- /**
1212
- * Detect the service type from the image name.
1213
- */
1214
- function detectServiceType(image) {
1215
- if (!image) return "app";
1216
- const lower = image.toLowerCase();
1217
- if (lower.startsWith("postgres")) return "postgres";
1218
- if (lower.startsWith("redis")) return "redis";
1219
- return "unknown";
1220
- }
1278
+ //#region src/spec/modes/cli/docker-lookup.ts
1221
1279
  /**
1222
- * Find the compose file in the project.
1223
- * Looks for docker/compose.test.yaml or docker-compose.test.yaml.
1280
+ * Thin synchronous wrappers around the `docker` CLI used by the docker() spec
1281
+ * mode. Sync is deliberate — the calls are fast, run only on the host, and
1282
+ * keep the dispose path straightforward (`Symbol.asyncDispose` can still be
1283
+ * async without needing these to be).
1224
1284
  */
1225
- function findComposeFile(projectRoot) {
1226
- const candidates = [
1227
- (0, node_path.resolve)(projectRoot, "docker/compose.test.yaml"),
1228
- (0, node_path.resolve)(projectRoot, "docker/compose.test.yml"),
1229
- (0, node_path.resolve)(projectRoot, "docker-compose.test.yaml"),
1230
- (0, node_path.resolve)(projectRoot, "docker-compose.test.yml")
1231
- ];
1232
- for (const candidate of candidates) if ((0, node_fs.existsSync)(candidate)) return candidate;
1233
- return null;
1285
+ const DEFAULT_TIMEOUT = 1e4;
1286
+ /** Return all container IDs (running or stopped) that carry `key=value`. */
1287
+ function findContainersByLabel(key, value) {
1288
+ try {
1289
+ return (0, node_child_process.execSync)(`docker ps -aq -f label=${key}=${value}`, {
1290
+ encoding: "utf8",
1291
+ timeout: DEFAULT_TIMEOUT
1292
+ }).split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
1293
+ } catch {
1294
+ return [];
1295
+ }
1234
1296
  }
1235
- /**
1236
- * Parse a docker-compose file and extract service definitions.
1237
- */
1238
- function parseComposeFile(filePath) {
1239
- const doc = (0, yaml.parse)((0, node_fs.readFileSync)(filePath, "utf8"));
1240
- if (!doc?.services) return {
1241
- services: [],
1242
- appService: null,
1243
- infraServices: []
1244
- };
1245
- const services = Object.entries(doc.services).map(([name, def]) => {
1246
- const ports = [];
1247
- if (def.ports) for (const port of def.ports) {
1248
- const str = String(port);
1249
- if (str.includes(":")) {
1250
- const [host, container] = str.split(":");
1251
- ports.push({
1252
- container: Number(container),
1253
- host: Number(host)
1254
- });
1255
- } else ports.push({ container: Number(str) });
1256
- }
1257
- const environment = {};
1258
- if (def.environment) if (Array.isArray(def.environment)) for (const env of def.environment) {
1259
- const [key, ...rest] = String(env).split("=");
1260
- environment[key] = rest.join("=");
1261
- }
1262
- else Object.assign(environment, def.environment);
1263
- const volumes = def.volumes ? def.volumes.map((v) => String(v)) : [];
1264
- let dependsOn = [];
1265
- if (def.depends_on) dependsOn = Array.isArray(def.depends_on) ? def.depends_on : Object.keys(def.depends_on);
1266
- return {
1267
- name,
1268
- image: def.image,
1269
- build: def.build,
1270
- ports,
1271
- environment,
1272
- volumes,
1273
- dependsOn
1274
- };
1297
+ /** Return the raw `docker inspect` payload (object, not array) for a container. */
1298
+ function inspectContainer(id) {
1299
+ const raw = (0, node_child_process.execSync)(`docker inspect ${id}`, {
1300
+ encoding: "utf8",
1301
+ timeout: DEFAULT_TIMEOUT
1275
1302
  });
1276
- return {
1277
- services,
1278
- appService: services.find((s) => s.build !== void 0) ?? null,
1279
- infraServices: services.filter((s) => s.build === void 0)
1280
- };
1303
+ const parsed = JSON.parse(raw);
1304
+ return Array.isArray(parsed) ? parsed[0] : parsed;
1305
+ }
1306
+ /** Force-remove the given container IDs in a single call. Errors are swallowed. */
1307
+ function removeContainers(ids) {
1308
+ if (ids.length === 0) return;
1309
+ try {
1310
+ (0, node_child_process.execSync)(`docker rm -f ${ids.join(" ")}`, {
1311
+ encoding: "utf8",
1312
+ stdio: "pipe",
1313
+ timeout: DEFAULT_TIMEOUT
1314
+ });
1315
+ } catch {}
1281
1316
  }
1282
1317
  //#endregion
1283
- //#region src/infra/orchestrator.ts
1318
+ //#region src/spec/modes/cli/result.ts
1284
1319
  /**
1285
- * Orchestrator for test infrastructure.
1286
- * Integration: starts services via testcontainers.
1287
- * E2E: runs full docker compose up.
1320
+ * Result from a CLI action (.exec(), .spawn()).
1321
+ *
1322
+ * When the runner was configured with a `docker` option, the result also
1323
+ * exposes container accessors and participates in `Symbol.asyncDispose`
1324
+ * cleanup. The Docker shell-outs are **lazy**: a test that never calls
1325
+ * `.container(...)` never queries the Docker daemon, so CLI-only tests
1326
+ * pay zero Docker cost even when the runner is Docker-aware.
1327
+ *
1328
+ * Dispose always runs one final label-filtered `docker ps` to catch
1329
+ * containers that were spawned during `.exec` but never asserted on —
1330
+ * tests still get leak-free cleanup even if they forget to reach into
1331
+ * a container.
1288
1332
  */
1289
- var Orchestrator = class {
1290
- services;
1291
- mode;
1292
- root;
1293
- projectName;
1294
- running = [];
1295
- composeStack = null;
1296
- composeHandles = [];
1297
- started = false;
1333
+ var CliResult = class extends BaseResult {
1334
+ commandResult;
1335
+ containersCache = null;
1336
+ dockerConfig;
1337
+ testRunId;
1338
+ transform;
1298
1339
  constructor(options) {
1299
- this.services = options.services;
1300
- this.mode = options.mode;
1301
- this.root = options.root ?? process.cwd();
1302
- this.projectName = options.projectName;
1340
+ super(options);
1341
+ this.commandResult = options.commandResult;
1342
+ this.dockerConfig = options.dockerConfig;
1343
+ this.testRunId = options.testRunId;
1344
+ this.transform = options.transform;
1345
+ }
1346
+ /** The process exit code. */
1347
+ get exitCode() {
1348
+ return this.commandResult.exitCode;
1349
+ }
1350
+ /** Accessor for the captured standard output with file-based assertions. */
1351
+ get stdout() {
1352
+ return new StreamAccessor(this.commandResult.stdout, "stdout", this.testDir, this.transform);
1353
+ }
1354
+ /** Accessor for the captured standard error with file-based assertions. */
1355
+ get stderr() {
1356
+ return new StreamAccessor(this.commandResult.stderr, "stderr", this.testDir, this.transform);
1357
+ }
1358
+ /** Accessor for parsing stdout as JSON and asserting against JSON fixtures. */
1359
+ get json() {
1360
+ return new JsonAccessor(this.commandResult.stdout, this.testDir, this.transform);
1361
+ }
1362
+ /** Accessor for the temporary working directory the command ran in. */
1363
+ get filesystem() {
1364
+ if (!this.workDir) throw new Error("CliResult.filesystem requires a working directory");
1365
+ return new FilesystemAccessor(this.workDir, this.testDir);
1303
1366
  }
1304
1367
  /**
1305
- * Start declared services via testcontainers (integration mode).
1306
- * Phase 1: start all containers in parallel (the slow part).
1307
- * Phase 2: wire connections, healthcheck, and init sequentially (fast).
1368
+ * Look up a container the CLI spawned during this run by the value of
1369
+ * its name-label (as declared in `SpecOptions.docker.nameLabel`).
1370
+ * First access triggers a one-shot `docker ps` + `docker inspect`
1371
+ * query and caches the result for the rest of the result's lifetime.
1372
+ * Tests that don't call this never touch Docker.
1373
+ *
1374
+ * Returns an accessor with `.exists === false` when no container
1375
+ * carries that name — callers can still assert absence without a try.
1308
1376
  */
1309
- async start() {
1310
- if (this.started) return;
1311
- const composePath = findComposeFile(this.root);
1312
- const composeDir = composePath ? (0, node_path.dirname)(composePath) : this.root;
1313
- const composeConfig = composePath ? parseComposeFile(composePath) : null;
1314
- const containerServices = [];
1315
- const embeddedServices = [];
1316
- for (const handle of this.services) {
1317
- if (handle.defaultPort === 0) {
1318
- embeddedServices.push(handle);
1377
+ container(name) {
1378
+ this.ensureDockerAware("container");
1379
+ this.loadContainers();
1380
+ const captured = this.containersCache.get(name);
1381
+ if (!captured) return new ContainerAccessor(null, null, this.testDir, this.transform);
1382
+ return new ContainerAccessor(captured.id, captured.inspect, this.testDir, this.transform);
1383
+ }
1384
+ /** All captured container IDs. Triggers the same lazy query as `container()`. */
1385
+ get containerIds() {
1386
+ this.ensureDockerAware("containerIds");
1387
+ this.loadContainers();
1388
+ return [...this.containersCache.values()].map((c) => c.id);
1389
+ }
1390
+ async [Symbol.asyncDispose]() {
1391
+ if (!this.dockerConfig || !this.testRunId) return;
1392
+ removeContainers(this.containersCache !== null ? [...this.containersCache.values()].map((c) => c.id) : findContainersByLabel(this.dockerConfig.testRunLabel, this.testRunId));
1393
+ }
1394
+ /**
1395
+ * Extract text blocks from stdout that contain a pattern.
1396
+ *
1397
+ * @example
1398
+ * expect(result.grep('error.ts')).toContain('no-unused-vars');
1399
+ */
1400
+ grep(pattern) {
1401
+ return grep(this.commandResult.stdout, pattern);
1402
+ }
1403
+ ensureDockerAware(member) {
1404
+ if (!this.dockerConfig || !this.testRunId) throw new Error(`CliResult.${member}: runner was not configured with a docker option. Pass \`docker: { envVar, nameLabel, testRunLabel }\` in SpecOptions.`);
1405
+ }
1406
+ loadContainers() {
1407
+ if (this.containersCache !== null) return;
1408
+ const ids = findContainersByLabel(this.dockerConfig.testRunLabel, this.testRunId);
1409
+ const map = /* @__PURE__ */ new Map();
1410
+ for (const id of ids) {
1411
+ let inspect;
1412
+ try {
1413
+ inspect = inspectContainer(id);
1414
+ } catch {
1319
1415
  continue;
1320
1416
  }
1321
- let image = handle.defaultImage;
1322
- let env = { ...handle.environment };
1323
- if (handle.composeName && composeConfig) {
1324
- const composeService = composeConfig.services.find((s) => s.name === handle.composeName);
1325
- if (composeService) {
1326
- image = composeService.image ?? image;
1327
- env = {
1328
- ...env,
1329
- ...composeService.environment
1330
- };
1331
- Object.assign(handle.environment, composeService.environment);
1332
- }
1333
- }
1334
- const container = new TestcontainersAdapter({
1335
- image,
1336
- port: handle.defaultPort,
1337
- env
1338
- });
1339
- containerServices.push({
1340
- container,
1341
- handle
1417
+ const nameValue = (inspect?.Config?.Labels ?? inspect?.config?.labels ?? {})[this.dockerConfig.nameLabel];
1418
+ const key = typeof nameValue === "string" && nameValue.length > 0 ? nameValue : id;
1419
+ map.set(key, {
1420
+ id,
1421
+ inspect
1342
1422
  });
1343
1423
  }
1344
- await Promise.all([...containerServices.map(({ container }) => container.start()), ...embeddedServices.map(async (handle) => {
1345
- await handle.initialize(composeDir);
1346
- handle.started = true;
1347
- this.running.push({
1348
- handle,
1349
- container: null
1424
+ this.containersCache = map;
1425
+ }
1426
+ };
1427
+ //#endregion
1428
+ //#region src/spec/result/response.ts
1429
+ /** Accessor for an HTTP response body with file-based assertion support. */
1430
+ var ResponseAccessor = class {
1431
+ body;
1432
+ testDir;
1433
+ constructor(body, testDir) {
1434
+ this.body = body;
1435
+ this.testDir = testDir;
1436
+ }
1437
+ /**
1438
+ * Assert that the response body matches the JSON in `responses/{file}`.
1439
+ *
1440
+ * @example
1441
+ * result.response.toMatchFile("expected-items.json");
1442
+ */
1443
+ toMatchFile(file) {
1444
+ const expected = JSON.parse((0, node_fs.readFileSync)((0, node_path.resolve)(this.testDir, "responses", file), "utf8"));
1445
+ if (JSON.stringify(this.body) !== JSON.stringify(expected)) throw new Error(formatResponseDiff(file, expected, this.body));
1446
+ }
1447
+ };
1448
+ //#endregion
1449
+ //#region src/spec/modes/http/result.ts
1450
+ /** Result from an HTTP action (.get(), .post(), .put(), .delete()). */
1451
+ var HttpResult = class extends BaseResult {
1452
+ responseData;
1453
+ constructor(options) {
1454
+ super(options);
1455
+ this.responseData = options.response;
1456
+ }
1457
+ /** The HTTP response status code. */
1458
+ get status() {
1459
+ return this.responseData.status;
1460
+ }
1461
+ /** Access the HTTP response body for assertions. */
1462
+ get response() {
1463
+ return new ResponseAccessor(this.responseData.body, this.testDir);
1464
+ }
1465
+ };
1466
+ //#endregion
1467
+ //#region src/spec/builder.ts
1468
+ /**
1469
+ * Fluent builder for declaring a single test specification.
1470
+ *
1471
+ * Chain setup methods ({@link seed}, {@link fixture}, {@link env}), an action
1472
+ * ({@link get}, {@link post}, {@link exec}), then call {@link run} to execute
1473
+ * and receive a {@link SpecificationResult} for assertions.
1474
+ */
1475
+ var SpecificationBuilder = class {
1476
+ commandArgs = null;
1477
+ commandEnv = {};
1478
+ config;
1479
+ fixtures = [];
1480
+ intercepts = [];
1481
+ jobName = null;
1482
+ label;
1483
+ mocks = [];
1484
+ projectName = null;
1485
+ request = null;
1486
+ requestHeaders = {};
1487
+ seeds = [];
1488
+ spawnConfig = null;
1489
+ testDir;
1490
+ constructor(config, testDir, label) {
1491
+ this.config = config;
1492
+ this.testDir = testDir;
1493
+ this.label = label;
1494
+ }
1495
+ /**
1496
+ * Queue a SQL seed file to run before the action.
1497
+ *
1498
+ * @example
1499
+ * spec("creates user").seed("users.sql").exec("list-users").run();
1500
+ */
1501
+ seed(file, options) {
1502
+ this.seeds.push({
1503
+ file,
1504
+ service: options?.service
1505
+ });
1506
+ return this;
1507
+ }
1508
+ /** Copy a file or directory from `fixtures/` into the working directory before execution. */
1509
+ fixture(file) {
1510
+ this.fixtures.push({ file });
1511
+ return this;
1512
+ }
1513
+ /** Copy an entire project fixture from `fixturesRoot` into the working directory. */
1514
+ project(name) {
1515
+ this.projectName = name;
1516
+ return this;
1517
+ }
1518
+ /** Register an MSW mock definition file from `mock/` to intercept HTTP calls. */
1519
+ mock(file) {
1520
+ this.mocks.push({ file });
1521
+ return this;
1522
+ }
1523
+ /**
1524
+ * Set environment variables for the CLI process. Merged on top of process.env.
1525
+ * Use `null` to unset a variable. Multiple calls merge.
1526
+ *
1527
+ * The token `$WORKDIR` (in any value) is replaced with the actual working
1528
+ * directory at run-time — useful for tests that need a fully isolated `HOME`.
1529
+ *
1530
+ * @example
1531
+ * spec("...").env({ HOME: "$WORKDIR", TZ: "UTC" }).exec("status").run();
1532
+ */
1533
+ env(env) {
1534
+ this.commandEnv = {
1535
+ ...this.commandEnv,
1536
+ ...env
1537
+ };
1538
+ return this;
1539
+ }
1540
+ /**
1541
+ * Set HTTP headers for the request. Multiple calls merge.
1542
+ *
1543
+ * @example
1544
+ * spec("french").headers({ 'Accept-Language': 'fr' }).get("/articles").run();
1545
+ */
1546
+ headers(headers) {
1547
+ this.requestHeaders = {
1548
+ ...this.requestHeaders,
1549
+ ...headers
1550
+ };
1551
+ return this;
1552
+ }
1553
+ /**
1554
+ * Intercept an outgoing HTTP request and return a controlled response.
1555
+ * Uses MSW under the hood. Intercepts are queued — multiple calls with the
1556
+ * same trigger fire sequentially (first match consumed first).
1557
+ *
1558
+ * @param trigger - What to match (use openai.agent(), http.get(), etc.)
1559
+ * @param response - What to return: an InterceptResponse object, or a filename
1560
+ * from `intercepts/` (JSON loaded and auto-wrapped by the trigger).
1561
+ *
1562
+ * @example
1563
+ * // With inline response
1564
+ * .intercept(openai.agent({...}), openai.reply({ categories: ['TECH'] }))
1565
+ *
1566
+ * // With JSON fixture file (loaded from intercepts/ directory)
1567
+ * .intercept(openai.agent({...}), 'ingest-tech.json')
1568
+ * .intercept(http.get(url), 'world-news-tech.json')
1569
+ */
1570
+ intercept(trigger, response) {
1571
+ if (typeof response === "string") {
1572
+ const slashIndex = response.indexOf("/");
1573
+ if (slashIndex === -1) throw new Error(`.intercept(): file path must be 'adapter/filename.json' (e.g. 'openai/ingest-tech.json'), got '${response}'`);
1574
+ const adapterName = response.slice(0, slashIndex);
1575
+ if (adapterName !== trigger.adapter) throw new Error(`.intercept(): adapter mismatch - trigger uses '${trigger.adapter}' but file path starts with '${adapterName}/'`);
1576
+ const filePath = (0, node_path.resolve)(this.testDir, "intercepts", response);
1577
+ const data = JSON.parse((0, node_fs.readFileSync)(filePath, "utf8"));
1578
+ const resolved = trigger.wrap(data);
1579
+ this.intercepts.push({
1580
+ trigger,
1581
+ response: resolved
1350
1582
  });
1351
- })]);
1352
- const reports = [];
1353
- for (const { container, handle } of containerServices) {
1354
- const serviceStartTime = Date.now();
1355
- try {
1356
- const host = container.getHost();
1357
- const port = container.getMappedPort(handle.defaultPort);
1358
- handle.connectionString = handle.buildConnectionString(host, port);
1359
- await handle.healthcheck();
1360
- await handle.initialize(composeDir);
1361
- handle.started = true;
1362
- reports.push({
1363
- name: handle.composeName ?? handle.type,
1364
- type: handle.type,
1365
- connectionString: handle.connectionString,
1366
- durationMs: Date.now() - serviceStartTime
1367
- });
1368
- this.running.push({
1369
- handle,
1370
- container
1371
- });
1372
- } catch (error) {
1373
- let logs = "";
1374
- try {
1375
- logs = await container.getLogs();
1376
- } catch {}
1377
- try {
1378
- await container.stop();
1379
- } catch {}
1380
- reports.push({
1381
- name: handle.composeName ?? handle.type,
1382
- type: handle.type,
1383
- durationMs: Date.now() - serviceStartTime,
1384
- error: error.message,
1385
- logs
1386
- });
1387
- const output = formatStartupReport("integration", reports, { type: "in-process" });
1388
- console.error(output);
1389
- throw error;
1390
- }
1391
- }
1392
- this.started = true;
1393
- const output = formatStartupReport("integration", reports, { type: "in-process" });
1394
- console.log(output);
1583
+ } else this.intercepts.push({
1584
+ trigger,
1585
+ response
1586
+ });
1587
+ return this;
1395
1588
  }
1396
1589
  /**
1397
- * Stop testcontainers (integration mode).
1590
+ * Send a GET request to the server adapter.
1591
+ *
1592
+ * @example
1593
+ * spec("list items").get("/api/items").run();
1398
1594
  */
1399
- async stop() {
1400
- for (const { container } of this.running) if (container) await container.stop();
1401
- this.running = [];
1402
- this.started = false;
1595
+ get(path) {
1596
+ this.request = {
1597
+ method: "GET",
1598
+ path
1599
+ };
1600
+ return this;
1601
+ }
1602
+ /**
1603
+ * Send a POST request to the server adapter.
1604
+ *
1605
+ * @param bodyFile - Optional JSON file from `requests/` to use as the request body.
1606
+ * @example
1607
+ * spec("create item").post("/api/items", "new-item.json").run();
1608
+ */
1609
+ post(path, bodyFile) {
1610
+ this.request = {
1611
+ bodyFile,
1612
+ method: "POST",
1613
+ path
1614
+ };
1615
+ return this;
1616
+ }
1617
+ /** Send a PUT request to the server adapter. */
1618
+ put(path, bodyFile) {
1619
+ this.request = {
1620
+ bodyFile,
1621
+ method: "PUT",
1622
+ path
1623
+ };
1624
+ return this;
1625
+ }
1626
+ /** Send a DELETE request to the server adapter. */
1627
+ delete(path) {
1628
+ this.request = {
1629
+ method: "DELETE",
1630
+ path
1631
+ };
1632
+ return this;
1633
+ }
1634
+ /**
1635
+ * Execute a CLI command (or a sequence of commands) in an isolated working directory.
1636
+ * When an array is passed, commands run sequentially and stop on the first non-zero exit code.
1637
+ *
1638
+ * @example
1639
+ * spec("init project").exec("init --name demo").run();
1640
+ * spec("multi-step").exec(["init", "build"]).run();
1641
+ */
1642
+ exec(args) {
1643
+ this.commandArgs = args;
1644
+ return this;
1645
+ }
1646
+ /** Spawn a long-running CLI process with custom spawn options (e.g. timeout, signal). */
1647
+ spawn(args, options) {
1648
+ this.spawnConfig = {
1649
+ args,
1650
+ options
1651
+ };
1652
+ return this;
1653
+ }
1654
+ /**
1655
+ * Execute a named job registered via the app() factory.
1656
+ *
1657
+ * @param name - The job name to trigger (must match a registered JobHandle.name).
1658
+ *
1659
+ * @example
1660
+ * spec('pipeline').intercept(openai.chat(), openai.response({...})).job('report-refresh').run();
1661
+ */
1662
+ job(name) {
1663
+ this.jobName = name;
1664
+ return this;
1665
+ }
1666
+ /**
1667
+ * Execute the specification: run seeds, copy fixtures, then perform the
1668
+ * configured action (HTTP or CLI).
1669
+ *
1670
+ * @returns The result object used for assertions.
1671
+ * @example
1672
+ * const result = await spec("test").exec("status").run();
1673
+ * expect(result.exitCode).toBe(0);
1674
+ */
1675
+ async run() {
1676
+ const hasHttpAction = this.request !== null;
1677
+ const hasCliAction = this.commandArgs !== null || this.spawnConfig !== null;
1678
+ const hasJobAction = this.jobName !== null;
1679
+ const actionCount = [
1680
+ hasHttpAction,
1681
+ hasCliAction,
1682
+ hasJobAction
1683
+ ].filter(Boolean).length;
1684
+ if (actionCount === 0) throw new Error(`Specification "${this.label}": no action defined. Call .get(), .post(), .exec(), .job(), etc. before .run()`);
1685
+ if (actionCount > 1) throw new Error(`Specification "${this.label}": cannot mix action types (.get/.post, .exec/.spawn, .job)`);
1686
+ let workDir = null;
1687
+ if (hasCliAction) workDir = this.prepareWorkDir();
1688
+ if (this.config.databases) for (const db of this.config.databases.values()) await db.reset();
1689
+ else if (this.config.database) await this.config.database.reset();
1690
+ for (const entry of this.seeds) {
1691
+ const handler = this.resolveSeedHandler(entry.file);
1692
+ if (handler) {
1693
+ if (!workDir) throw new Error(`seed("${entry.file}"): pluggable seed handlers require a CLI working directory`);
1694
+ const fragmentPath = (0, node_path.resolve)(this.testDir, "seeds", entry.file);
1695
+ await handler({
1696
+ cwd: workDir,
1697
+ testDir: this.testDir
1698
+ }, fragmentPath);
1699
+ continue;
1700
+ }
1701
+ let db;
1702
+ if (entry.service && this.config.databases) {
1703
+ db = this.config.databases.get(entry.service);
1704
+ if (!db) throw new Error(`seed() targets database "${entry.service}" but it was not found. Available: ${[...this.config.databases.keys()].join(", ")}`);
1705
+ } else db = this.config.database;
1706
+ if (!db) throw new Error("seed() requires a database adapter");
1707
+ const sql = (0, node_fs.readFileSync)((0, node_path.resolve)(this.testDir, "seeds", entry.file), "utf8");
1708
+ await db.seed(sql);
1709
+ }
1710
+ if (this.fixtures.length > 0 && workDir) for (const entry of this.fixtures) (0, node_fs.cpSync)((0, node_path.resolve)(this.testDir, "fixtures", entry.file), (0, node_path.resolve)(workDir, entry.file), { recursive: true });
1711
+ let cleanupIntercepts = null;
1712
+ if (this.intercepts.length > 0) {
1713
+ const { registerIntercepts } = await Promise.resolve().then(() => require("./intercept2.cjs"));
1714
+ cleanupIntercepts = await registerIntercepts(this.intercepts);
1715
+ }
1716
+ try {
1717
+ if (hasHttpAction) return await this.runHttpAction();
1718
+ if (hasJobAction) return await this.runJobAction();
1719
+ return await this.runCliAction(workDir);
1720
+ } finally {
1721
+ if (cleanupIntercepts) cleanupIntercepts();
1722
+ }
1723
+ }
1724
+ resolveSeedHandler(file) {
1725
+ const handlers = this.config.seedHandlers;
1726
+ if (!handlers) return;
1727
+ const normalized = file.replaceAll("\\", "/");
1728
+ let bestKey;
1729
+ for (const key of Object.keys(handlers)) {
1730
+ const prefix = key.endsWith("/") ? key : `${key}/`;
1731
+ if (normalized === key || normalized.startsWith(prefix)) {
1732
+ if (!bestKey || key.length > bestKey.length) bestKey = key;
1733
+ }
1734
+ }
1735
+ return bestKey ? handlers[bestKey] : void 0;
1736
+ }
1737
+ resolveEnv(workDir) {
1738
+ const keys = Object.keys(this.commandEnv);
1739
+ if (keys.length === 0) return;
1740
+ const resolved = {};
1741
+ for (const key of keys) {
1742
+ const value = this.commandEnv[key];
1743
+ resolved[key] = typeof value === "string" ? value.replace(/\$WORKDIR/g, workDir) : value;
1744
+ }
1745
+ return resolved;
1746
+ }
1747
+ prepareWorkDir() {
1748
+ const tempDir = (0, node_fs.mkdtempSync)((0, node_path.resolve)((0, node_os.tmpdir)(), "spec-cli-"));
1749
+ if (this.projectName && this.config.fixturesRoot) {
1750
+ const projectDir = (0, node_path.resolve)(this.config.fixturesRoot, this.projectName);
1751
+ if (!(0, node_fs.existsSync)(projectDir)) throw new Error(`project("${this.projectName}"): fixture project not found at ${projectDir}`);
1752
+ (0, node_fs.cpSync)(projectDir, tempDir, { recursive: true });
1753
+ }
1754
+ return tempDir;
1755
+ }
1756
+ async runHttpAction() {
1757
+ if (!this.config.server) throw new Error("HTTP actions require a server adapter (use integration() or e2e())");
1758
+ let body;
1759
+ if (this.request.bodyFile) body = JSON.parse((0, node_fs.readFileSync)((0, node_path.resolve)(this.testDir, "requests", this.request.bodyFile), "utf8"));
1760
+ const headers = Object.keys(this.requestHeaders).length > 0 ? this.requestHeaders : void 0;
1761
+ const response = await this.config.server.request(this.request.method, this.request.path, body, headers);
1762
+ return new HttpResult({
1763
+ config: this.config,
1764
+ response,
1765
+ testDir: this.testDir
1766
+ });
1767
+ }
1768
+ async runJobAction() {
1769
+ if (!this.config.jobs?.length) throw new Error("Job actions require jobs registered via app(() => ({ server, jobs }))");
1770
+ const job = this.config.jobs.find((j) => j.name === this.jobName);
1771
+ if (!job) {
1772
+ const available = this.config.jobs.map((j) => j.name).join(", ");
1773
+ throw new Error(`job("${this.jobName}"): not found. Available: ${available}`);
1774
+ }
1775
+ await job.execute();
1776
+ return new BaseResult({
1777
+ config: this.config,
1778
+ testDir: this.testDir
1779
+ });
1780
+ }
1781
+ async runCliAction(workDir) {
1782
+ if (!this.config.command) throw new Error("CLI actions require a command adapter (use cli())");
1783
+ const dockerConfig = this.config.dockerConfig;
1784
+ const testRunId = this.config.dockerTestRunId;
1785
+ let env = this.resolveEnv(workDir);
1786
+ if (dockerConfig && testRunId) env = {
1787
+ [dockerConfig.envVar]: testRunId,
1788
+ ...env
1789
+ };
1790
+ let commandResult;
1791
+ if (this.spawnConfig) commandResult = await this.config.command.spawn(this.spawnConfig.args, workDir, this.spawnConfig.options, env);
1792
+ else if (Array.isArray(this.commandArgs)) {
1793
+ commandResult = {
1794
+ exitCode: 0,
1795
+ stderr: "",
1796
+ stdout: ""
1797
+ };
1798
+ for (const args of this.commandArgs) {
1799
+ commandResult = await this.config.command.exec(args, workDir, env);
1800
+ if (commandResult.exitCode !== 0) break;
1801
+ }
1802
+ } else commandResult = await this.config.command.exec(this.commandArgs, workDir, env);
1803
+ return new CliResult({
1804
+ commandResult,
1805
+ config: this.config,
1806
+ dockerConfig: dockerConfig ?? void 0,
1807
+ testDir: this.testDir,
1808
+ testRunId: testRunId ?? void 0,
1809
+ transform: this.config.transform,
1810
+ workDir
1811
+ });
1812
+ }
1813
+ };
1814
+ function getCallerDir() {
1815
+ const stack = (/* @__PURE__ */ new Error("caller detection")).stack;
1816
+ if (!stack) throw new Error("Cannot detect caller directory: no stack trace");
1817
+ const lines = stack.split("\n");
1818
+ for (const line of lines) {
1819
+ const match = line.match(/at\s+(?:.*?\()?(?:file:\/\/)?([^:)]+):\d+:\d+/);
1820
+ if (!match) continue;
1821
+ const filePath = match[1];
1822
+ if (filePath.includes("node_modules")) continue;
1823
+ if (filePath.includes("/src/builder/") || filePath.includes("/src/spec/")) continue;
1824
+ return (0, node_path.resolve)(filePath, "..");
1825
+ }
1826
+ throw new Error("Cannot detect caller directory from stack trace");
1827
+ }
1828
+ /**
1829
+ * Create a {@link SpecificationRunner} bound to the given adapter configuration.
1830
+ * The test file directory is auto-detected from the call stack.
1831
+ */
1832
+ function createSpecificationRunner(config) {
1833
+ const resolved = config.dockerConfig && !config.dockerTestRunId ? {
1834
+ ...config,
1835
+ dockerTestRunId: `t-${Math.random().toString(36).slice(2, 10)}-${Date.now().toString(36)}`
1836
+ } : config;
1837
+ return (label) => {
1838
+ return new SpecificationBuilder(resolved, getCallerDir(), label);
1839
+ };
1840
+ }
1841
+ //#endregion
1842
+ //#region src/spec/modes/cli/adapters/exec.adapter.ts
1843
+ /**
1844
+ * Build a child-process env from the parent env plus user overrides.
1845
+ * `null` overrides delete keys (e.g. `INIT_CWD: null`).
1846
+ */
1847
+ function buildEnv(extra) {
1848
+ const env = {
1849
+ ...process.env,
1850
+ INIT_CWD: void 0
1851
+ };
1852
+ if (extra) for (const [key, value] of Object.entries(extra)) if (value === null) delete env[key];
1853
+ else env[key] = value;
1854
+ return env;
1855
+ }
1856
+ /**
1857
+ * Executes CLI commands via Node.js child_process.
1858
+ * Uses `spawnSync` for one-shot commands and `spawn` for long-running processes.
1859
+ * Used by the command() specification runner.
1860
+ *
1861
+ * `spawnSync` is used (over the simpler `execSync`) so stdout AND stderr are
1862
+ * captured regardless of exit code. Many CLIs follow the Unix convention of
1863
+ * writing status banners to stderr on success — `execSync` would silently
1864
+ * discard them, leaving snapshot tests with no output to assert on.
1865
+ */
1866
+ var ExecAdapter = class {
1867
+ command;
1868
+ constructor(command) {
1869
+ this.command = command;
1403
1870
  }
1404
- /**
1405
- * Start full docker compose stack (e2e mode).
1406
- * Auto-detects infra services and creates handles for them.
1407
- */
1408
- async startCompose() {
1409
- const composePath = findComposeFile(this.root);
1410
- if (!composePath) throw new Error(`E2E: no compose file found in ${this.root}`);
1411
- const startTime = Date.now();
1412
- const composeDir = (0, node_path.dirname)(composePath);
1413
- const composeConfig = parseComposeFile(composePath);
1414
- this.composeStack = new ComposeStackAdapter(composePath, this.projectName);
1415
- await this.composeStack.start();
1416
- for (const service of composeConfig.infraServices) {
1417
- const type = detectServiceType(service.image);
1418
- if (type === "postgres") {
1419
- const handle = require_sqlite_adapter.postgres({
1420
- compose: service.name,
1421
- env: service.environment
1871
+ async exec(args, cwd, extraEnv) {
1872
+ const env = buildEnv(extraEnv);
1873
+ const result = (0, node_child_process.spawnSync)(`${this.command} ${args}`, [], {
1874
+ cwd,
1875
+ encoding: "utf8",
1876
+ env,
1877
+ shell: true,
1878
+ stdio: [
1879
+ "pipe",
1880
+ "pipe",
1881
+ "pipe"
1882
+ ]
1883
+ });
1884
+ return {
1885
+ exitCode: result.status ?? 1,
1886
+ stdout: result.stdout ?? "",
1887
+ stderr: result.stderr ?? ""
1888
+ };
1889
+ }
1890
+ async spawn(args, cwd, options, extraEnv) {
1891
+ const env = buildEnv(extraEnv);
1892
+ return new Promise((resolve) => {
1893
+ let stdout = "";
1894
+ let stderr = "";
1895
+ let resolved = false;
1896
+ const child = (0, node_child_process.spawn)(this.command, args.split(/\s+/).filter(Boolean), {
1897
+ cwd,
1898
+ env,
1899
+ stdio: [
1900
+ "pipe",
1901
+ "pipe",
1902
+ "pipe"
1903
+ ]
1904
+ });
1905
+ const finish = (exitCode) => {
1906
+ if (resolved) return;
1907
+ resolved = true;
1908
+ child.kill("SIGTERM");
1909
+ resolve({
1910
+ exitCode,
1911
+ stdout,
1912
+ stderr
1422
1913
  });
1423
- const port = this.composeStack.getMappedPort(service.name, 5432);
1424
- handle.connectionString = handle.buildConnectionString("localhost", port);
1425
- await handle.initialize(composeDir);
1426
- handle.started = true;
1427
- this.composeHandles.push(handle);
1428
- } else if (type === "redis") {
1429
- const handle = require_sqlite_adapter.redis({ compose: service.name });
1430
- const port = this.composeStack.getMappedPort(service.name, 6379);
1431
- handle.connectionString = handle.buildConnectionString("localhost", port);
1432
- handle.started = true;
1433
- this.composeHandles.push(handle);
1434
- }
1435
- }
1436
- const durationMs = Date.now() - startTime;
1437
- const output = formatStartupReport("e2e", this.composeHandles.map((h) => ({
1438
- name: h.composeName ?? h.type,
1439
- type: h.type,
1440
- connectionString: h.connectionString,
1441
- durationMs
1442
- })), {
1443
- type: "http",
1444
- url: this.getAppUrl() ?? void 0
1914
+ };
1915
+ let patternMatched = false;
1916
+ const checkPattern = () => {
1917
+ if (!patternMatched && (stdout.includes(options.waitFor) || stderr.includes(options.waitFor))) {
1918
+ patternMatched = true;
1919
+ finish(0);
1920
+ }
1921
+ };
1922
+ child.stdout?.on("data", (data) => {
1923
+ stdout += data.toString();
1924
+ checkPattern();
1925
+ });
1926
+ child.stderr?.on("data", (data) => {
1927
+ stderr += data.toString();
1928
+ checkPattern();
1929
+ });
1930
+ child.on("exit", (code) => {
1931
+ if (!patternMatched) finish(code === 0 ? 1 : code ?? 1);
1932
+ });
1933
+ setTimeout(() => finish(124), options.timeout);
1445
1934
  });
1446
- console.log(output);
1447
1935
  }
1448
- /**
1449
- * Stop docker compose stack (e2e mode).
1450
- */
1451
- async stopCompose() {
1452
- if (this.composeStack) {
1453
- await this.composeStack.stop();
1454
- this.composeStack = null;
1455
- }
1456
- this.composeHandles = [];
1936
+ };
1937
+ //#endregion
1938
+ //#region src/spec/modes/http/adapters/fetch.adapter.ts
1939
+ /**
1940
+ * Server adapter that sends real HTTP requests via the Fetch API.
1941
+ * Used by the `e2e()` specification runner to hit a live server.
1942
+ */
1943
+ var FetchAdapter = class {
1944
+ baseUrl;
1945
+ constructor(url) {
1946
+ this.baseUrl = url.replace(/\/$/, "");
1457
1947
  }
1458
- /**
1459
- * Get a database service by compose name, or the first one if no name given.
1460
- */
1461
- getDatabase(serviceName) {
1462
- for (const handle of [...this.services, ...this.composeHandles]) {
1463
- if (serviceName && handle.composeName !== serviceName) continue;
1464
- const adapter = handle.createDatabaseAdapter();
1465
- if (adapter) return adapter;
1466
- }
1467
- return null;
1948
+ async request(method, path, body, headers) {
1949
+ const init = {
1950
+ method,
1951
+ headers: {
1952
+ "Content-Type": "application/json",
1953
+ ...headers
1954
+ }
1955
+ };
1956
+ if (body !== void 0) init.body = JSON.stringify(body);
1957
+ const response = await fetch(`${this.baseUrl}${path}`, init);
1958
+ const responseBody = await response.json().catch(() => null);
1959
+ const responseHeaders = {};
1960
+ response.headers.forEach((value, key) => {
1961
+ responseHeaders[key] = value;
1962
+ });
1963
+ return {
1964
+ status: response.status,
1965
+ body: responseBody,
1966
+ headers: responseHeaders
1967
+ };
1468
1968
  }
1469
- /**
1470
- * Get all database services keyed by compose name.
1471
- */
1472
- getDatabases() {
1473
- const map = /* @__PURE__ */ new Map();
1474
- for (const handle of [...this.services, ...this.composeHandles]) {
1475
- const adapter = handle.createDatabaseAdapter();
1476
- if (adapter && handle.composeName) map.set(handle.composeName, adapter);
1477
- }
1478
- return map;
1969
+ };
1970
+ //#endregion
1971
+ //#region src/spec/modes/http/adapters/hono.adapter.ts
1972
+ /**
1973
+ * Server adapter that dispatches requests in-process through a Hono app instance.
1974
+ * Used by the `integration()` specification runner -- no network overhead.
1975
+ */
1976
+ var HonoAdapter = class {
1977
+ app;
1978
+ constructor(app) {
1979
+ this.app = app;
1479
1980
  }
1480
- /**
1481
- * Get app URL from compose (e2e mode).
1482
- */
1483
- getAppUrl() {
1484
- const composePath = findComposeFile(this.root);
1485
- if (!composePath || !this.composeStack) return null;
1486
- const appService = parseComposeFile(composePath).appService;
1487
- if (!appService || appService.ports.length === 0) return null;
1488
- return `http://localhost:${this.composeStack.getMappedPort(appService.name, appService.ports[0].container)}`;
1981
+ async request(method, path, body, headers) {
1982
+ const init = {
1983
+ method,
1984
+ headers: {
1985
+ "Content-Type": "application/json",
1986
+ ...headers
1987
+ }
1988
+ };
1989
+ if (body !== void 0) init.body = JSON.stringify(body);
1990
+ const response = await this.app.request(path, init);
1991
+ const responseBody = await response.json().catch(() => null);
1992
+ const responseHeaders = {};
1993
+ response.headers.forEach((value, key) => {
1994
+ responseHeaders[key] = value;
1995
+ });
1996
+ return {
1997
+ status: response.status,
1998
+ body: responseBody,
1999
+ headers: responseHeaders
2000
+ };
1489
2001
  }
1490
2002
  };
1491
2003
  //#endregion
@@ -1636,7 +2148,10 @@ async function startCommand(target, options) {
1636
2148
  command: new ExecAdapter(bin),
1637
2149
  database,
1638
2150
  databases,
1639
- fixturesRoot: root
2151
+ dockerConfig: options.docker,
2152
+ fixturesRoot: root,
2153
+ seedHandlers: options.seedHandlers,
2154
+ transform: options.transform
1640
2155
  });
1641
2156
  runner.cleanup = async () => {
1642
2157
  await releaseIsolation(services);
@@ -1685,10 +2200,28 @@ function stack(root) {
1685
2200
  /**
1686
2201
  * Test a CLI binary. Each spec runs in a fresh temp directory.
1687
2202
  *
2203
+ * Pass a `docker: { envVar, nameLabel, testRunLabel }` option on
2204
+ * {@link SpecOptions} to make the runner Docker-aware — the runner
2205
+ * stamps a unique test-run id into `envVar`, and results expose
2206
+ * `.container(name)` accessors that lazily query Docker. Tests that
2207
+ * never call `.container(...)` pay zero Docker cost.
2208
+ *
1688
2209
  * @param bin - Path to the CLI binary or command name (resolved from node_modules/.bin or PATH).
1689
2210
  *
1690
2211
  * @example
2212
+ * // CLI-only
1691
2213
  * await spec(command('my-cli'), { root: '../fixtures' });
2214
+ *
2215
+ * // CLI binary that also spawns containers — same runner, just
2216
+ * // opt into container accessors via the docker option:
2217
+ * await spec(command('my-cli'), {
2218
+ * root: '../fixtures',
2219
+ * docker: {
2220
+ * envVar: 'MYCLI_TEST_LABEL',
2221
+ * nameLabel: 'com.mycli.world.name',
2222
+ * testRunLabel: 'com.mycli.test.run',
2223
+ * },
2224
+ * });
1692
2225
  */
1693
2226
  function command(bin) {
1694
2227
  return {
@@ -1697,118 +2230,35 @@ function command(bin) {
1697
2230
  };
1698
2231
  }
1699
2232
  //#endregion
1700
- //#region src/spec/legacy-cli.ts
1701
- /**
1702
- * Create a CLI specification runner.
1703
- * Runs CLI commands against fixture projects. Optionally starts infrastructure.
1704
- */
1705
- async function cli(options) {
1706
- const root = resolveProjectRoot(options.root);
1707
- const command = resolveCommand(options.command, root);
1708
- let orchestrator = null;
1709
- let database;
1710
- let databases;
1711
- if (options.services?.length) {
1712
- orchestrator = new Orchestrator({
1713
- mode: "integration",
1714
- root,
1715
- services: options.services
1716
- });
1717
- await orchestrator.start();
1718
- database = orchestrator.getDatabase() ?? void 0;
1719
- const dbMap = orchestrator.getDatabases();
1720
- databases = dbMap.size > 0 ? dbMap : void 0;
1721
- }
1722
- const runner = createSpecificationRunner({
1723
- command: new ExecAdapter(command),
1724
- database,
1725
- databases,
1726
- fixturesRoot: root
1727
- });
1728
- runner.cleanup = async () => {
1729
- if (orchestrator) await orchestrator.stop();
1730
- };
1731
- runner.orchestrator = orchestrator;
1732
- return runner;
1733
- }
1734
- //#endregion
1735
- //#region src/spec/legacy-e2e.ts
1736
- /**
1737
- * Create an E2E specification runner.
1738
- * Starts full docker compose stack. App URL and database auto-detected.
1739
- */
1740
- async function e2e(options = {}) {
1741
- const orchestrator = new Orchestrator({
1742
- mode: "e2e",
1743
- root: resolveProjectRoot(options.root),
1744
- services: []
1745
- });
1746
- await orchestrator.startCompose();
1747
- const appUrl = orchestrator.getAppUrl();
1748
- if (!appUrl) throw new Error("E2E: could not detect app URL from compose. Ensure an app service with ports is defined.");
1749
- const database = orchestrator.getDatabase() ?? void 0;
1750
- const databases = orchestrator.getDatabases();
1751
- const runner = createSpecificationRunner({
1752
- database,
1753
- databases: databases.size > 0 ? databases : void 0,
1754
- server: new FetchAdapter(appUrl)
1755
- });
1756
- runner.cleanup = () => orchestrator.stopCompose();
1757
- runner.orchestrator = orchestrator;
1758
- return runner;
1759
- }
1760
- //#endregion
1761
- //#region src/spec/legacy-integration.ts
1762
- /**
1763
- * Create an integration specification runner.
1764
- * Starts infra containers via testcontainers, app runs in-process.
1765
- */
1766
- async function integration(options) {
1767
- const orchestrator = new Orchestrator({
1768
- mode: "integration",
1769
- root: resolveProjectRoot(options.root),
1770
- services: options.services
1771
- });
1772
- await orchestrator.start();
1773
- const app = options.app();
1774
- const database = orchestrator.getDatabase() ?? void 0;
1775
- const databases = orchestrator.getDatabases();
1776
- const runner = createSpecificationRunner({
1777
- database,
1778
- databases: databases.size > 0 ? databases : void 0,
1779
- server: new HonoAdapter(app)
1780
- });
1781
- runner.cleanup = () => orchestrator.stop();
1782
- runner.orchestrator = orchestrator;
1783
- return runner;
1784
- }
1785
- //#endregion
2233
+ exports.BaseResult = BaseResult;
2234
+ exports.CliResult = CliResult;
2235
+ exports.ContainerAccessor = ContainerAccessor;
1786
2236
  exports.DirectoryAccessor = DirectoryAccessor;
1787
2237
  exports.DockerAssertion = DockerAssertion;
1788
2238
  exports.ExecAdapter = ExecAdapter;
1789
2239
  exports.FetchAdapter = FetchAdapter;
2240
+ exports.FilesystemAccessor = FilesystemAccessor;
1790
2241
  exports.HonoAdapter = HonoAdapter;
2242
+ exports.HttpResult = HttpResult;
2243
+ exports.JsonAccessor = JsonAccessor;
1791
2244
  exports.Orchestrator = Orchestrator;
1792
2245
  exports.ResponseAccessor = ResponseAccessor;
1793
2246
  exports.SpecificationBuilder = SpecificationBuilder;
1794
- exports.SpecificationResult = SpecificationResult;
2247
+ exports.StreamAccessor = StreamAccessor;
1795
2248
  exports.TableAssertion = TableAssertion;
1796
2249
  exports.app = app;
1797
- exports.cli = cli;
1798
2250
  exports.command = command;
1799
2251
  exports.createSpecificationRunner = createSpecificationRunner;
1800
2252
  exports.dockerContainer = dockerContainer;
1801
- exports.e2e = e2e;
1802
- exports.grep = grep;
1803
- exports.integration = integration;
2253
+ exports.findContainersByLabel = findContainersByLabel;
2254
+ exports.inspectContainer = inspectContainer;
1804
2255
  exports.mockOf = require_mock_of.mockOf;
1805
2256
  exports.mockOfDate = require_mock_of.mockOfDate;
1806
- exports.normalizeOutput = normalizeOutput;
1807
- exports.postgres = require_sqlite_adapter.postgres;
1808
- exports.redis = require_sqlite_adapter.redis;
2257
+ exports.postgres = require_sqlite.postgres;
2258
+ exports.redis = require_sqlite.redis;
2259
+ exports.removeContainers = removeContainers;
1809
2260
  exports.spec = spec;
1810
- exports.sqlite = require_sqlite_adapter.sqlite;
2261
+ exports.sqlite = require_sqlite.sqlite;
1811
2262
  exports.stack = stack;
1812
- exports.stripAnsi = stripAnsi;
1813
2263
 
1814
2264
  //# sourceMappingURL=index.cjs.map