@jterrazz/test 7.0.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,1122 +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 filePath = (0, node_path.resolve)(this.testDir, "intercepts", response);
688
- const data = JSON.parse((0, node_fs.readFileSync)(filePath, "utf8"));
689
- const resolved = trigger.wrap ? trigger.wrap(data) : {
690
- status: 200,
691
- body: data
692
- };
693
- this.intercepts.push({
694
- trigger,
695
- 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
696
574
  });
697
- } else this.intercepts.push({
698
- trigger,
699
- response
700
- });
701
- 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);
702
631
  }
703
632
  /**
704
- * Send a GET request to the server adapter.
705
- *
706
- * @example
707
- * spec("list items").get("/api/items").run();
633
+ * Stop testcontainers (integration mode).
708
634
  */
709
- get(path) {
710
- this.request = {
711
- method: "GET",
712
- path
713
- };
714
- return this;
635
+ async stop() {
636
+ for (const { container } of this.running) if (container) await container.stop();
637
+ this.running = [];
638
+ this.started = false;
715
639
  }
716
640
  /**
717
- * Send a POST request to the server adapter.
718
- *
719
- * @param bodyFile - Optional JSON file from `requests/` to use as the request body.
720
- * @example
721
- * 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.
722
643
  */
723
- post(path, bodyFile) {
724
- this.request = {
725
- bodyFile,
726
- method: "POST",
727
- path
728
- };
729
- return this;
730
- }
731
- /** Send a PUT request to the server adapter. */
732
- put(path, bodyFile) {
733
- this.request = {
734
- bodyFile,
735
- method: "PUT",
736
- path
737
- };
738
- return this;
739
- }
740
- /** Send a DELETE request to the server adapter. */
741
- delete(path) {
742
- this.request = {
743
- method: "DELETE",
744
- path
745
- };
746
- 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);
747
683
  }
748
684
  /**
749
- * Execute a CLI command (or a sequence of commands) in an isolated working directory.
750
- * When an array is passed, commands run sequentially and stop on the first non-zero exit code.
751
- *
752
- * @example
753
- * spec("init project").exec("init --name demo").run();
754
- * spec("multi-step").exec(["init", "build"]).run();
685
+ * Stop docker compose stack (e2e mode).
755
686
  */
756
- exec(args) {
757
- this.commandArgs = args;
758
- return this;
687
+ async stopCompose() {
688
+ if (this.composeStack) {
689
+ await this.composeStack.stop();
690
+ this.composeStack = null;
691
+ }
692
+ this.composeHandles = [];
759
693
  }
760
- /** Spawn a long-running CLI process with custom spawn options (e.g. timeout, signal). */
761
- spawn(args, options) {
762
- this.spawnConfig = {
763
- args,
764
- options
765
- };
766
- return this;
694
+ /**
695
+ * Get a database service by compose name, or the first one if no name given.
696
+ */
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;
767
704
  }
768
705
  /**
769
- * Execute a named job registered via the app() factory.
770
- *
771
- * @param name - The job name to trigger (must match a registered JobHandle.name).
772
- *
773
- * @example
774
- * spec('pipeline').intercept(openai.chat(), openai.response({...})).job('report-refresh').run();
706
+ * Get all database services keyed by compose name.
775
707
  */
776
- job(name) {
777
- this.jobName = name;
778
- return this;
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);
713
+ }
714
+ return map;
779
715
  }
780
716
  /**
781
- * Execute the specification: run seeds, copy fixtures, then perform the
782
- * configured action (HTTP or CLI).
783
- *
784
- * @returns The result object used for assertions.
785
- * @example
786
- * const result = await spec("test").exec("status").run();
787
- * expect(result.exitCode).toBe(0);
717
+ * Get app URL from compose (e2e mode).
788
718
  */
