@jterrazz/test 7.1.0 → 8.0.0

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