@jterrazz/test 7.0.0 → 8.0.0

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