789
- async run() {
790
- const hasHttpAction = this.request !== null;
791
- const hasCliAction = this.commandArgs !== null || this.spawnConfig !== null;
792
- const hasJobAction = this.jobName !== null;
793
- const actionCount = [
794
- hasHttpAction,
795
- hasCliAction,
796
- hasJobAction
797
- ].filter(Boolean).length;
798
- if (actionCount === 0) throw new Error(`Specification "${this.label}": no action defined. Call .get(), .post(), .exec(), .job(), etc. before .run()`);
799
- if (actionCount > 1) throw new Error(`Specification "${this.label}": cannot mix action types (.get/.post, .exec/.spawn, .job)`);
800
- let workDir = null;
801
- if (hasCliAction) workDir = this.prepareWorkDir();
802
- if (this.config.databases) for (const db of this.config.databases.values()) await db.reset();
803
- else if (this.config.database) await this.config.database.reset();
804
- for (const entry of this.seeds) {
805
- let db;
806
- if (entry.service && this.config.databases) {
807
- db = this.config.databases.get(entry.service);
808
- if (!db) throw new Error(`seed() targets database "${entry.service}" but it was not found. Available: ${[...this.config.databases.keys()].join(", ")}`);
809
- } else db = this.config.database;
810
- if (!db) throw new Error("seed() requires a database adapter");
811
- const sql = (0, node_fs.readFileSync)((0, node_path.resolve)(this.testDir, "seeds", entry.file), "utf8");
812
- await db.seed(sql);
813
- }
814
- 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 });
815
- let cleanupIntercepts = null;
816
- if (this.intercepts.length > 0) {
817
- const { registerIntercepts } = await Promise.resolve().then(() => require("./intercept2.cjs"));
818
- cleanupIntercepts = await registerIntercepts(this.intercepts);
819
- }
820
- try {
821
- if (hasHttpAction) return await this.runHttpAction();
822
- if (hasJobAction) return await this.runJobAction();
823
- return await this.runCliAction(workDir);
824
- } finally {
825
- if (cleanupIntercepts) cleanupIntercepts();
826
- }
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)}`;
827
725
  }
828
- resolveEnv(workDir) {
829
- const keys = Object.keys(this.commandEnv);
830
- if (keys.length === 0) return;
831
- const resolved = {};
832
- for (const key of keys) {
833
- const value = this.commandEnv[key];
834
- resolved[key] = typeof value === "string" ? value.replace(/\$WORKDIR/g, workDir) : value;
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;
835
750
  }
836
- return resolved;
837
- }
838
- prepareWorkDir() {
839
- const tempDir = (0, node_fs.mkdtempSync)((0, node_path.resolve)((0, node_os.tmpdir)(), "spec-cli-"));
840
- if (this.projectName && this.config.fixturesRoot) {
841
- const projectDir = (0, node_path.resolve)(this.config.fixturesRoot, this.projectName);
842
- if (!(0, node_fs.existsSync)(projectDir)) throw new Error(`project("${this.projectName}"): fixture project not found at ${projectDir}`);
843
- (0, node_fs.cpSync)(projectDir, tempDir, { recursive: true });
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("/"));
844
757
  }
845
- return tempDir;
846
758
  }
847
- async runHttpAction() {
848
- if (!this.config.server) throw new Error("HTTP actions require a server adapter (use integration() or e2e())");
849
- let body;
850
- if (this.request.bodyFile) body = JSON.parse((0, node_fs.readFileSync)((0, node_path.resolve)(this.testDir, "requests", this.request.bodyFile), "utf8"));
851
- const headers = Object.keys(this.requestHeaders).length > 0 ? this.requestHeaders : void 0;
852
- const response = await this.config.server.request(this.request.method, this.request.path, body, headers);
853
- return new SpecificationResult({
854
- config: this.config,
855
- requestInfo: {
856
- body,
857
- method: this.request.method,
858
- path: this.request.path
859
- },
860
- response,
861
- 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
862
782
  });
863
783
  }
864
- async runJobAction() {
865
- if (!this.config.jobs?.length) throw new Error("Job actions require jobs registered via app(() => ({ server, jobs }))");
866
- const job = this.config.jobs.find((j) => j.name === this.jobName);
867
- if (!job) {
868
- const available = this.config.jobs.map((j) => j.name).join(", ");
869
- 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;
870
813
  }
871
- await job.execute();
872
- return new SpecificationResult({
873
- config: this.config,
874
- testDir: this.testDir
875
- });
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."));
876
818
  }
877
- async runCliAction(workDir) {
878
- if (!this.config.command) throw new Error("CLI actions require a command adapter (use cli())");
879
- const env = this.resolveEnv(workDir);
880
- let commandResult;
881
- if (this.spawnConfig) commandResult = await this.config.command.spawn(this.spawnConfig.args, workDir, this.spawnConfig.options, env);
882
- else if (Array.isArray(this.commandArgs)) {
883
- commandResult = {
884
- exitCode: 0,
885
- stderr: "",
886
- stdout: ""
887
- };
888
- for (const args of this.commandArgs) {
889
- commandResult = await this.config.command.exec(args, workDir, env);
890
- if (commandResult.exitCode !== 0) break;
891
- }
892
- } else commandResult = await this.config.command.exec(this.commandArgs, workDir, env);
893
- return new SpecificationResult({
894
- commandResult,
895
- config: this.config,
896
- testDir: this.testDir,
897
- workDir
898
- });
819
+ async files(options = {}) {
820
+ return walkDirectory(this.absPath, options);
899
821
  }
900
822
  };
901
- function getCallerDir() {
902
- const stack = (/* @__PURE__ */ new Error("caller detection")).stack;
903
- if (!stack) throw new Error("Cannot detect caller directory: no stack trace");
904
- const lines = stack.split("\n");
905
- for (const line of lines) {
906
- const match = line.match(/at\s+(?:.*?\()?(?:file:\/\/)?([^:)]+):\d+:\d+/);
907
- if (!match) continue;
908
- const filePath = match[1];
909
- if (filePath.includes("node_modules")) continue;
910
- if (filePath.includes("/src/builder/") || filePath.includes("/src/spec/")) continue;
911
- return (0, node_path.resolve)(filePath, "..");
912
- }
913
- 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;
914
830
  }
915
831
  /**
916
- * Create a {@link SpecificationRunner} bound to the given adapter configuration.
917
- * 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>/`.
918
836
  */
919
- function createSpecificationRunner(config) {
920
- return (label) => {
921
- return new SpecificationBuilder(config, getCallerDir(), label);
922
- };
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");
923
883
  }
924
884
  //#endregion
925
- //#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
+ }
926
895
  /**
927
- * Implements {@link DockerContainerPort} by shelling out to the Docker CLI.
928
- * 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.
929
905
  */
930
- var DockerAdapter = class {
931
- containerId;
932
- constructor(containerId) {
933
- 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;
934
914
  }
935
- async exec(cmd) {
936
- return (0, node_child_process.execSync)(`docker exec ${this.containerId} ${cmd.map((c) => `'${c}'`).join(" ")}`, {
937
- encoding: "utf8",
938
- timeout: 1e4
939
- }).trim();
915
+ /** The parsed JSON value. Throws if the text is not valid JSON. */
916
+ get value() {
917
+ return this.parse();
940
918
  }
941
- async file(path) {
942
- try {
943
- return {
944
- exists: true,
945
- content: await this.exec(["cat", path])
946
- };
947
- } catch {
948
- return {
949
- exists: false,
950
- content: ""
951
- };
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;
952
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));
953
938
  }
954
- 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;
955
953
  try {
956
- return (0, node_child_process.execSync)(`docker inspect --format='{{.State.Running}}' ${this.containerId}`, {
957
- encoding: "utf8",
958
- timeout: 5e3
959
- }).trim() === "true";
954
+ return JSON.parse(source);
960
955
  } catch {
961
- return false;
956
+ const preview = source.slice(0, 200);
957
+ throw new Error(`stdout is not valid JSON: ${preview}`);
962
958
  }
963
959
  }
964
- async logs(tail) {
965
- return (0, node_child_process.execSync)(`docker logs ${tail ? `--tail ${tail}` : ""} ${this.containerId}`, {
966
- encoding: "utf8",
967
- timeout: 1e4
968
- });
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;
969
970
  }
970
- async inspect() {
971
- const raw = (0, node_child_process.execSync)(`docker inspect ${this.containerId}`, {
972
- encoding: "utf8",
973
- timeout: 5e3
974
- });
975
- const data = JSON.parse(raw)[0];
976
- return {
977
- id: data.Id,
978
- name: data.Name,
979
- state: {
980
- running: data.State.Running,
981
- exitCode: data.State.ExitCode,
982
- status: data.State.Status
983
- },
984
- config: {
985
- image: data.Config.Image,
986
- env: data.Config.Env || []
987
- },
988
- hostConfig: {
989
- memory: data.HostConfig.Memory || 0,
990
- cpuQuota: data.HostConfig.CpuQuota || 0,
991
- networkMode: data.HostConfig.NetworkMode || "",
992
- mounts: (data.Mounts || []).map((m) => ({
993
- source: m.Source,
994
- destination: m.Destination,
995
- type: m.Type
996
- }))
997
- },
998
- networkSettings: { networks: Object.fromEntries(Object.entries(data.NetworkSettings.Networks || {}).map(([name, net]) => [name, {
999
- gateway: net.Gateway,
1000
- ipAddress: net.IPAddress
1001
- }])) }
1002
- };
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));
1003
983
  }
1004
- async exists(path) {
1005
- try {
1006
- await this.exec([
1007
- "test",
1008
- "-e",
1009
- path
1010
- ]);
1011
- return true;
1012
- } catch {
1013
- return false;
1014
- }
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`);
1015
988
  }
1016
989
  };
1017
- /** Create a {@link DockerContainerPort} bound to an existing container by ID. */
1018
- function dockerContainer(containerId) {
1019
- return new DockerAdapter(containerId);
1020
- }
1021
990
  //#endregion
1022
- //#region src/docker/docker-assertion.ts
991
+ //#region src/spec/result/result.ts
1023
992
  /**
1024
- * Fluent assertion builder for Docker containers.
1025
- * 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.
1026
995
  */
1027
- var DockerAssertion = class {
1028
- container;
1029
- constructor(container) {
1030
- 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;
1031
1004
  }
1032
- /** Assert the container is running */
1033
- async toBeRunning() {
1034
- if (!await this.container.isRunning()) throw new Error("Expected container to be running");
1035
- 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);
1036
1008
  }
1037
- /** Assert the container is NOT running / doesn't exist */
1038
- async toNotExist() {
1039
- if (await this.container.isRunning()) throw new Error("Expected container to not exist or not be running");
1040
- 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
+ };
1041
1020
  }
1042
- /** Assert a file exists inside the container */
1043
- async toHaveFile(path, opts) {
1044
- const file = await this.container.file(path);
1045
- if (!file.exists) throw new Error(`Expected file ${path} to exist in container`);
1046
- if (opts?.containing && !file.content.includes(opts.containing)) throw new Error(`Expected file ${path} to contain "${opts.containing}", got: ${file.content.slice(0, 200)}`);
1047
- 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);
1048
1026
  }
1049
- /** Assert a file does NOT exist */
1050
- async toNotHaveFile(path) {
1051
- if (await this.container.exists(path)) throw new Error(`Expected ${path} to not exist in container`);
1052
- return this;
1027
+ resolveDatabase(serviceName) {
1028
+ if (serviceName && this.config.databases) return this.config.databases.get(serviceName);
1029
+ return this.config.database;
1053
1030
  }
1054
- /** Assert a directory exists */
1055
- async toHaveDirectory(path) {
1056
- if (!await this.container.exists(path)) throw new Error(`Expected directory ${path} to exist in container`);
1057
- 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;
1058
1058
  }
1059
- /** Assert a mount exists */
1060
- async toHaveMount(destination) {
1061
- const info = await this.container.inspect();
1062
- if (!info.hostConfig.mounts.find((m) => m.destination === destination)) {
1063
- const available = info.hostConfig.mounts.map((m) => m.destination).join(", ");
1064
- 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;
1065
1078
  }
1066
- return this;
1067
- }
1068
- /** Assert network mode */
1069
- async toHaveNetwork(mode) {
1070
- const info = await this.container.inspect();
1071
- if (!info.hostConfig.networkMode.includes(mode)) throw new Error(`Expected network mode "${mode}", got "${info.hostConfig.networkMode}"`);
1072
- 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));
1073
1082
  }
1074
- /** Assert memory limit */
1075
- async toHaveMemoryLimit(bytes) {
1076
- const info = await this.container.inspect();
1077
- if (info.hostConfig.memory !== bytes) throw new Error(`Expected memory limit ${bytes}, got ${info.hostConfig.memory}`);
1078
- return this;
1079
- }
1080
- /** Assert CPU quota */
1081
- async toHaveCpuQuota(quota) {
1082
- const info = await this.container.inspect();
1083
- if (info.hostConfig.cpuQuota !== quota) throw new Error(`Expected CPU quota ${quota}, got ${info.hostConfig.cpuQuota}`);
1084
- 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);
1085
1094
  }
1086
- /** Execute a command and return output for custom assertions */
1087
- async exec(cmd) {
1088
- 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)}`);
1089
1108
  }
1090
- /** Read a file for custom assertions */
1091
- async readFile(path) {
1092
- const file = await this.container.file(path);
1093
- if (!file.exists) throw new Error(`File ${path} does not exist`);
1094
- return file.content;
1109
+ toString() {
1110
+ return this.text;
1095
1111
  }
1096
- /** Get logs for custom assertions */
1097
- async getLogs(tail) {
1098
- return this.container.logs(tail);
1112
+ valueOf() {
1113
+ return this.text;
1099
1114
  }
1100
1115
  };
1101
1116
  //#endregion
1102
- //#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
+ }
1103
1130
  /**
1104
- * Start the full compose stack and stop it all on cleanup.
1105
- * 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`.
1106
1139
  */
1107
- var ComposeStackAdapter = class {
1108
- composeFile;
1109
- projectName;
1110
- started = false;
1111
- constructor(composeFile, projectName) {
1112
- this.composeFile = composeFile;
1113
- this.projectName = projectName ?? null;
1114
- }
1115
- get projectFlag() {
1116
- return this.projectName ? `-p ${this.projectName}` : "";
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;
1117
1157
  }
1118
- run(command) {
1119
- try {
1120
- return (0, node_child_process.execSync)(command, {
1121
- cwd: (0, node_path.dirname)(this.composeFile),
1122
- encoding: "utf8",
1123
- timeout: 12e4
1124
- }).trim();
1125
- } catch (error) {
1126
- const stderr = error.stderr?.toString().trim() ?? error.message;
1127
- throw new Error(`docker compose failed: ${stderr}`, { cause: error });
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";
1128
1171
  }
1129
1172
  }
1130
- async start() {
1131
- if (this.started) return;
1132
- this.run(`docker compose ${this.projectFlag} -f ${this.composeFile} up -d --wait`);
1133
- 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);
1134
1180
  }
1135
- async stop() {
1136
- if (!this.started) return;
1137
- this.run(`docker compose ${this.projectFlag} -f ${this.composeFile} down -v`);
1138
- 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);
1139
1185
  }
1140
- getMappedPort(serviceName, containerPort) {
1141
- const port = this.run(`docker compose ${this.projectFlag} -f ${this.composeFile} port ${serviceName} ${containerPort}`).split(":").pop();
1142
- 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);
1143
1190
  }
1144
- getHost() {
1145
- 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
+ };
1146
1214
  }
1147
- };
1148
- //#endregion
1149
- //#region src/infra/adapters/testcontainers.adapter.ts
1150
- /**
1151
- * Container adapter using testcontainers.
1152
- * Wraps a GenericContainer for programmatic container lifecycle.
1153
- */
1154
- var TestcontainersAdapter = class {
1155
- image;
1156
- containerPort;
1157
- env;
1158
- reuse;
1159
- container = null;
1160
- constructor(options) {
1161
- this.image = options.image;
1162
- this.containerPort = options.port;
1163
- this.env = options.env ?? {};
1164
- 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
+ });
1165
1232
  }
1166
- async start() {
1167
- const { GenericContainer, Wait } = await import("testcontainers");
1168
- let builder = new GenericContainer(this.image).withExposedPorts(this.containerPort);
1169
- for (const [key, value] of Object.entries(this.env)) builder = builder.withEnvironment({ [key]: value });
1170
- if (this.image.startsWith("postgres")) builder = builder.withWaitStrategy(Wait.forLogMessage(/database system is ready to accept connections/, 2));
1171
- if (this.reuse) builder = builder.withReuse();
1172
- this.container = await builder.start();
1233
+ requireExists(member) {
1234
+ if (!this.exists) throw new Error(`ContainerAccessor.${member}: container does not exist (check .exists first)`);
1173
1235
  }
1174
- async stop() {
1175
- if (this.container && !this.reuse) {
1176
- await this.container.stop();
1177
- 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 ?? "");
1178
1250
  }
1251
+ return this.cachedLogs ?? "";
1179
1252
  }
1180
- getMappedPort(containerPort) {
1181
- if (!this.container) throw new Error("Container not started");
1182
- return this.container.getMappedPort(containerPort);
1183
- }
1184
- getHost() {
1185
- if (!this.container) throw new Error("Container not started");
1186
- return this.container.getHost();
1187
- }
1188
- getConnectionString() {
1189
- return `${this.getHost()}:${this.getMappedPort(this.containerPort)}`;
1190
- }
1191
- async getLogs() {
1192
- if (!this.container) return "";
1193
- const stream = await this.container.logs();
1194
- return new Promise((resolve) => {
1195
- let output = "";
1196
- stream.on("data", (chunk) => {
1197
- output += chunk.toString();
1198
- });
1199
- stream.on("end", () => {
1200
- resolve(output);
1201
- });
1202
- setTimeout(() => {
1203
- resolve(output);
1204
- }, 1e3);
1205
- });
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
+ }
1206
1275
  }
1207
1276
  };
1208
1277
  //#endregion
1209
- //#region src/infra/compose-parser.ts
1210
- /**
1211
- * Detect the service type from the image name.
1212
- */
1213
- function detectServiceType(image) {
1214
- if (!image) return "app";
1215
- const lower = image.toLowerCase();
1216
- if (lower.startsWith("postgres")) return "postgres";
1217
- if (lower.startsWith("redis")) return "redis";
1218
- return "unknown";
1219
- }
1278
+ //#region src/spec/modes/cli/docker-lookup.ts
1220
1279
  /**
1221
- * Find the compose file in the project.
1222
- * 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).
1223
1284
  */
1224
- function findComposeFile(projectRoot) {
1225
- const candidates = [
1226
- (0, node_path.resolve)(projectRoot, "docker/compose.test.yaml"),
1227
- (0, node_path.resolve)(projectRoot, "docker/compose.test.yml"),
1228
- (0, node_path.resolve)(projectRoot, "docker-compose.test.yaml"),
1229
- (0, node_path.resolve)(projectRoot, "docker-compose.test.yml")
1230
- ];
1231
- for (const candidate of candidates) if ((0, node_fs.existsSync)(candidate)) return candidate;
1232
- 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
+ }
1233
1296
  }
1234
- /**
1235
- * Parse a docker-compose file and extract service definitions.
1236
- */
1237
- function parseComposeFile(filePath) {
1238
- const doc = (0, yaml.parse)((0, node_fs.readFileSync)(filePath, "utf8"));
1239
- if (!doc?.services) return {
1240
- services: [],
1241
- appService: null,
1242
- infraServices: []
1243
- };
1244
- const services = Object.entries(doc.services).map(([name, def]) => {
1245
- const ports = [];
1246
- if (def.ports) for (const port of def.ports) {
1247
- const str = String(port);
1248
- if (str.includes(":")) {
1249
- const [host, container] = str.split(":");
1250
- ports.push({
1251
- container: Number(container),
1252
- host: Number(host)
1253
- });
1254
- } else ports.push({ container: Number(str) });
1255
- }
1256
- const environment = {};
1257
- if (def.environment) if (Array.isArray(def.environment)) for (const env of def.environment) {
1258
- const [key, ...rest] = String(env).split("=");
1259
- environment[key] = rest.join("=");
1260
- }
1261
- else Object.assign(environment, def.environment);
1262
- const volumes = def.volumes ? def.volumes.map((v) => String(v)) : [];
1263
- let dependsOn = [];
1264
- if (def.depends_on) dependsOn = Array.isArray(def.depends_on) ? def.depends_on : Object.keys(def.depends_on);
1265
- return {
1266
- name,
1267
- image: def.image,
1268
- build: def.build,
1269
- ports,
1270
- environment,
1271
- volumes,
1272
- dependsOn
1273
- };
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
1274
1302
  });
1275
- return {
1276
- services,
1277
- appService: services.find((s) => s.build !== void 0) ?? null,
1278
- infraServices: services.filter((s) => s.build === void 0)
1279
- };
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 {}
1280
1316
  }
1281
1317
  //#endregion
1282
- //#region src/infra/orchestrator.ts
1318
+ //#region src/spec/modes/cli/result.ts
1283
1319
  /**
1284
- * Orchestrator for test infrastructure.
1285
- * Integration: starts services via testcontainers.
1286
- * 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.
1287
1332
  */
1288
- var Orchestrator = class {
1289
- services;
1290
- mode;
1291
- root;
1292
- projectName;
1293
- running = [];
1294
- composeStack = null;
1295
- composeHandles = [];
1296
- started = false;
1333
+ var CliResult = class extends BaseResult {
1334
+ commandResult;
1335
+ containersCache = null;
1336
+ dockerConfig;
1337
+ testRunId;
1338
+ transform;
1297
1339
  constructor(options) {
1298
- this.services = options.services;
1299
- this.mode = options.mode;
1300
- this.root = options.root ?? process.cwd();
1301
- 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);
1302
1366
  }
1303
1367
  /**
1304
- * Start declared services via testcontainers (integration mode).
1305
- * Phase 1: start all containers in parallel (the slow part).
1306
- * 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.
1307
1376
  */
1308
- async start() {
1309
- if (this.started) return;
1310
- const composePath = findComposeFile(this.root);
1311
- const composeDir = composePath ? (0, node_path.dirname)(composePath) : this.root;
1312
- const composeConfig = composePath ? parseComposeFile(composePath) : null;
1313
- const containerServices = [];
1314
- const embeddedServices = [];
1315
- for (const handle of this.services) {
1316
- if (handle.defaultPort === 0) {
1317
- 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 {
1318
1415
  continue;
1319
1416
  }
1320
- let image = handle.defaultImage;
1321
- let env = { ...handle.environment };
1322
- if (handle.composeName && composeConfig) {
1323
- const composeService = composeConfig.services.find((s) => s.name === handle.composeName);
1324
- if (composeService) {
1325
- image = composeService.image ?? image;
1326
- env = {
1327
- ...env,
1328
- ...composeService.environment
1329
- };
1330
- Object.assign(handle.environment, composeService.environment);
1331
- }
1332
- }
1333
- const container = new TestcontainersAdapter({
1334
- image,
1335
- port: handle.defaultPort,
1336
- env
1337
- });
1338
- containerServices.push({
1339
- container,
1340
- 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
1341
1422
  });
1342
1423
  }
1343
- await Promise.all([...containerServices.map(({ container }) => container.start()), ...embeddedServices.map(async (handle) => {
1344
- await handle.initialize(composeDir);
1345
- handle.started = true;
1346
- this.running.push({
1347
- handle,
1348
- 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
1349
1582
  });
1350
- })]);
1351
- const reports = [];
1352
- for (const { container, handle } of containerServices) {
1353
- const serviceStartTime = Date.now();
1354
- try {
1355
- const host = container.getHost();
1356
- const port = container.getMappedPort(handle.defaultPort);
1357
- handle.connectionString = handle.buildConnectionString(host, port);
1358
- await handle.healthcheck();
1359
- await handle.initialize(composeDir);
1360
- handle.started = true;
1361
- reports.push({
1362
- name: handle.composeName ?? handle.type,
1363
- type: handle.type,
1364
- connectionString: handle.connectionString,
1365
- durationMs: Date.now() - serviceStartTime
1366
- });
1367
- this.running.push({
1368
- handle,
1369
- container
1370
- });
1371
- } catch (error) {
1372
- let logs = "";
1373
- try {
1374
- logs = await container.getLogs();
1375
- } catch {}
1376
- try {
1377
- await container.stop();
1378
- } catch {}
1379
- reports.push({
1380
- name: handle.composeName ?? handle.type,
1381
- type: handle.type,
1382
- durationMs: Date.now() - serviceStartTime,
1383
- error: error.message,
1384
- logs
1385
- });
1386
- const output = formatStartupReport("integration", reports, { type: "in-process" });
1387
- console.error(output);
1388
- throw error;
1389
- }
1390
- }
1391
- this.started = true;
1392
- const output = formatStartupReport("integration", reports, { type: "in-process" });
1393
- console.log(output);
1583
+ } else this.intercepts.push({
1584
+ trigger,
1585
+ response
1586
+ });
1587
+ return this;
1394
1588
  }
1395
1589
  /**
1396
- * Stop testcontainers (integration mode).
1590
+ * Send a GET request to the server adapter.
1591
+ *
1592
+ * @example
1593
+ * spec("list items").get("/api/items").run();
1397
1594
  */
1398
- async stop() {
1399
- for (const { container } of this.running) if (container) await container.stop();
1400
- this.running = [];
1401
- 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;
1402
1870
  }
1403
- /**
1404
- * Start full docker compose stack (e2e mode).
1405
- * Auto-detects infra services and creates handles for them.
1406
- */
1407
- async startCompose() {
1408
- const composePath = findComposeFile(this.root);
1409
- if (!composePath) throw new Error(`E2E: no compose file found in ${this.root}`);
1410
- const startTime = Date.now();
1411
- const composeDir = (0, node_path.dirname)(composePath);
1412
- const composeConfig = parseComposeFile(composePath);
1413
- this.composeStack = new ComposeStackAdapter(composePath, this.projectName);
1414
- await this.composeStack.start();
1415
- for (const service of composeConfig.infraServices) {
1416
- const type = detectServiceType(service.image);
1417
- if (type === "postgres") {
1418
- const handle = require_sqlite_adapter.postgres({
1419
- compose: service.name,
1420
- 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
1421
1913
  });
1422
- const port = this.composeStack.getMappedPort(service.name, 5432);
1423
- handle.connectionString = handle.buildConnectionString("localhost", port);
1424
- await handle.initialize(composeDir);
1425
- handle.started = true;
1426
- this.composeHandles.push(handle);
1427
- } else if (type === "redis") {
1428
- const handle = require_sqlite_adapter.redis({ compose: service.name });
1429
- const port = this.composeStack.getMappedPort(service.name, 6379);
1430
- handle.connectionString = handle.buildConnectionString("localhost", port);
1431
- handle.started = true;
1432
- this.composeHandles.push(handle);
1433
- }
1434
- }
1435
- const durationMs = Date.now() - startTime;
1436
- const output = formatStartupReport("e2e", this.composeHandles.map((h) => ({
1437
- name: h.composeName ?? h.type,
1438
- type: h.type,
1439
- connectionString: h.connectionString,
1440
- durationMs
1441
- })), {
1442
- type: "http",
1443
- 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);
1444
1934
  });
1445
- console.log(output);
1446
1935
  }
1447
- /**
1448
- * Stop docker compose stack (e2e mode).
1449
- */
1450
- async stopCompose() {
1451
- if (this.composeStack) {
1452
- await this.composeStack.stop();
1453
- this.composeStack = null;
1454
- }
1455
- 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(/\/$/, "");
1456
1947
  }
1457
- /**
1458
- * Get a database service by compose name, or the first one if no name given.
1459
- */
1460
- getDatabase(serviceName) {
1461
- for (const handle of [...this.services, ...this.composeHandles]) {
1462
- if (serviceName && handle.composeName !== serviceName) continue;
1463
- const adapter = handle.createDatabaseAdapter();
1464
- if (adapter) return adapter;
1465
- }
1466
- 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
+ };
1467
1968
  }
1468
- /**
1469
- * Get all database services keyed by compose name.
1470
- */
1471
- getDatabases() {
1472
- const map = /* @__PURE__ */ new Map();
1473
- for (const handle of [...this.services, ...this.composeHandles]) {
1474
- const adapter = handle.createDatabaseAdapter();
1475
- if (adapter && handle.composeName) map.set(handle.composeName, adapter);
1476
- }
1477
- 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;
1478
1980
  }
1479
- /**
1480
- * Get app URL from compose (e2e mode).
1481
- */
1482
- getAppUrl() {
1483
- const composePath = findComposeFile(this.root);
1484
- if (!composePath || !this.composeStack) return null;
1485
- const appService = parseComposeFile(composePath).appService;
1486
- if (!appService || appService.ports.length === 0) return null;
1487
- 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
+ };
1488
2001
  }
1489
2002
  };
1490
2003
  //#endregion
@@ -1635,7 +2148,10 @@ async function startCommand(target, options) {
1635
2148
  command: new ExecAdapter(bin),
1636
2149
  database,
1637
2150
  databases,
1638
- fixturesRoot: root
2151
+ dockerConfig: options.docker,
2152
+ fixturesRoot: root,
2153
+ seedHandlers: options.seedHandlers,
2154
+ transform: options.transform
1639
2155
  });
1640
2156
  runner.cleanup = async () => {
1641
2157
  await releaseIsolation(services);
@@ -1684,10 +2200,28 @@ function stack(root) {
1684
2200
  /**
1685
2201
  * Test a CLI binary. Each spec runs in a fresh temp directory.
1686
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
+ *
1687
2209
  * @param bin - Path to the CLI binary or command name (resolved from node_modules/.bin or PATH).
1688
2210
  *
1689
2211
  * @example
2212
+ * // CLI-only
1690
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
+ * });
1691
2225
  */
1692
2226
  function command(bin) {
1693
2227
  return {
@@ -1696,118 +2230,35 @@ function command(bin) {
1696
2230
  };
1697
2231
  }
1698
2232
  //#endregion
1699
- //#region src/spec/legacy-cli.ts
1700
- /**
1701
- * Create a CLI specification runner.
1702
- * Runs CLI commands against fixture projects. Optionally starts infrastructure.
1703
- */
1704
- async function cli(options) {
1705
- const root = resolveProjectRoot(options.root);
1706
- const command = resolveCommand(options.command, root);
1707
- let orchestrator = null;
1708
- let database;
1709
- let databases;
1710
- if (options.services?.length) {
1711
- orchestrator = new Orchestrator({
1712
- mode: "integration",
1713
- root,
1714
- services: options.services
1715
- });
1716
- await orchestrator.start();
1717
- database = orchestrator.getDatabase() ?? void 0;
1718
- const dbMap = orchestrator.getDatabases();
1719
- databases = dbMap.size > 0 ? dbMap : void 0;
1720
- }
1721
- const runner = createSpecificationRunner({
1722
- command: new ExecAdapter(command),
1723
- database,
1724
- databases,
1725
- fixturesRoot: root
1726
- });
1727
- runner.cleanup = async () => {
1728
- if (orchestrator) await orchestrator.stop();
1729
- };
1730
- runner.orchestrator = orchestrator;
1731
- return runner;
1732
- }
1733
- //#endregion
1734
- //#region src/spec/legacy-e2e.ts
1735
- /**
1736
- * Create an E2E specification runner.
1737
- * Starts full docker compose stack. App URL and database auto-detected.
1738
- */
1739
- async function e2e(options = {}) {
1740
- const orchestrator = new Orchestrator({
1741
- mode: "e2e",
1742
- root: resolveProjectRoot(options.root),
1743
- services: []
1744
- });
1745
- await orchestrator.startCompose();
1746
- const appUrl = orchestrator.getAppUrl();
1747
- if (!appUrl) throw new Error("E2E: could not detect app URL from compose. Ensure an app service with ports is defined.");
1748
- const database = orchestrator.getDatabase() ?? void 0;
1749
- const databases = orchestrator.getDatabases();
1750
- const runner = createSpecificationRunner({
1751
- database,
1752
- databases: databases.size > 0 ? databases : void 0,
1753
- server: new FetchAdapter(appUrl)
1754
- });
1755
- runner.cleanup = () => orchestrator.stopCompose();
1756
- runner.orchestrator = orchestrator;
1757
- return runner;
1758
- }
1759
- //#endregion
1760
- //#region src/spec/legacy-integration.ts
1761
- /**
1762
- * Create an integration specification runner.
1763
- * Starts infra containers via testcontainers, app runs in-process.
1764
- */
1765
- async function integration(options) {
1766
- const orchestrator = new Orchestrator({
1767
- mode: "integration",
1768
- root: resolveProjectRoot(options.root),
1769
- services: options.services
1770
- });
1771
- await orchestrator.start();
1772
- const app = options.app();
1773
- const database = orchestrator.getDatabase() ?? void 0;
1774
- const databases = orchestrator.getDatabases();
1775
- const runner = createSpecificationRunner({
1776
- database,
1777
- databases: databases.size > 0 ? databases : void 0,
1778
- server: new HonoAdapter(app)
1779
- });
1780
- runner.cleanup = () => orchestrator.stop();
1781
- runner.orchestrator = orchestrator;
1782
- return runner;
1783
- }
1784
- //#endregion
2233
+ exports.BaseResult = BaseResult;
2234
+ exports.CliResult = CliResult;
2235
+ exports.ContainerAccessor = ContainerAccessor;
1785
2236
  exports.DirectoryAccessor = DirectoryAccessor;
1786
2237
  exports.DockerAssertion = DockerAssertion;
1787
2238
  exports.ExecAdapter = ExecAdapter;
1788
2239
  exports.FetchAdapter = FetchAdapter;
2240
+ exports.FilesystemAccessor = FilesystemAccessor;
1789
2241
  exports.HonoAdapter = HonoAdapter;
2242
+ exports.HttpResult = HttpResult;
2243
+ exports.JsonAccessor = JsonAccessor;
1790
2244
  exports.Orchestrator = Orchestrator;
1791
2245
  exports.ResponseAccessor = ResponseAccessor;
1792
2246
  exports.SpecificationBuilder = SpecificationBuilder;
1793
- exports.SpecificationResult = SpecificationResult;
2247
+ exports.StreamAccessor = StreamAccessor;
1794
2248
  exports.TableAssertion = TableAssertion;
1795
2249
  exports.app = app;
1796
- exports.cli = cli;
1797
2250
  exports.command = command;
1798
2251
  exports.createSpecificationRunner = createSpecificationRunner;
1799
2252
  exports.dockerContainer = dockerContainer;
1800
- exports.e2e = e2e;
1801
- exports.grep = grep;
1802
- exports.integration = integration;
2253
+ exports.findContainersByLabel = findContainersByLabel;
2254
+ exports.inspectContainer = inspectContainer;
1803
2255
  exports.mockOf = require_mock_of.mockOf;
1804
2256
  exports.mockOfDate = require_mock_of.mockOfDate;
1805
- exports.normalizeOutput = normalizeOutput;
1806
- exports.postgres = require_sqlite_adapter.postgres;
1807
- exports.redis = require_sqlite_adapter.redis;
2257
+ exports.postgres = require_sqlite.postgres;
2258
+ exports.redis = require_sqlite.redis;
2259
+ exports.removeContainers = removeContainers;
1808
2260
  exports.spec = spec;
1809
- exports.sqlite = require_sqlite_adapter.sqlite;
2261
+ exports.sqlite = require_sqlite.sqlite;
1810
2262
  exports.stack = stack;
1811
- exports.stripAnsi = stripAnsi;
1812
2263
 
1813
2264
  //# sourceMappingURL=index.cjs.map