@jterrazz/test 7.1.0 → 9.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.
Files changed (48) hide show
  1. package/README.md +209 -230
  2. package/dist/checker.d.ts +1 -0
  3. package/dist/checker.js +741 -0
  4. package/dist/index.d.ts +1296 -529
  5. package/dist/index.js +3303 -1261
  6. package/dist/intercept.js +129 -290
  7. package/dist/match.js +153 -0
  8. package/dist/oxlint.cjs +2931 -0
  9. package/dist/oxlint.d.cts +150 -0
  10. package/dist/oxlint.d.ts +150 -0
  11. package/dist/oxlint.js +2925 -0
  12. package/package.json +47 -41
  13. package/dist/chunk.cjs +0 -28
  14. package/dist/index.cjs +0 -1814
  15. package/dist/index.cjs.map +0 -1
  16. package/dist/index.d.cts +0 -734
  17. package/dist/index.js.map +0 -1
  18. package/dist/intercept.cjs +0 -313
  19. package/dist/intercept.cjs.map +0 -1
  20. package/dist/intercept.d.cts +0 -105
  21. package/dist/intercept.d.ts +0 -105
  22. package/dist/intercept.js.map +0 -1
  23. package/dist/intercept2.cjs +0 -81
  24. package/dist/intercept2.cjs.map +0 -1
  25. package/dist/intercept2.js +0 -81
  26. package/dist/intercept2.js.map +0 -1
  27. package/dist/mock-of.cjs +0 -32
  28. package/dist/mock-of.cjs.map +0 -1
  29. package/dist/mock-of.d.cts +0 -27
  30. package/dist/mock-of.d.ts +0 -27
  31. package/dist/mock-of.js +0 -19
  32. package/dist/mock-of.js.map +0 -1
  33. package/dist/mock.cjs +0 -4
  34. package/dist/mock.d.cts +0 -2
  35. package/dist/mock.d.ts +0 -2
  36. package/dist/mock.js +0 -2
  37. package/dist/services.cjs +0 -5
  38. package/dist/services.d.cts +0 -2
  39. package/dist/services.d.ts +0 -2
  40. package/dist/services.js +0 -2
  41. package/dist/sqlite.adapter.cjs +0 -350
  42. package/dist/sqlite.adapter.cjs.map +0 -1
  43. package/dist/sqlite.adapter.d.cts +0 -209
  44. package/dist/sqlite.adapter.d.ts +0 -209
  45. package/dist/sqlite.adapter.js +0 -331
  46. package/dist/sqlite.adapter.js.map +0 -1
  47. package/dist/types.d.cts +0 -42
  48. package/dist/types.d.ts +0 -42
package/dist/index.js CHANGED
@@ -1,142 +1,393 @@
1
- import { n as redis, r as postgres, t as sqlite } from "./sqlite.adapter.js";
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";
6
- import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
7
- import { readdir } from "node:fs/promises";
1
+ import { i as match, n as Matcher, r as TOKEN_KINDS, t as CaptureScope } from "./match.js";
2
+ import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
8
4
  import { parse } from "yaml";
9
- //#region src/builder/cli/adapters/exec.adapter.ts
5
+ import { execSync, spawn, spawnSync } from "node:child_process";
6
+ import { Client } from "pg";
7
+ import { readdir } from "node:fs/promises";
8
+ import { fileURLToPath } from "node:url";
9
+ import { tmpdir } from "node:os";
10
+ import Database from "better-sqlite3";
11
+ import { mockDeep } from "vitest-mock-extended";
12
+ import MockDatePackage from "mockdate";
13
+ //#region src/core/specification/shared/registry.ts
14
+ let containerIntegrations = null;
15
+ const composeServiceFactories = /* @__PURE__ */ new Map();
16
+ /** Wire the container runtimes. Called by the package entry point. */
17
+ function registerContainerIntegrations(integrations) {
18
+ containerIntegrations = integrations;
19
+ }
20
+ /** Wire a service auto-detection factory (e.g. `'postgres'`, `'redis'`). */
21
+ function registerComposeServiceFactory(type, factory) {
22
+ composeServiceFactories.set(type, factory);
23
+ }
24
+ function getContainerIntegrations() {
25
+ if (!containerIntegrations) throw new Error("@jterrazz/test: container integrations are not registered — import from '@jterrazz/test' (the package entry point wires them).");
26
+ return containerIntegrations;
27
+ }
28
+ function getComposeServiceFactory(type) {
29
+ return composeServiceFactories.get(type);
30
+ }
31
+ //#endregion
32
+ //#region src/integrations/compose/compose-parser.ts
10
33
  /**
11
- * Build a child-process env from the parent env plus user overrides.
12
- * `null` overrides delete keys (e.g. `INIT_CWD: null`).
34
+ * Parse a docker-compose file and extract service definitions.
13
35
  */
14
- function buildEnv(extra) {
15
- const env = {
16
- ...process.env,
17
- INIT_CWD: void 0
36
+ function parseComposeFile(filePath) {
37
+ const doc = parse(readFileSync(filePath, "utf8"));
38
+ if (!doc?.services) return {
39
+ services: [],
40
+ appService: null,
41
+ infraServices: []
42
+ };
43
+ const services = Object.entries(doc.services).map(([name, def]) => {
44
+ const ports = [];
45
+ if (def.ports) for (const port of def.ports) {
46
+ const str = String(port);
47
+ if (str.includes(":")) {
48
+ const [host, container] = str.split(":");
49
+ ports.push({
50
+ container: Number(container),
51
+ host: Number(host)
52
+ });
53
+ } else ports.push({ container: Number(str) });
54
+ }
55
+ const environment = {};
56
+ if (def.environment) if (Array.isArray(def.environment)) for (const env of def.environment) {
57
+ const [key, ...rest] = String(env).split("=");
58
+ environment[key] = rest.join("=");
59
+ }
60
+ else Object.assign(environment, def.environment);
61
+ const volumes = def.volumes ? def.volumes.map((v) => String(v)) : [];
62
+ let dependsOn = [];
63
+ if (def.depends_on) dependsOn = Array.isArray(def.depends_on) ? def.depends_on : Object.keys(def.depends_on);
64
+ return {
65
+ name,
66
+ image: def.image,
67
+ build: def.build,
68
+ ports,
69
+ environment,
70
+ volumes,
71
+ dependsOn
72
+ };
73
+ });
74
+ return {
75
+ services,
76
+ appService: services.find((s) => s.build !== void 0) ?? null,
77
+ infraServices: services.filter((s) => s.build === void 0)
18
78
  };
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
79
  }
80
+ //#endregion
81
+ //#region src/integrations/compose/compose.ts
23
82
  /**
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.
83
+ * Start the full compose stack and stop it all on cleanup.
84
+ * Supports per-worker project names for parallel execution.
27
85
  */
28
- var ExecAdapter = class {
29
- command;
30
- constructor(command) {
31
- this.command = command;
86
+ var ComposeStackAdapter = class {
87
+ composeFile;
88
+ projectName;
89
+ started = false;
90
+ constructor(composeFile, projectName) {
91
+ this.composeFile = composeFile;
92
+ this.projectName = projectName ?? null;
32
93
  }
33
- async exec(args, cwd, extraEnv) {
34
- const env = buildEnv(extraEnv);
94
+ get projectFlag() {
95
+ return this.projectName ? `-p ${this.projectName}` : "";
96
+ }
97
+ run(command) {
35
98
  try {
36
- 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: ""
49
- };
99
+ return execSync(command, {
100
+ cwd: dirname(this.composeFile),
101
+ encoding: "utf8",
102
+ timeout: 12e4
103
+ }).trim();
50
104
  } catch (error) {
51
- return {
52
- exitCode: error.status ?? 1,
53
- stdout: error.stdout?.toString() ?? "",
54
- stderr: error.stderr?.toString() ?? ""
55
- };
105
+ const stderr = error.stderr?.toString().trim() ?? error.message;
106
+ throw new Error(`docker compose failed: ${stderr}`, { cause: error });
56
107
  }
57
108
  }
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
- });
109
+ async start() {
110
+ if (this.started) return;
111
+ this.run(`docker compose ${this.projectFlag} -f ${this.composeFile} up -d --wait --build`);
112
+ this.started = true;
113
+ }
114
+ async stop() {
115
+ if (!this.started) return;
116
+ this.run(`docker compose ${this.projectFlag} -f ${this.composeFile} down -v`);
117
+ this.started = false;
118
+ }
119
+ getMappedPort(serviceName, containerPort) {
120
+ const port = this.run(`docker compose ${this.projectFlag} -f ${this.composeFile} port ${serviceName} ${containerPort}`).split(":").pop();
121
+ return Number(port);
122
+ }
123
+ getHost() {
124
+ return "localhost";
103
125
  }
104
126
  };
105
127
  //#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
+ //#region src/integrations/postgres/postgres.ts
129
+ var PostgresHandle = class {
130
+ type = "postgres";
131
+ composeName;
132
+ defaultPort = 5432;
133
+ defaultImage;
134
+ environment;
135
+ connectionString = "";
136
+ started = false;
137
+ client = null;
138
+ originalConnectionString = "";
139
+ schema = "public";
140
+ constructor(options = {}) {
141
+ this.composeName = options.composeService ?? null;
142
+ this.defaultImage = options.image ?? "postgres:17";
143
+ this.environment = {
144
+ POSTGRES_DB: "test",
145
+ POSTGRES_PASSWORD: "test",
146
+ POSTGRES_USER: "test",
147
+ ...options.env
148
+ };
115
149
  }
116
- async request(method, path, body, headers) {
117
- const init = {
118
- method,
119
- headers: {
120
- "Content-Type": "application/json",
121
- ...headers
150
+ buildConnectionString(host, port) {
151
+ return `postgresql://${this.environment.POSTGRES_USER ?? "test"}:${this.environment.POSTGRES_PASSWORD ?? "test"}@${host}:${port}/${this.environment.POSTGRES_DB ?? "test"}`;
152
+ }
153
+ createDatabaseAdapter() {
154
+ return this;
155
+ }
156
+ async healthcheck() {
157
+ if (!this.connectionString) throw new Error("postgres: cannot healthcheck — no connection string");
158
+ try {
159
+ const client = new Client({ connectionString: this.connectionString });
160
+ await client.connect();
161
+ await client.query("SELECT 1");
162
+ await client.end();
163
+ } catch (error) {
164
+ throw new Error(`postgres healthcheck failed: ${error.message || error.code || String(error)}`, { cause: error });
165
+ }
166
+ }
167
+ async initialize(composeDir) {
168
+ if (!this.composeName) return;
169
+ const initPaths = [resolve(composeDir, `${this.composeName}/init.sql`), resolve(composeDir, "postgres/init.sql")];
170
+ for (const initPath of initPaths) if (existsSync(initPath)) {
171
+ const sql = readFileSync(initPath, "utf8");
172
+ try {
173
+ await this.seed(sql);
174
+ } catch (error) {
175
+ throw new Error(`postgres init script failed (${initPath}):\n${error.message}`, { cause: error });
176
+ }
177
+ return;
178
+ }
179
+ }
180
+ async getClient() {
181
+ if (this.client) return this.client;
182
+ const client = new Client({ connectionString: this.connectionString });
183
+ client.on("error", () => {
184
+ this.client = null;
185
+ });
186
+ await client.connect();
187
+ this.client = client;
188
+ return client;
189
+ }
190
+ async seed(sql) {
191
+ await (await this.getClient()).query(sql);
192
+ }
193
+ async reset() {
194
+ const client = await this.getClient();
195
+ const result = await client.query(`
196
+ SELECT tablename FROM pg_tables
197
+ WHERE schemaname = '${this.schema}'
198
+ AND tablename NOT LIKE '_prisma%'
199
+ `);
200
+ for (const row of result.rows) await client.query(`TRUNCATE "${this.schema}"."${row.tablename}" CASCADE`);
201
+ }
202
+ async query(table, columns) {
203
+ const client = await this.getClient();
204
+ const columnList = columns.join(", ");
205
+ return (await client.query(`SELECT ${columnList} FROM "${this.schema}"."${table}" ORDER BY 1`)).rows.map((row) => columns.map((col) => row[col]));
206
+ }
207
+ isolation() {
208
+ return {
209
+ acquire: async (workerId) => {
210
+ const workerSchema = `worker_${workerId}`;
211
+ this.originalConnectionString = this.connectionString;
212
+ const client = await this.getClient();
213
+ await client.query(`DROP SCHEMA IF EXISTS "${workerSchema}" CASCADE`);
214
+ await client.query(`CREATE SCHEMA "${workerSchema}"`);
215
+ const tables = await client.query(`
216
+ SELECT tablename FROM pg_tables
217
+ WHERE schemaname = 'public'
218
+ AND tablename NOT LIKE '_prisma%'
219
+ `);
220
+ for (const row of tables.rows) await client.query(`CREATE TABLE "${workerSchema}"."${row.tablename}" (LIKE "public"."${row.tablename}" INCLUDING ALL)`);
221
+ this.schema = workerSchema;
222
+ await client.query(`SET search_path TO "${workerSchema}", public`);
223
+ const url = new URL(this.connectionString);
224
+ url.searchParams.set("options", `-c search_path=${workerSchema},public`);
225
+ this.connectionString = url.toString();
226
+ },
227
+ reset: async () => {
228
+ await this.reset();
229
+ },
230
+ release: async () => {
231
+ const client = await this.getClient();
232
+ const workerSchema = this.schema;
233
+ this.schema = "public";
234
+ this.connectionString = this.originalConnectionString;
235
+ await client.query(`SET search_path TO public`);
236
+ await client.query(`DROP SCHEMA IF EXISTS "${workerSchema}" CASCADE`);
122
237
  }
123
238
  };
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;
239
+ }
240
+ };
241
+ /**
242
+ * Create a PostgreSQL service handle.
243
+ *
244
+ * @example
245
+ * // Key derives the compose service (exact name or kebab-case):
246
+ * // { db: postgres() } → compose service "db"
247
+ * // { analyticsDb: postgres() } → compose service "analytics-db"
248
+ * // Escape hatch for names the key cannot derive:
249
+ * const events = postgres({ composeService: "legacy_event_store" });
250
+ * // After start: events.connectionString is populated
251
+ */
252
+ function postgres(options = {}) {
253
+ return new PostgresHandle(options);
254
+ }
255
+ //#endregion
256
+ //#region src/integrations/redis/redis.ts
257
+ var RedisHandle = class {
258
+ type = "redis";
259
+ composeName;
260
+ defaultPort = 6379;
261
+ defaultImage;
262
+ environment = {};
263
+ connectionString = "";
264
+ started = false;
265
+ dbIndex = 0;
266
+ constructor(options = {}) {
267
+ this.composeName = options.composeService ?? null;
268
+ this.defaultImage = options.image ?? "redis:7";
269
+ }
270
+ buildConnectionString(host, port) {
271
+ return `redis://${host}:${port}`;
272
+ }
273
+ createDatabaseAdapter() {
274
+ return null;
275
+ }
276
+ async healthcheck() {
277
+ if (!this.connectionString) throw new Error("redis: cannot healthcheck — no connection string");
278
+ try {
279
+ const { createClient } = await import("redis");
280
+ const client = createClient({ url: this.connectionString });
281
+ await client.connect();
282
+ await client.ping();
283
+ await client.disconnect();
284
+ } catch (error) {
285
+ throw new Error(`redis healthcheck failed: ${error.message || error.code || String(error)}`, { cause: error });
286
+ }
287
+ }
288
+ async initialize() {}
289
+ async reset() {
290
+ const { createClient } = await import("redis");
291
+ const client = createClient({
292
+ url: this.connectionString,
293
+ database: this.dbIndex
130
294
  });
295
+ await client.connect();
296
+ try {
297
+ await client.flushDb();
298
+ } finally {
299
+ await client.disconnect();
300
+ }
301
+ }
302
+ isolation() {
131
303
  return {
132
- status: response.status,
133
- body: responseBody,
134
- headers: responseHeaders
304
+ acquire: async (workerId) => {
305
+ const numericId = Number.parseInt(workerId, 10) || 0;
306
+ this.dbIndex = numericId % 15 + 1;
307
+ },
308
+ reset: async () => {
309
+ await this.reset();
310
+ },
311
+ release: async () => {
312
+ await this.reset();
313
+ this.dbIndex = 0;
314
+ }
135
315
  };
136
316
  }
137
317
  };
318
+ /**
319
+ * Create a Redis service handle.
320
+ *
321
+ * @example
322
+ * // The record key derives the compose service: { cache: redis() } → "cache".
323
+ * const cache = redis();
324
+ * // After start: cache.connectionString is populated
325
+ */
326
+ function redis(options = {}) {
327
+ return new RedisHandle(options);
328
+ }
329
+ //#endregion
330
+ //#region src/integrations/testcontainers/testcontainers.ts
331
+ /**
332
+ * Container adapter using testcontainers.
333
+ * Wraps a GenericContainer for programmatic container lifecycle.
334
+ */
335
+ var TestcontainersAdapter = class {
336
+ image;
337
+ containerPort;
338
+ env;
339
+ reuse;
340
+ container = null;
341
+ constructor(options) {
342
+ this.image = options.image;
343
+ this.containerPort = options.port;
344
+ this.env = options.env ?? {};
345
+ this.reuse = options.reuse ?? false;
346
+ }
347
+ async start() {
348
+ const { GenericContainer, Wait } = await import("testcontainers");
349
+ let builder = new GenericContainer(this.image).withExposedPorts(this.containerPort);
350
+ for (const [key, value] of Object.entries(this.env)) builder = builder.withEnvironment({ [key]: value });
351
+ if (this.image.startsWith("postgres")) builder = builder.withWaitStrategy(Wait.forLogMessage(/database system is ready to accept connections/, 2));
352
+ if (this.reuse) builder = builder.withReuse();
353
+ this.container = await builder.start();
354
+ }
355
+ async stop() {
356
+ if (this.container && !this.reuse) {
357
+ await this.container.stop();
358
+ this.container = null;
359
+ }
360
+ }
361
+ getMappedPort(containerPort) {
362
+ if (!this.container) throw new Error("Container not started");
363
+ return this.container.getMappedPort(containerPort);
364
+ }
365
+ getHost() {
366
+ if (!this.container) throw new Error("Container not started");
367
+ return this.container.getHost();
368
+ }
369
+ getConnectionString() {
370
+ return `${this.getHost()}:${this.getMappedPort(this.containerPort)}`;
371
+ }
372
+ async getLogs() {
373
+ if (!this.container) return "";
374
+ const stream = await this.container.logs();
375
+ return new Promise((resolve) => {
376
+ let output = "";
377
+ stream.on("data", (chunk) => {
378
+ output += chunk.toString();
379
+ });
380
+ stream.on("end", () => {
381
+ resolve(output);
382
+ });
383
+ setTimeout(() => {
384
+ resolve(output);
385
+ }, 1e3);
386
+ });
387
+ }
388
+ };
138
389
  //#endregion
139
- //#region src/builder/http/adapters/hono.adapter.ts
390
+ //#region src/integrations/hono/hono.adapter.ts
140
391
  /**
141
392
  * Server adapter that dispatches requests in-process through a Hono app instance.
142
393
  * Used by the `integration()` specification runner -- no network overhead.
@@ -154,7 +405,7 @@ var HonoAdapter = class {
154
405
  ...headers
155
406
  }
156
407
  };
157
- if (body !== void 0) init.body = JSON.stringify(body);
408
+ if (body !== void 0) init.body = typeof body === "string" ? body : JSON.stringify(body);
158
409
  const response = await this.app.request(path, init);
159
410
  const responseBody = await response.json().catch(() => null);
160
411
  const responseHeaders = {};
@@ -169,76 +420,387 @@ var HonoAdapter = class {
169
420
  }
170
421
  };
171
422
  //#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
- ];
423
+ //#region src/core/http-files/http-file.ts
424
+ function splitSections(content) {
425
+ const lines = content.split(/\r?\n/);
426
+ let index = 0;
427
+ while (index < lines.length && lines[index].trim() === "") index++;
428
+ const startLine = lines[index] ?? "";
429
+ index++;
430
+ const headerLines = [];
431
+ while (index < lines.length && lines[index].trim() !== "") {
432
+ headerLines.push(lines[index]);
433
+ index++;
434
+ }
435
+ return {
436
+ body: lines.slice(index + 1).join("\n").trim(),
437
+ headerLines,
438
+ startLine
439
+ };
440
+ }
441
+ function parseHeaders(headerLines, fileName) {
442
+ const headers = {};
443
+ for (const line of headerLines) {
444
+ const separator = line.indexOf(":");
445
+ if (separator === -1) throw new Error(`${fileName}: invalid header line "${line}" (expected "Name: value")`);
446
+ headers[line.slice(0, separator).trim()] = line.slice(separator + 1).trim();
447
+ }
448
+ return headers;
449
+ }
450
+ /** Parse a `requests/*.http` file. First line must be `METHOD /path`. */
451
+ function parseRequestFile(content, fileName) {
452
+ const { body, headerLines, startLine } = splitSections(content);
453
+ const start = /^(?<method>[A-Z]+)\s+(?<path>\S+)$/.exec(startLine.trim());
454
+ if (!start?.groups) throw new Error(`${fileName}: first line must be "METHOD /path" (e.g. "POST /users"), got "${startLine}"`);
455
+ return {
456
+ body: body.length > 0 ? body : void 0,
457
+ headers: parseHeaders(headerLines, fileName),
458
+ method: start.groups.method,
459
+ path: start.groups.path
460
+ };
461
+ }
462
+ /** Parse a response `.http` file. First line must be `HTTP/1.1 <status>`. */
463
+ function parseResponseFile(content, fileName) {
464
+ const { body, headerLines, startLine } = splitSections(content);
465
+ const start = /^HTTP\/1\.1\s+(?<status>\S+)(?:\s+.*)?$/.exec(startLine.trim());
466
+ if (!start?.groups) throw new Error(`${fileName}: first line must be "HTTP/1.1 <status>" (e.g. "HTTP/1.1 200 OK"), got "${startLine}"`);
467
+ let parsedBody;
468
+ if (body.length > 0) try {
469
+ parsedBody = JSON.parse(body);
470
+ } catch {
471
+ parsedBody = body;
472
+ }
473
+ return {
474
+ body: parsedBody,
475
+ hasBody: body.length > 0,
476
+ headers: parseHeaders(headerLines, fileName),
477
+ status: start.groups.status
478
+ };
479
+ }
480
+ const STATUS_TEXT = {
481
+ 200: "OK",
482
+ 201: "Created",
483
+ 202: "Accepted",
484
+ 204: "No Content",
485
+ 301: "Moved Permanently",
486
+ 302: "Found",
487
+ 304: "Not Modified",
488
+ 400: "Bad Request",
489
+ 401: "Unauthorized",
490
+ 403: "Forbidden",
491
+ 404: "Not Found",
492
+ 409: "Conflict",
493
+ 422: "Unprocessable Entity",
494
+ 429: "Too Many Requests",
495
+ 500: "Internal Server Error",
496
+ 502: "Bad Gateway",
497
+ 503: "Service Unavailable"
498
+ };
499
+ /** Serialize a response fixture (used by update mode). */
500
+ function serializeResponseFile(response) {
501
+ const statusNumber = Number(response.status);
502
+ const statusText = STATUS_TEXT[statusNumber];
503
+ const lines = [`HTTP/1.1 ${response.status}${statusText ? ` ${statusText}` : ""}`];
504
+ for (const [name, value] of Object.entries(response.headers)) lines.push(`${name}: ${value}`);
505
+ if (response.hasBody) {
506
+ lines.push("");
507
+ lines.push(typeof response.body === "string" && !isJsonLike(response.body) ? response.body : JSON.stringify(response.body, null, 4));
508
+ }
509
+ return `${lines.join("\n")}\n`;
510
+ }
511
+ function isJsonLike(text) {
512
+ try {
513
+ JSON.parse(text);
514
+ return true;
515
+ } catch {
516
+ return false;
517
+ }
518
+ }
519
+ //#endregion
520
+ //#region src/core/matching/structural.ts
186
521
  /**
187
- * Recursively walk a directory, returning sorted relative paths of files only.
188
- * Ignored entries (default + caller-supplied) are skipped.
522
+ * Structural comparison engine shared by every fixture matcher.
523
+ *
524
+ * Handles three kinds of "expected" values:
525
+ * - plain JSON values → strict deep equality
526
+ * - {@link Matcher} instances (code-side `match.*`)
527
+ * - strings containing `{{placeholder}}` forms (file-side fixtures)
528
+ *
529
+ * One unified `{{token}}` grammar (CONVENTIONS D4) — the same vocabulary
530
+ * works in `expected/*.http` (body and headers), `expected/*.json`, and
531
+ * text snapshots (`expected/*.txt`).
532
+ *
533
+ * Ref captures (`match.ref(name)` / `{{type#name}}`) are recorded in the
534
+ * {@link CaptureScope} supplied by the caller.
189
535
  */
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("/"));
536
+ const UUID_SOURCE = "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}";
537
+ const ULID_SOURCE = "[0-9A-HJKMNP-TV-Z]{26}";
538
+ const ISO8601_SOURCE = String.raw`\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})`;
539
+ const DATE_SOURCE = String.raw`\d{4}-\d{2}-\d{2}`;
540
+ const TIME_SOURCE = String.raw`\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?`;
541
+ const DURATION_SOURCE = String.raw`\d+(?:\.\d+)?(?:ms|s|m|h)`;
542
+ const NUMBER_SOURCE = String.raw`-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?`;
543
+ const INT_SOURCE = String.raw`-?\d+`;
544
+ const FLOAT_SOURCE = String.raw`-?\d+\.\d+`;
545
+ const SEMVER_SOURCE = String.raw`\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?`;
546
+ const SHA_SOURCE = "[0-9a-f]{7,64}";
547
+ const HEX_SOURCE = "[0-9a-fA-F]+";
548
+ const BASE64_SOURCE = "[A-Za-z0-9+/]+={0,2}";
549
+ const PORT_SOURCE = String.raw`(?:6553[0-5]|655[0-2]\d|65[0-4]\d{2}|6[0-4]\d{3}|[1-5]\d{4}|\d{1,4})`;
550
+ const IP_OCTET = String.raw`(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)`;
551
+ const IP_SOURCE = String.raw`(?:${IP_OCTET}\.){3}${IP_OCTET}`;
552
+ const URL_SOURCE = String.raw`https?:\/\/[^\s"'<>]+`;
553
+ const EMBEDDED_SOURCES = {
554
+ base64: BASE64_SOURCE,
555
+ date: DATE_SOURCE,
556
+ duration: DURATION_SOURCE,
557
+ email: String.raw`[^\s@"'<>]+@[^\s@"'<>]+\.[^\s@"'<>]+`,
558
+ float: FLOAT_SOURCE,
559
+ hex: HEX_SOURCE,
560
+ int: INT_SOURCE,
561
+ ip: IP_SOURCE,
562
+ iso8601: ISO8601_SOURCE,
563
+ number: NUMBER_SOURCE,
564
+ path: String.raw`\.{0,2}\/[^\s"'<>]*`,
565
+ port: PORT_SOURCE,
566
+ semver: SEMVER_SOURCE,
567
+ sha: SHA_SOURCE,
568
+ time: TIME_SOURCE,
569
+ ulid: ULID_SOURCE,
570
+ url: URL_SOURCE,
571
+ uuid: UUID_SOURCE
572
+ };
573
+ const wholeRe = (source) => new RegExp(`^(?:${source})$`);
574
+ const WHOLE_RES = Object.fromEntries(Object.entries(EMBEDDED_SOURCES).map(([kind, source]) => [kind, wholeRe(source)]));
575
+ const PLACEHOLDER_RE = new RegExp(String.raw`\{\{(?<kind>${[...TOKEN_KINDS].sort((a, b) => b.length - a.length).join("|")})(?:#(?<ref>[\w.-]+))?\}\}`, "g");
576
+ /** Whether a fixture string contains at least one `{{placeholder}}`. */
577
+ function hasPlaceholders(value) {
578
+ PLACEHOLDER_RE.lastIndex = 0;
579
+ return PLACEHOLDER_RE.test(value);
580
+ }
581
+ /** Key-order-independent stringification for captured-object comparison. */
582
+ function stableStringify(value) {
583
+ if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(",")}]`;
584
+ if (value !== null && typeof value === "object") return `{${Object.entries(value).sort(([a], [b]) => a < b ? -1 : 1).map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`).join(",")}}`;
585
+ return JSON.stringify(value) ?? "undefined";
586
+ }
587
+ function capturedEquals(a, b) {
588
+ if (Object.is(a, b)) return true;
589
+ if (typeof a === "number" && typeof b === "string" || typeof a === "string" && typeof b === "number") return String(a) === String(b);
590
+ if (a !== null && b !== null && typeof a === "object" && typeof b === "object") return stableStringify(a) === stableStringify(b);
591
+ return false;
592
+ }
593
+ function recordRef(name, actual, scope) {
594
+ if (scope.has(name)) return capturedEquals(scope.get(name), actual);
595
+ scope.set(name, actual);
596
+ return true;
597
+ }
598
+ function isPortValue(value) {
599
+ return Number.isInteger(value) && value >= 0 && value <= 65535;
600
+ }
601
+ function kindMatches(kind, actual, scope) {
602
+ switch (kind) {
603
+ case "any": return true;
604
+ case "float":
605
+ if (typeof actual === "number") return Number.isFinite(actual);
606
+ return typeof actual === "string" && WHOLE_RES.float.test(actual);
607
+ case "int":
608
+ if (typeof actual === "number") return Number.isInteger(actual);
609
+ return typeof actual === "string" && WHOLE_RES.int.test(actual);
610
+ case "number":
611
+ if (typeof actual === "number") return Number.isFinite(actual);
612
+ return typeof actual === "string" && WHOLE_RES.number.test(actual);
613
+ case "port":
614
+ if (typeof actual === "number") return isPortValue(actual);
615
+ return typeof actual === "string" && WHOLE_RES.port.test(actual) && isPortValue(Number(actual));
616
+ case "string": return typeof actual === "string";
617
+ case "workdir": return typeof actual === "string" && scope.workdir !== void 0 ? actual === scope.workdir : false;
618
+ default: {
619
+ const re = WHOLE_RES[kind];
620
+ return re !== void 0 && typeof actual === "string" && re.test(actual);
206
621
  }
207
622
  }
208
- await walk(root);
209
- out.sort();
210
- return out;
211
623
  }
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
624
+ function matcherMatches(matcher, actual, scope) {
625
+ if (matcher.kind === "regex") return typeof actual === "string" && matcher.regex.test(actual);
626
+ if (matcher.kind === "ref") {
627
+ if (matcher.notRef && scope.has(matcher.notRef) && capturedEquals(scope.get(matcher.notRef), actual)) return false;
628
+ return recordRef(matcher.refName, actual, scope);
629
+ }
630
+ return kindMatches(matcher.kind, actual, scope);
631
+ }
632
+ function placeholderSource(kind, scope) {
633
+ if (kind === "workdir") return scope.workdir === void 0 ? "(?!)" : escapeRegExp(scope.workdir);
634
+ const source = EMBEDDED_SOURCES[kind];
635
+ if (source) return source;
636
+ return kind === "any" ? String.raw`[\s\S]*?` : String.raw`[^\n]*?`;
637
+ }
638
+ function escapeRegExp(text) {
639
+ return text.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
640
+ }
641
+ function parsePlaceholderString(expected, scope) {
642
+ PLACEHOLDER_RE.lastIndex = 0;
643
+ const refs = [];
644
+ let pattern = "^";
645
+ let lastIndex = 0;
646
+ let single = null;
647
+ let count = 0;
648
+ for (const found of expected.matchAll(PLACEHOLDER_RE)) {
649
+ const kind = found.groups.kind;
650
+ const ref = found.groups.ref;
651
+ pattern += escapeRegExp(expected.slice(lastIndex, found.index));
652
+ pattern += `(${placeholderSource(kind, scope)})`;
653
+ refs.push({
654
+ index: count,
655
+ kind,
656
+ ref
232
657
  });
658
+ count++;
659
+ lastIndex = found.index + found[0].length;
660
+ if (found.index === 0 && found[0].length === expected.length) single = {
661
+ kind,
662
+ ref
663
+ };
233
664
  }
665
+ pattern += `${escapeRegExp(expected.slice(lastIndex))}$`;
234
666
  return {
235
- added,
236
- changed,
237
- removed
667
+ pattern: new RegExp(pattern),
668
+ refs,
669
+ single
238
670
  };
239
671
  }
672
+ function placeholderStringMatches(expected, actual, scope) {
673
+ const parsed = parsePlaceholderString(expected, scope);
674
+ if (parsed.single) {
675
+ if (!kindMatches(parsed.single.kind, actual, scope)) return false;
676
+ if (parsed.single.ref) return recordRef(parsed.single.ref, actual, scope);
677
+ return true;
678
+ }
679
+ if (typeof actual !== "string" && typeof actual !== "number") return false;
680
+ const text = String(actual);
681
+ const found = parsed.pattern.exec(text);
682
+ if (!found) return false;
683
+ for (const entry of parsed.refs) {
684
+ if (!entry.ref) continue;
685
+ if (!recordRef(entry.ref, found[entry.index + 1], scope)) return false;
686
+ }
687
+ return true;
688
+ }
689
+ function isPlainObject(value) {
690
+ return value !== null && typeof value === "object" && !Array.isArray(value);
691
+ }
692
+ /**
693
+ * Deep structural equality with matcher / placeholder support. Ref captures
694
+ * are recorded in `scope` in traversal order (arrays left-to-right, object
695
+ * keys in expected-key order).
696
+ */
697
+ function structuralEquals(expected, actual, scope) {
698
+ if (expected instanceof Matcher) return matcherMatches(expected, actual, scope);
699
+ if (typeof expected === "string" && hasPlaceholders(expected)) return placeholderStringMatches(expected, actual, scope);
700
+ if (Array.isArray(expected)) {
701
+ if (!Array.isArray(actual) || actual.length !== expected.length) return false;
702
+ return expected.every((item, i) => structuralEquals(item, actual[i], scope));
703
+ }
704
+ if (isPlainObject(expected)) {
705
+ if (!isPlainObject(actual)) return false;
706
+ const expectedKeys = Object.keys(expected);
707
+ const actualKeys = Object.keys(actual);
708
+ if (expectedKeys.length !== actualKeys.length) return false;
709
+ return expectedKeys.every((key) => key in actual && structuralEquals(expected[key], actual[key], scope));
710
+ }
711
+ return Object.is(expected, actual);
712
+ }
713
+ /**
714
+ * Deep structural SUBSET match (toMatchObject-style) with matcher /
715
+ * placeholder support. Plain objects match when every expected key is present
716
+ * and recursively subset-matches — the actual value may carry extra keys.
717
+ * Arrays require equal length with each element subset-matched. Leaves fall
718
+ * back to {@link structuralEquals} semantics (matchers, placeholders, strict
719
+ * equality). Used by request-body filters on intercept triggers.
720
+ */
721
+ function structuralSubset(expected, actual, scope) {
722
+ if (expected instanceof Matcher) return matcherMatches(expected, actual, scope);
723
+ if (typeof expected === "string" && hasPlaceholders(expected)) return placeholderStringMatches(expected, actual, scope);
724
+ if (Array.isArray(expected)) {
725
+ if (!Array.isArray(actual) || actual.length !== expected.length) return false;
726
+ return expected.every((item, i) => structuralSubset(item, actual[i], scope));
727
+ }
728
+ if (isPlainObject(expected)) {
729
+ if (!isPlainObject(actual)) return false;
730
+ return Object.keys(expected).every((key) => key in actual && structuralSubset(expected[key], actual[key], scope));
731
+ }
732
+ return Object.is(expected, actual);
733
+ }
734
+ /**
735
+ * Multi-line text comparison with `{{token}}` support — used by text
736
+ * snapshots (`expected/*.txt`). Without placeholders this is strict equality.
737
+ */
738
+ function textEquals(expected, actual, scope) {
739
+ if (!hasPlaceholders(expected)) return expected === actual;
740
+ return placeholderStringMatches(expected, actual, scope);
741
+ }
742
+ /**
743
+ * Render an expected value for failure diffs and serialization: Matcher
744
+ * instances become their placeholder text, everything else is untouched.
745
+ */
746
+ function renderExpected(value) {
747
+ if (value instanceof Matcher) return value.toString();
748
+ if (Array.isArray(value)) return value.map((item) => renderExpected(item));
749
+ if (isPlainObject(value)) return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, renderExpected(item)]));
750
+ return value;
751
+ }
752
+ /**
753
+ * Substitute the framework-known cwd (`workdir`) for its `{{workdir}}` token in
754
+ * every string leaf of a structured value. The structural mirror of the text
755
+ * path's `actual.replaceAll(workdir, '{{workdir}}')` — so a golden written under
756
+ * `TEST_UPDATE=1` stores the token, not a run-specific temp path (CONVENTIONS
757
+ * D5). A no-op when `workdir` is undefined (no cwd, e.g. api/jobs mode).
758
+ */
759
+ function substituteWorkdirDeep(value, workdir) {
760
+ if (typeof value === "string") return value.replaceAll(workdir, "{{workdir}}");
761
+ if (Array.isArray(value)) return value.map((item) => substituteWorkdirDeep(item, workdir));
762
+ if (isPlainObject(value)) return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, substituteWorkdirDeep(item, workdir)]));
763
+ return value;
764
+ }
765
+ /**
766
+ * Update-mode merge: rewrite a fixture from the actual output while
767
+ * preserving every segment of the previous fixture that was covered by a
768
+ * placeholder (and still matches the actual value).
769
+ *
770
+ * Token preservation is symmetric with the text path (CONVENTIONS D5): a
771
+ * previous `{{placeholder}}` (including `{{workdir}}`) that still matches the
772
+ * RAW actual value is kept; every other leaf is taken from the actual output
773
+ * with the framework-known cwd substituted back to `{{workdir}}`.
774
+ */
775
+ function mergePreservingPlaceholders(previous, actual, workdir) {
776
+ const scope = new CaptureScope(workdir);
777
+ const subst = (value) => workdir === void 0 ? value : substituteWorkdirDeep(value, workdir);
778
+ function merge(prev, act) {
779
+ if (typeof prev === "string" && hasPlaceholders(prev)) return placeholderStringMatches(prev, act, scope) ? prev : subst(act);
780
+ if (Array.isArray(prev) && Array.isArray(act)) return act.map((item, i) => i < prev.length ? merge(prev[i], item) : subst(item));
781
+ if (isPlainObject(prev) && isPlainObject(act)) return Object.fromEntries(Object.entries(act).map(([key, item]) => [key, key in prev ? merge(prev[key], item) : subst(item)]));
782
+ return subst(act);
783
+ }
784
+ return merge(previous, actual);
785
+ }
786
+ /**
787
+ * Update-mode merge for text snapshots: line-by-line, a previous line whose
788
+ * placeholders still match the actual line is preserved; every other line is
789
+ * taken from the actual output. Values the framework knows to be dynamic
790
+ * (`{{workdir}}`) are substituted automatically (CONVENTIONS D5).
791
+ */
792
+ function mergeTextPreservingPlaceholders(previous, actual, scope) {
793
+ const substituted = scope.workdir === void 0 ? actual : actual.replaceAll(scope.workdir, "{{workdir}}");
794
+ if (previous === null) return substituted;
795
+ const prevLines = previous.split("\n");
796
+ return substituted.split("\n").map((line, i) => {
797
+ const prev = prevLines[i];
798
+ if (prev === void 0 || !hasPlaceholders(prev)) return line;
799
+ return textEquals(prev, line, new CaptureScope(scope.workdir)) || textEquals(prev, actual.split("\n")[i] ?? line, new CaptureScope(scope.workdir)) ? prev : line;
800
+ }).join("\n");
801
+ }
240
802
  //#endregion
241
- //#region src/builder/common/reporter.ts
803
+ //#region src/core/specification/shared/reporter.ts
242
804
  const GREEN = "\x1B[32m";
243
805
  const RED = "\x1B[31m";
244
806
  const DIM = "\x1B[2m";
@@ -320,6 +882,27 @@ function formatResponseDiff(file, expected, actual) {
320
882
  }
321
883
  return lines.join("\n");
322
884
  }
885
+ function formatStdoutDiff(file, expected, actual) {
886
+ const lines = [];
887
+ lines.push(`Output mismatch (${file})`);
888
+ lines.push("");
889
+ lines.push(`${GREEN}- Expected${RESET}`);
890
+ lines.push(`${RED}+ Received${RESET}`);
891
+ lines.push("");
892
+ const expectedLines = expected.split("\n");
893
+ const actualLines = actual.split("\n");
894
+ const maxLines = Math.max(expectedLines.length, actualLines.length);
895
+ for (let i = 0; i < maxLines; i++) {
896
+ const exp = expectedLines[i];
897
+ const act = actualLines[i];
898
+ if (exp === act) lines.push(` ${exp}`);
899
+ else {
900
+ if (exp !== void 0) lines.push(`${GREEN}- ${exp}${RESET}`);
901
+ if (act !== void 0) lines.push(`${RED}+ ${act}${RESET}`);
902
+ }
903
+ }
904
+ return lines.join("\n");
905
+ }
323
906
  function formatDirectoryDiff(fixtureName, diff, hint) {
324
907
  const lines = [];
325
908
  const total = diff.added.length + diff.removed.length + diff.changed.length;
@@ -367,70 +950,155 @@ function rowLabel(n) {
367
950
  function formatRow(row) {
368
951
  return row.map((v) => String(v ?? "null")).join(" | ");
369
952
  }
370
- /** Strip ANSI escape codes from a string. */
371
- function stripAnsi(str) {
372
- return str.replace(/\x1b\[[0-9;]*m/g, "");
373
- }
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();
377
- }
378
953
  //#endregion
379
- //#region src/builder/common/directory-accessor.ts
954
+ //#region src/core/specification/shared/result/directory.ts
955
+ const DEFAULT_IGNORES = [
956
+ ".git",
957
+ ".DS_Store",
958
+ "node_modules",
959
+ ".next",
960
+ "dist",
961
+ ".turbo",
962
+ ".cache"
963
+ ];
380
964
  /**
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
965
+ * Recursively walk a directory, returning sorted relative paths of files only.
385
966
  */
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;
967
+ async function walkDirectory(root, options = {}) {
968
+ const ignores = /* @__PURE__ */ new Set([...DEFAULT_IGNORES, ...options.ignore ?? []]);
969
+ const out = [];
970
+ async function walk(current) {
971
+ let entries;
972
+ try {
973
+ entries = await readdir(current);
974
+ } catch {
975
+ return;
976
+ }
977
+ for (const entry of entries) {
978
+ if (ignores.has(entry)) continue;
979
+ const abs = resolve(current, entry);
980
+ const stat = statSync(abs);
981
+ if (stat.isDirectory()) await walk(abs);
982
+ else if (stat.isFile()) out.push(relative(root, abs).split(sep).join("/"));
983
+ }
984
+ }
985
+ await walk(root);
986
+ out.sort();
987
+ return out;
391
988
  }
392
989
  /**
393
- * Handle to a directory produced by a specification run.
394
- * Supports snapshot-based assertions via {@link toMatchFixture} and file listing via {@link files}.
990
+ * Compare two directory trees file-by-file. Fixture file contents honor the
991
+ * unified `{{token}}` grammar (CONVENTIONS D4) captures are recorded in
992
+ * `scope` in sorted-path order.
993
+ */
994
+ async function diffDirectories(expectedRoot, actualRoot, options = {}) {
995
+ const expectedFiles = await walkDirectory(expectedRoot, options);
996
+ const actualFiles = await walkDirectory(actualRoot, options);
997
+ const expectedSet = new Set(expectedFiles);
998
+ const actualSet = new Set(actualFiles);
999
+ const scope = options.scope ?? new CaptureScope();
1000
+ const added = actualFiles.filter((f) => !expectedSet.has(f));
1001
+ const removed = expectedFiles.filter((f) => !actualSet.has(f));
1002
+ const changed = [];
1003
+ for (const file of expectedFiles) {
1004
+ if (!actualSet.has(file)) continue;
1005
+ const expected = readFileSync(resolve(expectedRoot, file), "utf8");
1006
+ const actual = readFileSync(resolve(actualRoot, file), "utf8");
1007
+ if (!textEquals(expected, actual, scope)) changed.push({
1008
+ actual,
1009
+ expected,
1010
+ path: file
1011
+ });
1012
+ }
1013
+ return {
1014
+ added,
1015
+ changed,
1016
+ removed
1017
+ };
1018
+ }
1019
+ /**
1020
+ * Read-only accessor for a directory produced by a spec action.
1021
+ *
1022
+ * Assertions go through `expect()` (async — they walk the disk):
1023
+ * `await expect(result.directory('out')).toMatch('scaffold/out')`
1024
+ * compares the tree against the fixture directory `expected/<name>/`.
395
1025
  */
396
1026
  var DirectoryAccessor = class {
397
- absPath;
1027
+ /** @internal Ref-capture scope shared by the current spec execution. */
1028
+ captures;
1029
+ /** @internal Absolute path of the directory under assertion. */
1030
+ root;
1031
+ /** @internal Test-file directory — fixture resolution root for matchers. */
398
1032
  testDir;
399
- constructor(absPath, testDir) {
400
- this.absPath = absPath;
1033
+ constructor(absPath, testDir, captures) {
1034
+ this.root = absPath;
401
1035
  this.testDir = testDir;
1036
+ this.captures = captures ?? new CaptureScope();
402
1037
  }
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;
418
- }
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."));
1038
+ /** List all files (recursively) under the directory, sorted. */
1039
+ async files(options = {}) {
1040
+ return walkDirectory(this.root, options);
423
1041
  }
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
- */
1042
+ };
1043
+ //#endregion
1044
+ //#region src/core/specification/shared/result/filesystem.ts
1045
+ /**
1046
+ * Read-only accessor for the whole temporary working directory used by a
1047
+ * command spec.
1048
+ *
1049
+ * Assertions go through `expect()` (async — they walk the disk):
1050
+ * `await expect(result.filesystem).toMatch('scaffolded')` compares the
1051
+ * tree against the fixture directory `expected/<name>/`.
1052
+ */
1053
+ var FilesystemAccessor = class {
1054
+ /** @internal Ref-capture scope shared by the current spec execution. */
1055
+ captures;
1056
+ /** The absolute path of the temporary working directory. */
1057
+ cwd;
1058
+ /** @internal Test-file directory — fixture resolution root for matchers. */
1059
+ testDir;
1060
+ constructor(cwd, testDir, captures) {
1061
+ this.cwd = cwd;
1062
+ this.testDir = testDir;
1063
+ this.captures = captures ?? new CaptureScope();
1064
+ }
1065
+ /** List all files (recursively) under the working directory, sorted. */
428
1066
  async files(options = {}) {
429
- return walkDirectory(this.absPath, options);
1067
+ return walkDirectory(this.cwd, options);
430
1068
  }
431
1069
  };
432
1070
  //#endregion
433
- //#region src/builder/common/grep.ts
1071
+ //#region src/core/specification/shared/caller.ts
1072
+ /**
1073
+ * Directory of the running framework module itself. From the built package
1074
+ * this is `<package root>/dist`, whatever the resolution path — including a
1075
+ * `file:` link, where the real path carries neither `node_modules` nor
1076
+ * `/src/…` and would otherwise be mistaken for a caller frame.
1077
+ */
1078
+ const FRAMEWORK_DIR = dirname(fileURLToPath(import.meta.url));
1079
+ /**
1080
+ * Detect the directory of the first stack frame that lives outside this
1081
+ * package. Used to anchor fixture resolution (`seeds/`, `requests/`,
1082
+ * `expected/`, …) on the calling test / specification file.
1083
+ *
1084
+ * @internal
1085
+ */
1086
+ function getCallerDir() {
1087
+ const stack = (/* @__PURE__ */ new Error("caller detection")).stack;
1088
+ if (!stack) throw new Error("Cannot detect caller directory: no stack trace");
1089
+ const lines = stack.split("\n");
1090
+ for (const line of lines) {
1091
+ const match = line.match(/at\s+(?:.*?\()?(?:file:\/\/)?(?<filePath>[^:)]+):\d+:\d+/);
1092
+ if (!match?.groups?.filePath) continue;
1093
+ const filePath = match.groups.filePath;
1094
+ if (filePath.includes("node_modules")) continue;
1095
+ if ((filePath.includes("/src/core/") || filePath.includes("/src/integrations/") || filePath.includes("/src/vitest/") || resolve(filePath, "..") === FRAMEWORK_DIR) && !/\.test\.[cm]?[jt]s$/.test(filePath)) continue;
1096
+ return resolve(filePath, "..");
1097
+ }
1098
+ throw new Error("Cannot detect caller directory from stack trace");
1099
+ }
1100
+ //#endregion
1101
+ //#region src/core/specification/shared/result/grep.ts
434
1102
  /**
435
1103
  * Extract text blocks from output that contain a pattern.
436
1104
  * Splits by blank lines (how linter/compiler output is structured),
@@ -444,841 +1112,1461 @@ function grep(output, pattern) {
444
1112
  return output.replace(/\x1b\[[0-9;]*m/g, "").split(/\n\s*\n/).filter((block) => block.includes(pattern)).join("\n\n");
445
1113
  }
446
1114
  //#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;
1115
+ //#region src/core/specification/shared/result/text.ts
1116
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
1117
+ /** Strip ANSI escape sequences from a string. */
1118
+ function stripAnsiCodes(value) {
1119
+ return value.replace(ANSI_RE, "");
1120
+ }
1121
+ /**
1122
+ * Read-only accessor for a captured text handle — THE universal one: stdout,
1123
+ * stderr, container logs, and file text all surface as a `TextAccessor`.
1124
+ *
1125
+ * Backed by a primitive string, exposed via {@link text}, but also provides
1126
+ * `toString()` / `valueOf()` for string coercion, so common patterns like
1127
+ * `String(result.stdout)` and template-literal interpolation still work.
1128
+ *
1129
+ * ANSI escape sequences are stripped by default before every comparison
1130
+ * (CONVENTIONS D6) — `.text` stays raw. The runner `transform` option remains
1131
+ * an escape hatch for application noise not covered by the `{{token}}`
1132
+ * grammar.
1133
+ *
1134
+ * Text operations are **closed** over the type: `.grep(pattern)` returns a
1135
+ * `TextAccessor` (not a bare string), preserving the `expected/`-resolution
1136
+ * context and the `transform`, so results are chainable
1137
+ * (`result.stdout.grep(a).grep(b)`) and snapshot-able
1138
+ * (`expect(result.stdout.grep('users.ts')).toMatch('block.txt')`).
1139
+ *
1140
+ * Assertions go through `expect()` matchers: `expect(result.stdout).toContain(...)`
1141
+ * and `expect(result.stdout).toMatch('name.txt')` (resolved against
1142
+ * `expected/<name>`, flat — with `{{token}}` support, CONVENTIONS D4).
1143
+ */
1144
+ var TextAccessor = class TextAccessor {
1145
+ /** @internal Ref-capture scope shared by the current spec execution. */
1146
+ captures;
1147
+ /** @internal Stream label used in failure messages ("stdout" / "stderr"). */
1148
+ streamName;
1149
+ /** @internal Test-file directory — fixture resolution root for matchers. */
451
1150
  testDir;
452
- constructor(body, testDir) {
453
- this.body = body;
1151
+ /** The raw captured text (never transformed, ANSI preserved). */
1152
+ text;
1153
+ /** @internal Normaliser applied before comparisons, never to fixtures. */
1154
+ transform;
1155
+ constructor(value, streamName, testDir, options = {}) {
1156
+ this.text = value;
1157
+ this.streamName = streamName;
454
1158
  this.testDir = testDir;
1159
+ this.transform = options.transform;
1160
+ this.captures = options.captures ?? new CaptureScope();
455
1161
  }
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;
1162
+ /** @internal The text as compared by matchers — ANSI stripped, transform applied. */
1163
+ get comparableText() {
1164
+ const stripped = stripAnsiCodes(this.text);
1165
+ return this.transform ? this.transform(stripped) : stripped;
476
1166
  }
477
1167
  /**
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
- * });
1168
+ * Keep only the blank-line-separated blocks of the text that contain
1169
+ * `pattern` (how linter/compiler output is structured), returned as a new
1170
+ * `TextAccessor` — the same subject type, so the result is chainable and
1171
+ * snapshot-able. The scalpel for probing large tool outputs; snapshot the
1172
+ * whole surface by default, reach for `.grep()` for targeted checks.
485
1173
  */
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));
1174
+ grep(pattern) {
1175
+ return new TextAccessor(grep(this.text, pattern), this.streamName, this.testDir, {
1176
+ captures: this.captures,
1177
+ transform: this.transform
1178
+ });
1179
+ }
1180
+ toString() {
1181
+ return this.text;
489
1182
  }
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`);
1183
+ valueOf() {
1184
+ return this.text;
494
1185
  }
495
1186
  };
1187
+ /**
1188
+ * Wrap an arbitrary string into a {@link TextAccessor} anchored on the calling
1189
+ * test's directory — the same caller-detection the builders use.
1190
+ *
1191
+ * The product surface of a test framework is its own error messages, checker
1192
+ * output, and reports; those deserve the same goldening as any other output.
1193
+ * `text()` makes an ad-hoc string a first-class snapshot subject:
1194
+ *
1195
+ * ```typescript
1196
+ * const message = await catchMessage(() => expect(result.response).toMatch('wrong-body.http'));
1197
+ * expect(text(message)).toMatch('wrong-body-error.txt'); // resolves to expected/
1198
+ * ```
1199
+ *
1200
+ * ANSI is stripped before every comparison (the raw form stays on `.text`),
1201
+ * the `{{token}}` grammar applies to the fixture, and `.grep()` composition
1202
+ * works exactly as on any stream accessor.
1203
+ */
1204
+ function text(value) {
1205
+ return new TextAccessor(value, "text", getCallerDir());
1206
+ }
496
1207
  //#endregion
497
- //#region src/builder/common/result.ts
1208
+ //#region src/core/specification/shared/result/json.ts
498
1209
  /**
499
- * The outcome of a single specification run.
500
- * Provides accessors for CLI output, HTTP responses, files, directories, and database tables.
1210
+ * Read-only accessor for a JSON payload parsed from a text stream (stdout).
1211
+ *
1212
+ * Lazily parses the JSON on first use; parse errors are deferred until the
1213
+ * value is read so that callers can still read the raw stream text without
1214
+ * triggering a throw.
1215
+ *
1216
+ * ANSI escape sequences are stripped by default before `JSON.parse`
1217
+ * (CONVENTIONS D6). When a `transform` is configured on the spec runner, it
1218
+ * runs on the stripped text — an escape hatch for application noise not
1219
+ * covered by the `{{token}}` grammar.
1220
+ *
1221
+ * Assertions go through `expect()`: `expect(result.json).toMatch('name.json')`
1222
+ * (deep-equal against `expected/<name>`, with `{{placeholder}}` support).
501
1223
  */
502
- var SpecificationResult = class {
503
- commandResult;
504
- config;
505
- requestInfo;
506
- responseData;
1224
+ var JsonAccessor = class {
1225
+ /** @internal Ref-capture scope shared by the current spec execution. */
1226
+ captures;
1227
+ /** @internal */
1228
+ rawText;
1229
+ /** @internal Test-file directory — fixture resolution root for matchers. */
507
1230
  testDir;
508
- workDir;
509
- 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;
1231
+ /** @internal */
1232
+ transform;
1233
+ constructor(rawText, testDir, transform, captures) {
1234
+ this.rawText = rawText;
1235
+ this.testDir = testDir;
1236
+ this.transform = transform;
1237
+ this.captures = captures ?? new CaptureScope();
516
1238
  }
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;
1239
+ /** The parsed JSON value. Throws if the text is not valid JSON. */
1240
+ get value() {
1241
+ const stripped = stripAnsiCodes(this.rawText);
1242
+ const source = this.transform ? this.transform(stripped) : stripped;
1243
+ try {
1244
+ return JSON.parse(source);
1245
+ } catch {
1246
+ const preview = source.slice(0, 200);
1247
+ throw new Error(`stdout is not valid JSON: ${preview}`);
1248
+ }
521
1249
  }
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;
1250
+ };
1251
+ //#endregion
1252
+ //#region src/core/specification/shared/result/response.ts
1253
+ /**
1254
+ * Read-only accessor for an HTTP response.
1255
+ *
1256
+ * Assertions go through `expect()`:
1257
+ * `expect(result.response).toMatch('created.http')` compares status,
1258
+ * a subset of headers, and the JSON body against `expected/<name>` —
1259
+ * with `{{placeholder}}` support in all three.
1260
+ */
1261
+ var ResponseAccessor = class {
1262
+ /** The parsed JSON response body (null when parsing failed). */
1263
+ body;
1264
+ /** @internal Ref-capture scope shared by the current spec execution. */
1265
+ captures;
1266
+ /** Response headers as a flat, lower-cased key-value map. */
1267
+ headers;
1268
+ /** The HTTP response status code. */
1269
+ status;
1270
+ /** @internal Test-file directory — fixture resolution root for matchers. */
1271
+ testDir;
1272
+ constructor(response, testDir, captures) {
1273
+ this.body = response.body;
1274
+ this.headers = response.headers;
1275
+ this.status = response.status;
1276
+ this.testDir = testDir;
1277
+ this.captures = captures ?? new CaptureScope();
526
1278
  }
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;
1279
+ };
1280
+ //#endregion
1281
+ //#region src/core/specification/shared/result/table.ts
1282
+ /**
1283
+ * Read-only accessor for a database table after a specification run.
1284
+ *
1285
+ * Assertions go through `expect()` (async — they query the database):
1286
+ *
1287
+ * await expect(result.table('users', { database: 'db' })).toMatchRows({
1288
+ * columns: ['name'],
1289
+ * rows: [['Alice']],
1290
+ * });
1291
+ * await expect(result.table('users', { database: 'db' })).toBeEmpty();
1292
+ */
1293
+ var TableAccessor = class {
1294
+ /** @internal Ref-capture scope shared by the current spec execution. */
1295
+ captures;
1296
+ /** @internal */
1297
+ db;
1298
+ /** The table name this accessor reads. */
1299
+ name;
1300
+ constructor(name, db, captures) {
1301
+ this.name = name;
1302
+ this.db = db;
1303
+ this.captures = captures ?? new CaptureScope();
531
1304
  }
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;
1305
+ /** @internal Query the table used by the toMatchRows / toBeEmpty matchers. */
1306
+ query(columns) {
1307
+ return this.db.query(this.name, columns);
536
1308
  }
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);
1309
+ };
1310
+ //#endregion
1311
+ //#region src/integrations/docker/docker-lookup.ts
1312
+ /**
1313
+ * Thin synchronous wrappers around the `docker` CLI used by the docker() spec
1314
+ * mode. Sync is deliberate — the calls are fast, run only on the host, and
1315
+ * keep the dispose path straightforward (`Symbol.asyncDispose` can still be
1316
+ * async without needing these to be).
1317
+ */
1318
+ const DEFAULT_TIMEOUT = 1e4;
1319
+ /** Return all container IDs (running or stopped) that carry `key=value`. */
1320
+ function findContainersByLabel(key, value) {
1321
+ try {
1322
+ return execSync(`docker ps -aq -f label=${key}=${value}`, {
1323
+ encoding: "utf8",
1324
+ timeout: DEFAULT_TIMEOUT
1325
+ }).split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
1326
+ } catch {
1327
+ return [];
546
1328
  }
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);
1329
+ }
1330
+ /** Return the raw `docker inspect` payload (object, not array) for a container. */
1331
+ function inspectContainer(id) {
1332
+ const raw = execSync(`docker inspect ${id}`, {
1333
+ encoding: "utf8",
1334
+ timeout: DEFAULT_TIMEOUT
1335
+ });
1336
+ const parsed = JSON.parse(raw);
1337
+ return Array.isArray(parsed) ? parsed[0] : parsed;
1338
+ }
1339
+ /** Force-remove the given container IDs in a single call. Errors are swallowed. */
1340
+ function removeContainers(ids) {
1341
+ if (ids.length === 0) return;
1342
+ try {
1343
+ execSync(`docker rm -f ${ids.join(" ")}`, {
1344
+ encoding: "utf8",
1345
+ stdio: "pipe",
1346
+ timeout: DEFAULT_TIMEOUT
1347
+ });
1348
+ } catch {}
1349
+ }
1350
+ //#endregion
1351
+ //#region src/core/specification/shared/result/result.ts
1352
+ /**
1353
+ * Base result - common accessors available after any action type.
1354
+ * Extended by HttpResult, CliResult, and used directly by job results.
1355
+ *
1356
+ * Every result carries a fresh {@link CaptureScope}: `match.ref()` captures
1357
+ * and `{{type#ref}}` placeholders are scoped to one spec execution.
1358
+ */
1359
+ var BaseResult = class {
1360
+ /** @internal Ref-capture scope shared by every assertion on this result. */
1361
+ captures;
1362
+ config;
1363
+ testDir;
1364
+ workDir;
1365
+ constructor(options) {
1366
+ this.config = options.config;
1367
+ this.testDir = options.testDir;
1368
+ this.workDir = options.workDir;
1369
+ this.captures = new CaptureScope(options.workDir ? safeRealpath(options.workDir) : void 0);
551
1370
  }
552
1371
  /** Access a directory (relative to the working directory) for snapshot assertions. */
553
1372
  directory(path = ".") {
554
- return new DirectoryAccessor(resolve(this.workDir ?? this.testDir, path), this.testDir);
1373
+ return new DirectoryAccessor(resolve(this.workDir ?? this.testDir, path), this.testDir, this.captures);
555
1374
  }
556
1375
  /** Access a single file (relative to the working directory) for content assertions. */
557
1376
  file(path) {
558
1377
  const resolvedPath = resolve(this.workDir ?? this.testDir, path);
559
1378
  const exists = existsSync(resolvedPath);
1379
+ const { captures, testDir } = this;
560
1380
  return {
561
1381
  get content() {
562
1382
  if (!exists) throw new Error(`File not found: ${path}`);
563
1383
  return readFileSync(resolvedPath, "utf8");
564
1384
  },
565
- exists
1385
+ exists,
1386
+ grep(pattern) {
1387
+ if (!exists) throw new Error(`File not found: ${path}`);
1388
+ return new TextAccessor(readFileSync(resolvedPath, "utf8"), path, testDir, { captures }).grep(pattern);
1389
+ }
566
1390
  };
567
1391
  }
568
- /** Access a database table for row-level assertions. */
1392
+ /** Access a database table for row-level assertions via expect() matchers. */
569
1393
  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);
1394
+ validateDatabaseOption("table", this.config, options?.database);
1395
+ const db = this.resolveDatabase(options?.database);
1396
+ if (!db) throw new Error(options?.database ? `table("${tableName}") requires database "${options.database}" but it was not found` : `table("${tableName}") requires a database adapter`);
1397
+ return new TableAccessor(tableName, db, this.captures);
573
1398
  }
574
- resolveDatabase(serviceName) {
575
- if (serviceName && this.config.databases) return this.config.databases.get(serviceName);
1399
+ resolveDatabase(databaseKey) {
1400
+ if (databaseKey && this.config.databases) return this.config.databases.get(databaseKey);
576
1401
  return this.config.database;
577
1402
  }
578
1403
  };
1404
+ function safeRealpath(path) {
1405
+ try {
1406
+ return realpathSync(path);
1407
+ } catch {
1408
+ return path;
1409
+ }
1410
+ }
1411
+ /**
1412
+ * Enforce CONVENTIONS A7: with two or more databases in the services record
1413
+ * the `database` option is mandatory on `.seed()` / `.table()`; with exactly
1414
+ * one it is forbidden (redundant).
1415
+ *
1416
+ * @internal
1417
+ */
1418
+ function validateDatabaseOption(method, config, database) {
1419
+ const keys = config.databaseKeys;
1420
+ if (!keys) return;
1421
+ if (keys.length >= 2 && !database) throw new Error(`${method}(): ${keys.length} databases are declared (${keys.map((k) => `"${k}"`).join(", ")}) — pass { database: <key> } to target one of them.`);
1422
+ if (keys.length === 1 && database) throw new Error(`${method}(): redundant database option — "${keys[0]}" is the only declared database.`);
1423
+ }
579
1424
  //#endregion
580
- //#region src/builder/specification-builder.ts
1425
+ //#region src/core/specification/cli/result.ts
581
1426
  /**
582
- * Fluent builder for declaring a single test specification.
1427
+ * Result from a command action (`.exec()`).
583
1428
  *
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.
1429
+ * When the runner was configured with a `docker` option, the result also
1430
+ * exposes container accessors and participates in `Symbol.asyncDispose`
1431
+ * cleanup. The Docker shell-outs are **lazy**: a test that never calls
1432
+ * `.container(...)` never queries the Docker daemon, so command-only tests
1433
+ * pay zero Docker cost even when the runner is Docker-aware.
1434
+ *
1435
+ * Dispose always runs one final label-filtered `docker ps` to catch
1436
+ * containers that were spawned during `.exec` but never asserted on —
1437
+ * tests still get leak-free cleanup even if they forget to reach into
1438
+ * a container.
587
1439
  */
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;
1440
+ var CliResult = class extends BaseResult {
1441
+ commandOutput;
1442
+ containersCache = null;
1443
+ dockerConfig;
1444
+ testRunId;
1445
+ transform;
1446
+ constructor(options) {
1447
+ super(options);
1448
+ this.commandOutput = options.commandOutput;
1449
+ this.dockerConfig = options.dockerConfig;
1450
+ this.testRunId = options.testRunId;
1451
+ this.transform = options.transform;
607
1452
  }
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
1453
+ /** The process exit code. */
1454
+ get exitCode() {
1455
+ return this.commandOutput.exitCode;
1456
+ }
1457
+ /** Accessor for the captured standard output with file-based assertions. */
1458
+ get stdout() {
1459
+ return new TextAccessor(this.commandOutput.stdout, "stdout", this.testDir, {
1460
+ captures: this.captures,
1461
+ transform: this.transform
618
1462
  });
619
- return this;
620
1463
  }
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;
1464
+ /** Accessor for the captured standard error with file-based assertions. */
1465
+ get stderr() {
1466
+ return new TextAccessor(this.commandOutput.stderr, "stderr", this.testDir, {
1467
+ captures: this.captures,
1468
+ transform: this.transform
1469
+ });
625
1470
  }
626
- /** Copy an entire project fixture from `fixturesRoot` into the working directory. */
627
- project(name) {
628
- this.projectName = name;
629
- return this;
1471
+ /** Accessor for parsing stdout as JSON and asserting against JSON fixtures. */
1472
+ get json() {
1473
+ return new JsonAccessor(this.commandOutput.stdout, this.testDir, this.transform, this.captures);
630
1474
  }
631
- /** Register an MSW mock definition file from `mock/` to intercept HTTP calls. */
632
- mock(file) {
633
- this.mocks.push({ file });
634
- return this;
1475
+ /** Accessor for the temporary working directory the command ran in. */
1476
+ get filesystem() {
1477
+ if (!this.workDir) throw new Error("CliResult.filesystem requires a working directory");
1478
+ return new FilesystemAccessor(this.workDir, this.testDir, this.captures);
635
1479
  }
636
1480
  /**
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`.
1481
+ * Look up a container the command spawned during this run by the value of
1482
+ * its name-label (as declared in the `docker.nameLabel` of the
1483
+ * `specification.cli()` options).
1484
+ * First access triggers a one-shot `docker ps` + `docker inspect`
1485
+ * query and caches the result for the rest of the result's lifetime.
1486
+ * Tests that don't call this never touch Docker.
642
1487
  *
643
- * @example
644
- * spec("...").env({ HOME: "$WORKDIR", TZ: "UTC" }).exec("status").run();
1488
+ * Returns an accessor with `.exists === false` when no container
1489
+ * carries that name callers can still assert absence without a try.
645
1490
  */
646
- env(env) {
647
- this.commandEnv = {
648
- ...this.commandEnv,
649
- ...env
650
- };
651
- return this;
1491
+ container(name) {
1492
+ this.ensureDockerAware("container");
1493
+ this.loadContainers();
1494
+ const captured = this.containersCache.get(name);
1495
+ if (!captured) return new ContainerAccessor(null, null, this.testDir, this.transform);
1496
+ return new ContainerAccessor(captured.id, captured.inspect, this.testDir, this.transform);
1497
+ }
1498
+ /** All captured container IDs. Triggers the same lazy query as `container()`. */
1499
+ get containerIds() {
1500
+ this.ensureDockerAware("containerIds");
1501
+ this.loadContainers();
1502
+ return [...this.containersCache.values()].map((c) => c.id);
1503
+ }
1504
+ async [Symbol.asyncDispose]() {
1505
+ if (!this.dockerConfig || !this.testRunId) return;
1506
+ removeContainers(this.containersCache !== null ? [...this.containersCache.values()].map((c) => c.id) : findContainersByLabel(this.dockerConfig.testRunLabel, this.testRunId));
1507
+ }
1508
+ ensureDockerAware(member) {
1509
+ if (!this.dockerConfig || !this.testRunId) throw new Error(`CliResult.${member}: runner was not configured with a docker option. Pass \`docker: { envVar, nameLabel, testRunLabel }\` in the specification.cli() options.`);
1510
+ }
1511
+ loadContainers() {
1512
+ if (this.containersCache !== null) return;
1513
+ const ids = findContainersByLabel(this.dockerConfig.testRunLabel, this.testRunId);
1514
+ const map = /* @__PURE__ */ new Map();
1515
+ for (const id of ids) {
1516
+ let inspect;
1517
+ try {
1518
+ inspect = inspectContainer(id);
1519
+ } catch {
1520
+ continue;
1521
+ }
1522
+ const nameValue = (inspect?.Config?.Labels ?? inspect?.config?.labels ?? {})[this.dockerConfig.nameLabel];
1523
+ const key = typeof nameValue === "string" && nameValue.length > 0 ? nameValue : id;
1524
+ map.set(key, {
1525
+ id,
1526
+ inspect
1527
+ });
1528
+ }
1529
+ this.containersCache = map;
652
1530
  }
1531
+ };
1532
+ //#endregion
1533
+ //#region src/integrations/docker/container-accessor.ts
1534
+ const EXEC_TIMEOUT = 1e4;
1535
+ function readInspectState(inspect) {
1536
+ const state = inspect?.State ?? inspect?.state;
1537
+ if (!state) return {
1538
+ running: false,
1539
+ status: "unknown"
1540
+ };
1541
+ return {
1542
+ running: Boolean(state.Running ?? state.running ?? false),
1543
+ status: String(state.Status ?? state.status ?? "unknown")
1544
+ };
1545
+ }
1546
+ /**
1547
+ * Assertion accessor for a single Docker container captured by the docker()
1548
+ * spec mode. Mirrors the shape of {@link CliResult} so tests use the same
1549
+ * vocabulary (`stdout.toContain`, `file(...).content`, etc.) regardless
1550
+ * of where output came from.
1551
+ *
1552
+ * Sync state (`exists`, `running`, `status`) is derived from the `docker
1553
+ * inspect` payload captured at result-construction time. Logs are fetched
1554
+ * lazily on first access to `stdout`/`stderr`.
1555
+ */
1556
+ var ContainerAccessor = class {
1557
+ cachedLogs = null;
1558
+ containerId;
1559
+ exists;
1560
+ inspectData;
1561
+ running;
1562
+ status;
1563
+ testDir;
1564
+ transform;
653
1565
  /**
654
- * Set HTTP headers for the request. Multiple calls merge.
655
- *
656
- * @example
657
- * spec("french").headers({ 'Accept-Language': 'fr' }).get("/articles").run();
1566
+ * Underlying Docker container ID, or `null` if no container was captured
1567
+ * for this name. Useful when a follow-up CLI command needs to reference
1568
+ * the container by id (e.g. `spwn world inspect <id>`). Prefer the other
1569
+ * accessors for common reads — pulling the raw id is the escape hatch.
658
1570
  */
659
- headers(headers) {
660
- this.requestHeaders = {
661
- ...this.requestHeaders,
662
- ...headers
663
- };
664
- return this;
1571
+ get id() {
1572
+ return this.containerId;
665
1573
  }
666
- /**
667
- * Intercept an outgoing HTTP request and return a controlled response.
668
- * Uses MSW under the hood. Intercepts are queued — multiple calls with the
669
- * same trigger fire sequentially (first match consumed first).
670
- *
671
- * @param trigger - What to match (use openai.agent(), http.get(), etc.)
672
- * @param response - What to return: an InterceptResponse object, or a filename
673
- * from `intercepts/` (JSON loaded and auto-wrapped by the trigger).
674
- *
675
- * @example
676
- * // With inline response
677
- * .intercept(openai.agent({...}), openai.reply({ categories: ['TECH'] }))
678
- *
679
- * // With JSON fixture file (loaded from intercepts/ directory)
680
- * .intercept(openai.agent({...}), 'ingest-tech.json')
681
- * .intercept(http.get(url), 'world-news-tech.json')
682
- */
683
- intercept(trigger, response) {
684
- if (typeof response === "string") {
685
- const slashIndex = response.indexOf("/");
686
- if (slashIndex === -1) throw new Error(`.intercept(): file path must be 'adapter/filename.json' (e.g. 'openai/ingest-tech.json'), got '${response}'`);
687
- const adapterName = response.slice(0, slashIndex);
688
- if (adapterName !== trigger.adapter) throw new Error(`.intercept(): adapter mismatch - trigger uses '${trigger.adapter}' but file path starts with '${adapterName}/'`);
689
- const filePath = resolve(this.testDir, "intercepts", response);
690
- const data = JSON.parse(readFileSync(filePath, "utf8"));
691
- const resolved = trigger.wrap(data);
692
- this.intercepts.push({
693
- trigger,
694
- response: resolved
695
- });
696
- } else this.intercepts.push({
697
- trigger,
698
- response
699
- });
700
- return this;
1574
+ constructor(containerId, inspectData, testDir, transform) {
1575
+ this.containerId = containerId;
1576
+ this.inspectData = inspectData;
1577
+ this.testDir = testDir;
1578
+ this.transform = transform;
1579
+ this.exists = containerId !== null;
1580
+ if (this.exists) {
1581
+ const state = readInspectState(inspectData);
1582
+ this.running = state.running;
1583
+ this.status = state.status;
1584
+ } else {
1585
+ this.running = false;
1586
+ this.status = "absent";
1587
+ }
701
1588
  }
702
1589
  /**
703
- * Send a GET request to the server adapter.
704
- *
705
- * @example
706
- * spec("list items").get("/api/items").run();
1590
+ * Raw `docker inspect` object for this container. Throws if the container
1591
+ * was not captured (i.e. `.exists === false`).
707
1592
  */
708
- get(path) {
709
- this.request = {
710
- method: "GET",
711
- path
712
- };
713
- return this;
1593
+ get inspect() {
1594
+ this.requireExists("inspect");
1595
+ return new JsonAccessor(JSON.stringify(this.inspectData), this.testDir, this.transform);
1596
+ }
1597
+ /** Captured logs (stdout+stderr combined for v1). */
1598
+ get stdout() {
1599
+ this.requireExists("stdout");
1600
+ return new TextAccessor(this.loadLogs(), "stdout", this.testDir, { transform: this.transform });
1601
+ }
1602
+ /** Captured logs (stdout+stderr combined for v1). */
1603
+ get stderr() {
1604
+ this.requireExists("stderr");
1605
+ return new TextAccessor(this.loadLogs(), "stderr", this.testDir, { transform: this.transform });
714
1606
  }
715
1607
  /**
716
- * Send a POST request to the server adapter.
717
- *
718
- * @param bodyFile - Optional JSON file from `requests/` to use as the request body.
719
- * @example
720
- * spec("create item").post("/api/items", "new-item.json").run();
1608
+ * Read a file from inside the container via `docker exec cat`. The
1609
+ * returned object satisfies the same {@link FileAccessor} shape used by
1610
+ * the host filesystem accessor, so tests do not need to learn a new API.
721
1611
  */
722
- post(path, bodyFile) {
723
- this.request = {
724
- bodyFile,
725
- method: "POST",
726
- path
727
- };
728
- return this;
729
- }
730
- /** Send a PUT request to the server adapter. */
731
- put(path, bodyFile) {
732
- this.request = {
733
- bodyFile,
734
- method: "PUT",
1612
+ file(path) {
1613
+ this.requireExists("file");
1614
+ const id = this.containerId;
1615
+ const exists = this.containerExec(id, [
1616
+ "test",
1617
+ "-e",
735
1618
  path
1619
+ ]).exitCode === 0;
1620
+ const { testDir } = this;
1621
+ const readContent = () => {
1622
+ if (!exists) throw new Error(`File not found in container: ${path}`);
1623
+ return execSync(`docker exec ${id} cat ${JSON.stringify(path)}`, {
1624
+ encoding: "utf8",
1625
+ timeout: EXEC_TIMEOUT
1626
+ });
736
1627
  };
737
- return this;
738
- }
739
- /** Send a DELETE request to the server adapter. */
740
- delete(path) {
741
- this.request = {
742
- method: "DELETE",
743
- path
1628
+ return {
1629
+ get content() {
1630
+ return readContent();
1631
+ },
1632
+ exists,
1633
+ grep(pattern) {
1634
+ return new TextAccessor(readContent(), path, testDir).grep(pattern);
1635
+ }
744
1636
  };
745
- return this;
746
1637
  }
747
1638
  /**
748
- * Execute a CLI command (or a sequence of commands) in an isolated working directory.
749
- * When an array is passed, commands run sequentially and stop on the first non-zero exit code.
750
- *
751
- * @example
752
- * spec("init project").exec("init --name demo").run();
753
- * spec("multi-step").exec(["init", "build"]).run();
1639
+ * Run a shell command inside the container and get back the same
1640
+ * {@link CliResult} used for host-side executions.
754
1641
  */
755
- exec(args) {
756
- this.commandArgs = args;
757
- return this;
758
- }
759
- /** Spawn a long-running CLI process with custom spawn options (e.g. timeout, signal). */
760
- spawn(args, options) {
761
- this.spawnConfig = {
762
- args,
763
- options
764
- };
765
- return this;
1642
+ async exec(cmd) {
1643
+ this.requireExists("exec");
1644
+ return new CliResult({
1645
+ commandOutput: this.containerExec(this.containerId, [
1646
+ "sh",
1647
+ "-c",
1648
+ cmd
1649
+ ]),
1650
+ config: {},
1651
+ testDir: this.testDir,
1652
+ transform: this.transform,
1653
+ workDir: this.testDir
1654
+ });
766
1655
  }
767
- /**
768
- * Execute a named job registered via the app() factory.
769
- *
770
- * @param name - The job name to trigger (must match a registered JobHandle.name).
771
- *
772
- * @example
773
- * spec('pipeline').intercept(openai.chat(), openai.response({...})).job('report-refresh').run();
774
- */
775
- job(name) {
776
- this.jobName = name;
777
- return this;
1656
+ requireExists(member) {
1657
+ if (!this.exists) throw new Error(`ContainerAccessor.${member}: container does not exist (check .exists first)`);
778
1658
  }
779
- /**
780
- * Execute the specification: run seeds, copy fixtures, then perform the
781
- * configured action (HTTP or CLI).
782
- *
783
- * @returns The result object used for assertions.
784
- * @example
785
- * const result = await spec("test").exec("status").run();
786
- * expect(result.exitCode).toBe(0);
787
- */
788
- async run() {
789
- const hasHttpAction = this.request !== null;
790
- const hasCliAction = this.commandArgs !== null || this.spawnConfig !== null;
791
- const hasJobAction = this.jobName !== null;
792
- const actionCount = [
793
- hasHttpAction,
794
- hasCliAction,
795
- hasJobAction
796
- ].filter(Boolean).length;
797
- if (actionCount === 0) throw new Error(`Specification "${this.label}": no action defined. Call .get(), .post(), .exec(), .job(), etc. before .run()`);
798
- if (actionCount > 1) throw new Error(`Specification "${this.label}": cannot mix action types (.get/.post, .exec/.spawn, .job)`);
799
- let workDir = null;
800
- if (hasCliAction) workDir = this.prepareWorkDir();
801
- if (this.config.databases) for (const db of this.config.databases.values()) await db.reset();
802
- else if (this.config.database) await this.config.database.reset();
803
- for (const entry of this.seeds) {
804
- let db;
805
- if (entry.service && this.config.databases) {
806
- db = this.config.databases.get(entry.service);
807
- if (!db) throw new Error(`seed() targets database "${entry.service}" but it was not found. Available: ${[...this.config.databases.keys()].join(", ")}`);
808
- } else db = this.config.database;
809
- if (!db) throw new Error("seed() requires a database adapter");
810
- const sql = readFileSync(resolve(this.testDir, "seeds", entry.file), "utf8");
811
- await db.seed(sql);
812
- }
813
- if (this.fixtures.length > 0 && workDir) for (const entry of this.fixtures) cpSync(resolve(this.testDir, "fixtures", entry.file), resolve(workDir, entry.file), { recursive: true });
814
- let cleanupIntercepts = null;
815
- if (this.intercepts.length > 0) {
816
- const { registerIntercepts } = await import("./intercept2.js");
817
- cleanupIntercepts = await registerIntercepts(this.intercepts);
818
- }
1659
+ loadLogs() {
1660
+ if (this.cachedLogs !== null) return this.cachedLogs;
819
1661
  try {
820
- if (hasHttpAction) return await this.runHttpAction();
821
- if (hasJobAction) return await this.runJobAction();
822
- return await this.runCliAction(workDir);
823
- } finally {
824
- if (cleanupIntercepts) cleanupIntercepts();
825
- }
826
- }
827
- resolveEnv(workDir) {
828
- const keys = Object.keys(this.commandEnv);
829
- if (keys.length === 0) return;
830
- const resolved = {};
831
- for (const key of keys) {
832
- const value = this.commandEnv[key];
833
- resolved[key] = typeof value === "string" ? value.replace(/\$WORKDIR/g, workDir) : value;
834
- }
835
- return resolved;
836
- }
837
- prepareWorkDir() {
838
- const tempDir = mkdtempSync(resolve(tmpdir(), "spec-cli-"));
839
- if (this.projectName && this.config.fixturesRoot) {
840
- const projectDir = resolve(this.config.fixturesRoot, this.projectName);
841
- if (!existsSync(projectDir)) throw new Error(`project("${this.projectName}"): fixture project not found at ${projectDir}`);
842
- cpSync(projectDir, tempDir, { recursive: true });
843
- }
844
- return tempDir;
845
- }
846
- async runHttpAction() {
847
- if (!this.config.server) throw new Error("HTTP actions require a server adapter (use integration() or e2e())");
848
- let body;
849
- if (this.request.bodyFile) body = JSON.parse(readFileSync(resolve(this.testDir, "requests", this.request.bodyFile), "utf8"));
850
- const headers = Object.keys(this.requestHeaders).length > 0 ? this.requestHeaders : void 0;
851
- const response = await this.config.server.request(this.request.method, this.request.path, body, headers);
852
- return new SpecificationResult({
853
- config: this.config,
854
- requestInfo: {
855
- body,
856
- method: this.request.method,
857
- path: this.request.path
858
- },
859
- response,
860
- testDir: this.testDir
861
- });
862
- }
863
- async runJobAction() {
864
- if (!this.config.jobs?.length) throw new Error("Job actions require jobs registered via app(() => ({ server, jobs }))");
865
- const job = this.config.jobs.find((j) => j.name === this.jobName);
866
- if (!job) {
867
- const available = this.config.jobs.map((j) => j.name).join(", ");
868
- throw new Error(`job("${this.jobName}"): not found. Available: ${available}`);
1662
+ const out = execSync(`docker logs ${this.containerId}`, {
1663
+ encoding: "utf8",
1664
+ stdio: [
1665
+ "ignore",
1666
+ "pipe",
1667
+ "pipe"
1668
+ ],
1669
+ timeout: EXEC_TIMEOUT
1670
+ });
1671
+ this.cachedLogs = out;
1672
+ } catch (error) {
1673
+ this.cachedLogs = typeof error?.stdout === "string" ? error.stdout : String(error?.stderr ?? "");
869
1674
  }
870
- await job.execute();
871
- return new SpecificationResult({
872
- config: this.config,
873
- testDir: this.testDir
874
- });
1675
+ return this.cachedLogs ?? "";
875
1676
  }
876
- async runCliAction(workDir) {
877
- if (!this.config.command) throw new Error("CLI actions require a command adapter (use cli())");
878
- const env = this.resolveEnv(workDir);
879
- let commandResult;
880
- if (this.spawnConfig) commandResult = await this.config.command.spawn(this.spawnConfig.args, workDir, this.spawnConfig.options, env);
881
- else if (Array.isArray(this.commandArgs)) {
882
- commandResult = {
1677
+ containerExec(id, argv) {
1678
+ try {
1679
+ return {
883
1680
  exitCode: 0,
884
1681
  stderr: "",
885
- stdout: ""
1682
+ stdout: execSync(`docker exec ${id} ${argv.map((a) => JSON.stringify(a)).join(" ")}`, {
1683
+ encoding: "utf8",
1684
+ stdio: [
1685
+ "ignore",
1686
+ "pipe",
1687
+ "pipe"
1688
+ ],
1689
+ timeout: EXEC_TIMEOUT
1690
+ })
886
1691
  };
887
- for (const args of this.commandArgs) {
888
- commandResult = await this.config.command.exec(args, workDir, env);
889
- if (commandResult.exitCode !== 0) break;
890
- }
891
- } else commandResult = await this.config.command.exec(this.commandArgs, workDir, env);
892
- return new SpecificationResult({
893
- commandResult,
894
- config: this.config,
895
- testDir: this.testDir,
896
- workDir
897
- });
1692
+ } catch (error) {
1693
+ return {
1694
+ exitCode: typeof error?.status === "number" ? error.status : 1,
1695
+ stderr: typeof error?.stderr === "string" ? error.stderr : String(error?.message ?? ""),
1696
+ stdout: typeof error?.stdout === "string" ? error.stdout : ""
1697
+ };
1698
+ }
898
1699
  }
899
1700
  };
900
- function getCallerDir() {
901
- const stack = (/* @__PURE__ */ new Error("caller detection")).stack;
902
- if (!stack) throw new Error("Cannot detect caller directory: no stack trace");
903
- const lines = stack.split("\n");
904
- for (const line of lines) {
905
- const match = line.match(/at\s+(?:.*?\()?(?:file:\/\/)?([^:)]+):\d+:\d+/);
906
- if (!match) continue;
907
- const filePath = match[1];
908
- if (filePath.includes("node_modules")) continue;
909
- if (filePath.includes("/src/builder/") || filePath.includes("/src/spec/")) continue;
910
- return resolve(filePath, "..");
911
- }
912
- throw new Error("Cannot detect caller directory from stack trace");
1701
+ //#endregion
1702
+ //#region src/vitest/update.ts
1703
+ /**
1704
+ * Snapshot update-mode detection. `TEST_UPDATE=1` (framework convention, see
1705
+ * CONVENTIONS E1) or vitest's `-u` / `--update` flag turn fixture mismatches
1706
+ * into fixture rewrites.
1707
+ */
1708
+ function shouldUpdateSnapshots() {
1709
+ if (process.env.TEST_UPDATE === "1") return true;
1710
+ return process.argv.includes("-u") || process.argv.includes("--update");
913
1711
  }
1712
+ /** Standard hint appended to missing-fixture errors. */
1713
+ const UPDATE_HINT = "Run with TEST_UPDATE=1 (or vitest -u) to create it.";
1714
+ //#endregion
1715
+ //#region src/vitest/matchers.ts
914
1716
  /**
915
- * Create a {@link SpecificationRunner} bound to the given adapter configuration.
916
- * The test file directory is auto-detected from the call stack.
1717
+ * Vitest custom matchers for `@jterrazz/test` accessors.
1718
+ *
1719
+ * Auto-registered (idempotently) on the first `specification.api()` /
1720
+ * `specification.jobs()` / `specification.cli()` call via a dynamic
1721
+ * `import('vitest')` — the library never hard-imports vitest at module load.
1722
+ *
1723
+ * All assertions go through `expect()` (CONVENTIONS D1). Only matchers that
1724
+ * do IO are async (D2): `table` (SQL query), `filesystem`/`directory`
1725
+ * (disk walk), `container` (docker). Everything else is synchronous.
917
1726
  */
918
- function createSpecificationRunner(config) {
919
- return (label) => {
920
- return new SpecificationBuilder(config, getCallerDir(), label);
1727
+ const PASS = (label) => ({
1728
+ message: () => label,
1729
+ pass: true
1730
+ });
1731
+ const FAIL = (message) => ({
1732
+ message: () => message,
1733
+ pass: false
1734
+ });
1735
+ function requireExtension(name, subject) {
1736
+ if (!/\.[A-Za-z0-9]+$/.test(name)) throw new Error(`toMatch("${name}"): the extension is part of the name and is required for ${subject} subjects (e.g. "help.txt").`);
1737
+ }
1738
+ function formatJson(value) {
1739
+ return `${JSON.stringify(value, null, 4)}\n`;
1740
+ }
1741
+ function matchStreamFile(accessor, name, frozen) {
1742
+ requireExtension(name, "stream");
1743
+ const filePath = resolve(accessor.testDir, "expected", name);
1744
+ const actual = accessor.comparableText;
1745
+ if (shouldUpdateSnapshots() && !frozen) {
1746
+ const merged = mergeTextPreservingPlaceholders(existsSync(filePath) ? readFileSync(filePath, "utf8") : null, actual, accessor.captures);
1747
+ mkdirSync(dirname(filePath), { recursive: true });
1748
+ writeFileSync(filePath, merged);
1749
+ return PASS(`updated expected/${name}`);
1750
+ }
1751
+ if (!existsSync(filePath)) return FAIL(`${accessor.streamName} fixture "${name}" does not exist at ${filePath}.\n${UPDATE_HINT}`);
1752
+ const expected = readFileSync(filePath, "utf8");
1753
+ if (textEquals(expected, actual, accessor.captures)) return PASS(`expected ${accessor.streamName} not to match expected/${name}`);
1754
+ return FAIL(formatStdoutDiff(name, expected, actual));
1755
+ }
1756
+ function matchJsonFile(accessor, name, frozen) {
1757
+ requireExtension(name, "json");
1758
+ const filePath = resolve(accessor.testDir, "expected", name);
1759
+ const actual = accessor.value;
1760
+ if (shouldUpdateSnapshots() && !frozen) {
1761
+ const merged = existsSync(filePath) ? mergePreservingPlaceholders(JSON.parse(readFileSync(filePath, "utf8")), actual, accessor.captures.workdir) : mergePreservingPlaceholders(null, actual, accessor.captures.workdir);
1762
+ mkdirSync(dirname(filePath), { recursive: true });
1763
+ writeFileSync(filePath, formatJson(merged));
1764
+ return PASS(`updated expected/${name}`);
1765
+ }
1766
+ if (!existsSync(filePath)) return FAIL(`JSON fixture "${name}" does not exist at ${filePath}.\n${UPDATE_HINT}`);
1767
+ const expected = JSON.parse(readFileSync(filePath, "utf8"));
1768
+ if (structuralEquals(expected, actual, accessor.captures)) return PASS(`expected JSON not to match expected/${name}`);
1769
+ return FAIL(formatResponseDiff(name, renderExpected(expected), actual));
1770
+ }
1771
+ /**
1772
+ * Build the updated `expected/*.http` fixture content from the previous
1773
+ * fixture and the actual response (CONVENTIONS D5). Headers are the
1774
+ * INTERSECTION with the actual response: placeholders still matching are
1775
+ * preserved, stale values are replaced, headers absent from the actual
1776
+ * response are dropped — so a freshly updated fixture passes the next run.
1777
+ *
1778
+ * @internal Exported for unit tests.
1779
+ */
1780
+ function buildUpdatedResponse(previous, actual, workdir) {
1781
+ const scope = new CaptureScope();
1782
+ const status = previous && structuralEquals(previous.status, String(actual.status), scope) ? previous.status : String(actual.status);
1783
+ const headers = {};
1784
+ if (previous) for (const [key, value] of Object.entries(previous.headers)) {
1785
+ const actualValue = actual.headers[key.toLowerCase()];
1786
+ if (actualValue === void 0) continue;
1787
+ headers[key] = structuralEquals(value, actualValue, scope) ? value : actualValue;
1788
+ }
1789
+ else if (actual.headers["content-type"]) headers["content-type"] = actual.headers["content-type"];
1790
+ const hasBody = actual.body !== null && actual.body !== void 0;
1791
+ return {
1792
+ body: previous?.hasBody && hasBody ? mergePreservingPlaceholders(previous.body, actual.body, workdir) : mergePreservingPlaceholders(null, actual.body, workdir),
1793
+ hasBody,
1794
+ headers,
1795
+ status
921
1796
  };
922
1797
  }
923
- //#endregion
924
- //#region src/docker/docker-adapter.ts
925
1798
  /**
926
- * Implements {@link DockerContainerPort} by shelling out to the Docker CLI.
927
- * Each instance is bound to a single container ID.
1799
+ * Compare a parsed `expected/*.http` fixture against an actual response.
1800
+ * Returns the failure message, or null when everything matches.
1801
+ *
1802
+ * @internal Exported for unit tests.
928
1803
  */
929
- var DockerAdapter = class {
930
- containerId;
931
- constructor(containerId) {
932
- this.containerId = containerId;
1804
+ function compareResponse(name, expected, actual, scope) {
1805
+ if (!(/^\d+$/.test(expected.status) ? Number(expected.status) === actual.status : structuralEquals(expected.status, String(actual.status), scope))) return `Response status mismatch (${name})\n expected: ${expected.status}\n received: ${actual.status}`;
1806
+ for (const [key, value] of Object.entries(expected.headers)) {
1807
+ const actualValue = actual.headers[key.toLowerCase()];
1808
+ if (actualValue === void 0 || !structuralEquals(value, actualValue, scope)) return `Response header mismatch (${name})\n header: ${key}\n expected: ${value}\n received: ${actualValue ?? "(absent)"}`;
933
1809
  }
934
- async exec(cmd) {
935
- return execSync(`docker exec ${this.containerId} ${cmd.map((c) => `'${c}'`).join(" ")}`, {
936
- encoding: "utf8",
937
- timeout: 1e4
938
- }).trim();
939
- }
940
- async file(path) {
941
- try {
942
- return {
943
- exists: true,
944
- content: await this.exec(["cat", path])
945
- };
946
- } catch {
947
- return {
948
- exists: false,
949
- content: ""
950
- };
1810
+ if (expected.hasBody && !structuralEquals(expected.body, actual.body, scope)) return formatResponseDiff(name, renderExpected(expected.body), actual.body);
1811
+ return null;
1812
+ }
1813
+ function matchResponseFile(accessor, name, frozen) {
1814
+ requireExtension(name, "response");
1815
+ const filePath = resolve(accessor.testDir, "expected", name);
1816
+ if (shouldUpdateSnapshots() && !frozen) {
1817
+ const updated = buildUpdatedResponse(existsSync(filePath) ? parseResponseFile(readFileSync(filePath, "utf8"), `expected/${name}`) : null, accessor, accessor.captures.workdir);
1818
+ mkdirSync(dirname(filePath), { recursive: true });
1819
+ writeFileSync(filePath, serializeResponseFile(updated));
1820
+ return PASS(`updated expected/${name}`);
1821
+ }
1822
+ if (!existsSync(filePath)) return FAIL(`Response fixture "${name}" does not exist at ${filePath}.\n${UPDATE_HINT}`);
1823
+ const failure = compareResponse(name, parseResponseFile(readFileSync(filePath, "utf8"), `expected/${name}`), accessor, accessor.captures);
1824
+ return failure === null ? PASS(`expected response not to match expected/${name}`) : FAIL(failure);
1825
+ }
1826
+ async function matchTreeFile(actualRoot, testDir, name, scope, frozen) {
1827
+ const fixtureDir = resolve(testDir, "expected", name);
1828
+ if (shouldUpdateSnapshots() && !frozen) {
1829
+ const previousContents = /* @__PURE__ */ new Map();
1830
+ if (existsSync(fixtureDir)) for (const file of await walkDirectory(fixtureDir)) previousContents.set(file, readFileSync(resolve(fixtureDir, file), "utf8"));
1831
+ rmSync(fixtureDir, {
1832
+ force: true,
1833
+ recursive: true
1834
+ });
1835
+ mkdirSync(fixtureDir, { recursive: true });
1836
+ cpSync(actualRoot, fixtureDir, { recursive: true });
1837
+ for (const file of await walkDirectory(fixtureDir)) {
1838
+ const previous = previousContents.get(file) ?? null;
1839
+ const actual = readFileSync(resolve(fixtureDir, file), "utf8");
1840
+ const merged = mergeTextPreservingPlaceholders(previous, actual, scope);
1841
+ if (merged !== actual) writeFileSync(resolve(fixtureDir, file), merged);
951
1842
  }
1843
+ return PASS(`updated expected/${name}/`);
952
1844
  }
953
- async isRunning() {
954
- try {
955
- return execSync(`docker inspect --format='{{.State.Running}}' ${this.containerId}`, {
956
- encoding: "utf8",
957
- timeout: 5e3
958
- }).trim() === "true";
959
- } catch {
960
- return false;
961
- }
1845
+ if (!existsSync(fixtureDir)) return FAIL(`Directory fixture "${name}" does not exist at ${fixtureDir}.\n${UPDATE_HINT}`);
1846
+ const diff = await diffDirectories(fixtureDir, actualRoot, { scope });
1847
+ if (diff.added.length === 0 && diff.removed.length === 0 && diff.changed.length === 0) return PASS(`expected directory not to match expected/${name}/`);
1848
+ return FAIL(formatDirectoryDiff(name, diff, "Run with TEST_UPDATE=1 to update the fixture."));
1849
+ }
1850
+ /**
1851
+ * Guard the accessor `toMatch` subjects: their argument is a fixture NAME, never
1852
+ * a regex or other value. A `RegExp` (the natural instinct, since vitest-native
1853
+ * `toMatch` takes one) must fail loudly with the escape hatch, not silently trip
1854
+ * the extension check or be coerced to `"/re/"`.
1855
+ */
1856
+ function requireFixtureName(name, subjectKind) {
1857
+ if (typeof name !== "string") {
1858
+ const got = name instanceof RegExp ? "a regular expression" : `a ${typeof name}`;
1859
+ throw new TypeError(`toMatch on accessors takes a fixture name (extension included) — the ${subjectKind} subject received ${got}. For a regex, assert on the raw text instead: use expect(x.text).toMatch(/re/) for regex matching.`);
962
1860
  }
963
- async logs(tail) {
964
- return execSync(`docker logs ${tail ? `--tail ${tail}` : ""} ${this.containerId}`, {
965
- encoding: "utf8",
966
- timeout: 1e4
967
- });
1861
+ }
1862
+ function toMatch(received, expected, options) {
1863
+ const frozen = options?.frozen === true;
1864
+ if (received instanceof TextAccessor) {
1865
+ requireFixtureName(expected, "stream");
1866
+ return matchStreamFile(received, expected, frozen);
1867
+ }
1868
+ if (received instanceof JsonAccessor) {
1869
+ requireFixtureName(expected, "json");
1870
+ return matchJsonFile(received, expected, frozen);
1871
+ }
1872
+ if (received instanceof ResponseAccessor) {
1873
+ requireFixtureName(expected, "response");
1874
+ return matchResponseFile(received, expected, frozen);
1875
+ }
1876
+ if (received instanceof FilesystemAccessor) {
1877
+ requireFixtureName(expected, "filesystem");
1878
+ return matchTreeFile(received.cwd, received.testDir, expected, received.captures, frozen);
1879
+ }
1880
+ if (received instanceof DirectoryAccessor) {
1881
+ requireFixtureName(expected, "directory");
1882
+ return matchTreeFile(received.root, received.testDir, expected, received.captures, frozen);
1883
+ }
1884
+ if (typeof received === "string") {
1885
+ const pass = expected instanceof RegExp ? expected.test(received) : received.includes(String(expected));
1886
+ return {
1887
+ message: () => `expected ${JSON.stringify(received)} ${pass ? "not " : ""}to match ${String(expected)}`,
1888
+ pass
1889
+ };
968
1890
  }
969
- async inspect() {
970
- const raw = execSync(`docker inspect ${this.containerId}`, {
971
- encoding: "utf8",
972
- timeout: 5e3
973
- });
974
- const data = JSON.parse(raw)[0];
1891
+ throw new TypeError("toMatch: unsupported subject — expected a stream, json, response, filesystem, or directory accessor, or a string.");
1892
+ }
1893
+ function toContain(received, expected) {
1894
+ if (received instanceof TextAccessor) {
1895
+ const actual = received.comparableText;
1896
+ const text = String(expected);
1897
+ const pass = actual.includes(text);
975
1898
  return {
976
- id: data.Id,
977
- name: data.Name,
978
- state: {
979
- running: data.State.Running,
980
- exitCode: data.State.ExitCode,
981
- status: data.State.Status
982
- },
983
- config: {
984
- image: data.Config.Image,
985
- env: data.Config.Env || []
986
- },
987
- hostConfig: {
988
- memory: data.HostConfig.Memory || 0,
989
- cpuQuota: data.HostConfig.CpuQuota || 0,
990
- networkMode: data.HostConfig.NetworkMode || "",
991
- mounts: (data.Mounts || []).map((m) => ({
992
- source: m.Source,
993
- destination: m.Destination,
994
- type: m.Type
995
- }))
996
- },
997
- networkSettings: { networks: Object.fromEntries(Object.entries(data.NetworkSettings.Networks || {}).map(([name, net]) => [name, {
998
- gateway: net.Gateway,
999
- ipAddress: net.IPAddress
1000
- }])) }
1899
+ message: () => pass ? `expected ${received.streamName} not to contain ${JSON.stringify(text)}` : `${received.streamName} does not contain expected substring.\n expected to contain: ${JSON.stringify(text)}\n actual: ${JSON.stringify(actual.length > 500 ? `${actual.slice(0, 500)}…` : actual)}`,
1900
+ pass
1001
1901
  };
1002
1902
  }
1003
- async exists(path) {
1004
- try {
1005
- await this.exec([
1006
- "test",
1007
- "-e",
1008
- path
1009
- ]);
1010
- return true;
1011
- } catch {
1012
- return false;
1013
- }
1903
+ if (typeof received === "string") {
1904
+ const pass = received.includes(String(expected));
1905
+ return {
1906
+ message: () => `expected ${JSON.stringify(received)} ${pass ? "not " : ""}to contain ${JSON.stringify(expected)}`,
1907
+ pass
1908
+ };
1909
+ }
1910
+ if (received != null && typeof received[Symbol.iterator] === "function") {
1911
+ const pass = [...received].includes(expected);
1912
+ return {
1913
+ message: () => `expected iterable ${pass ? "not " : ""}to contain ${JSON.stringify(expected)}`,
1914
+ pass
1915
+ };
1916
+ }
1917
+ throw new TypeError(`toContain: unsupported subject of type ${typeof received} — expected a stream accessor, string, or iterable.`);
1918
+ }
1919
+ async function toMatchRows(received, expected) {
1920
+ if (!(received instanceof TableAccessor)) throw new TypeError("toMatchRows: unsupported subject — expected result.table(...).");
1921
+ const actual = await received.query(expected.columns);
1922
+ const pass = actual.length === expected.rows.length && expected.rows.every((row, i) => row.length === actual[i].length && row.every((cell, j) => structuralEquals(cell, actual[i][j], received.captures)));
1923
+ return {
1924
+ message: () => pass ? `expected table "${received.name}" not to match the given rows` : formatTableDiff(received.name, expected.columns, expected.rows.map((row) => row.map((cell) => renderExpected(cell))), actual),
1925
+ pass
1926
+ };
1927
+ }
1928
+ async function toBeEmpty(received) {
1929
+ if (!(received instanceof TableAccessor)) throw new TypeError("toBeEmpty: unsupported subject — expected result.table(...).");
1930
+ const rows = await received.query(["*"]);
1931
+ const pass = rows.length === 0;
1932
+ return {
1933
+ message: () => pass ? `expected table "${received.name}" not to be empty` : `Expected table "${received.name}" to be empty, but it has ${rows.length} row${rows.length === 1 ? "" : "s"}`,
1934
+ pass
1935
+ };
1936
+ }
1937
+ async function toBeRunning(received) {
1938
+ if (!(received instanceof ContainerAccessor)) throw new TypeError("toBeRunning: unsupported subject — expected a container accessor (result.container(...) or spec.docker(...)).");
1939
+ const pass = received.running;
1940
+ return {
1941
+ message: () => {
1942
+ if (pass) return "expected container not to be running";
1943
+ if (received.exists) return `Expected container to be running, but its status is "${received.status}"`;
1944
+ return "Expected container to be running, but it does not exist";
1945
+ },
1946
+ pass
1947
+ };
1948
+ }
1949
+ const REGISTERED = Symbol.for("@jterrazz/test:matchers-registered");
1950
+ /**
1951
+ * Register the custom matchers with vitest's `expect`. Idempotent — safe to
1952
+ * call from every `specification.*` constructor. A missing vitest peer is
1953
+ * tolerated (the library can be imported outside a vitest run).
1954
+ */
1955
+ async function registerMatchers() {
1956
+ const globals = globalThis;
1957
+ if (globals[REGISTERED]) return;
1958
+ globals[REGISTERED] = true;
1959
+ try {
1960
+ const { expect } = await import("vitest");
1961
+ expect.extend({
1962
+ toBeEmpty,
1963
+ toBeRunning,
1964
+ toContain,
1965
+ toMatch,
1966
+ toMatchRows
1967
+ });
1968
+ } catch {
1969
+ globals[REGISTERED] = false;
1970
+ }
1971
+ }
1972
+ //#endregion
1973
+ //#region src/core/specification/api/result.ts
1974
+ /** Result from an HTTP action (.request(), .get(), .post(), .put(), .delete()). */
1975
+ var HttpResult = class extends BaseResult {
1976
+ responseData;
1977
+ constructor(options) {
1978
+ super(options);
1979
+ this.responseData = options.response;
1980
+ }
1981
+ /** Access the HTTP response (status, headers, body) for assertions. */
1982
+ get response() {
1983
+ return new ResponseAccessor(this.responseData, this.testDir, this.captures);
1984
+ }
1985
+ /** The HTTP response status code. */
1986
+ get status() {
1987
+ return this.responseData.status;
1014
1988
  }
1015
1989
  };
1016
- /** Create a {@link DockerContainerPort} bound to an existing container by ID. */
1017
- function dockerContainer(containerId) {
1018
- return new DockerAdapter(containerId);
1990
+ //#endregion
1991
+ //#region src/core/specification/shared/binding.ts
1992
+ /**
1993
+ * Compose-binding resolution and the case conversions it shares with the
1994
+ * automatic env injection (CONVENTIONS A6 / B6).
1995
+ *
1996
+ * A services-record key is written in the natural TypeScript style
1997
+ * (`analyticsDb`), while a compose service name is kebab-case
1998
+ * (`analytics-db`). These helpers bridge the two vocabularies deterministically
1999
+ * so the record key stays the single source of truth and `composeService`
2000
+ * remains a rare escape hatch.
2001
+ */
2002
+ /**
2003
+ * Convert a record key to its kebab-case form: camelCase boundaries become
2004
+ * `-`, everything is lowercased. `analyticsDb` → `analytics-db`, `db` → `db`.
2005
+ */
2006
+ function toKebabCase(key) {
2007
+ return key.replace(/(?<lower>[a-z0-9])(?<upper>[A-Z])/g, "$<lower>-$<upper>").toLowerCase();
2008
+ }
2009
+ /**
2010
+ * Convert a record key to CONSTANT_CASE for env injection: insert `_` at
2011
+ * camelCase boundaries, map any remaining non-alphanumeric run to `_`, then
2012
+ * uppercase. `analyticsDb` → `ANALYTICS_DB`, `db-main` → `DB_MAIN`, `db` → `DB`.
2013
+ */
2014
+ function toConstantCase(key) {
2015
+ return key.replace(/(?<lower>[a-z0-9])(?<upper>[A-Z])/g, "$<lower>_$<upper>").replace(/[^A-Za-z0-9]/g, "_").toUpperCase();
2016
+ }
2017
+ /**
2018
+ * Resolve the compose service a record key binds to (CONVENTIONS A6).
2019
+ *
2020
+ * Order:
2021
+ * 1. An explicit `composeService` (`explicitComposeName`) always wins — the
2022
+ * escape hatch for non-derivable names.
2023
+ * 2. Otherwise the key binds to the compose service named exactly like it,
2024
+ * else to the kebab-case conversion of the key.
2025
+ * 3. If BOTH the exact key and its kebab-case form name a service in the
2026
+ * compose file, the binding is ambiguous → throw (Lint runtime).
2027
+ * 4. If neither is present, fall back to the key itself (single-word keys and
2028
+ * runs without a compose file stay unchanged).
2029
+ */
2030
+ function resolveComposeBinding(key, explicitComposeName, serviceNames) {
2031
+ if (explicitComposeName !== null) return explicitComposeName;
2032
+ const kebab = toKebabCase(key);
2033
+ const exactExists = serviceNames.includes(key);
2034
+ const kebabExists = kebab !== key && serviceNames.includes(kebab);
2035
+ if (exactExists && kebabExists) throw new Error(`Ambiguous compose binding for service key "${key}": the compose file declares both "${key}" and "${kebab}". Rename one service, or set composeService explicitly on the handle to pick the intended one.`);
2036
+ if (kebabExists) return kebab;
2037
+ return key;
1019
2038
  }
1020
2039
  //#endregion
1021
- //#region src/docker/docker-assertion.ts
2040
+ //#region src/core/specification/shared/fixtures.ts
1022
2041
  /**
1023
- * Fluent assertion builder for Docker containers.
1024
- * Chain async assertions like `toBeRunning()`, `toHaveFile()`, `toHaveMount()`.
2042
+ * Fixture path resolution + copy semantics for the `cli` facet's `.fixture()`.
2043
+ *
2044
+ * A fixture path is one of two shapes:
2045
+ *
2046
+ * - `$FIXTURES/<rest>` — the shared pool at `<specs-root>/fixtures/<rest>`,
2047
+ * where `<specs-root>` is the nearest ancestor directory named `specs`.
2048
+ * - `<path>` (no marker) — feature-local, at `<test-dir>/fixtures/<path>`.
2049
+ *
2050
+ * Any other `$`-prefixed marker is a usage error (listed against the known
2051
+ * markers). Copy semantics mirror rsync's trailing-slash rule (see
2052
+ * {@link copyPlan}).
2053
+ */
2054
+ /**
2055
+ * Markers understood in a `.fixture()` path. Extend here + in
2056
+ * {@link resolveFixtureSource}. Exported so the lint layer (rule
2057
+ * `jterrazz/b2-known-fixture-marker`) validates literals against the same list.
2058
+ */
2059
+ const KNOWN_FIXTURE_MARKERS = ["$FIXTURES"];
2060
+ /**
2061
+ * Walk up from `startDir` to the nearest ancestor directory named `specs`.
2062
+ * Throws with guidance if none is found up to the filesystem root — the
2063
+ * `$FIXTURES` marker is meaningless without a specs root.
1025
2064
  */
1026
- var DockerAssertion = class {
1027
- container;
1028
- constructor(container) {
1029
- this.container = container;
2065
+ function discoverSpecsRoot(startDir) {
2066
+ let dir = startDir;
2067
+ for (;;) {
2068
+ if (basename(dir) === "specs") return dir;
2069
+ const parent = dirname(dir);
2070
+ if (parent === dir) break;
2071
+ dir = parent;
2072
+ }
2073
+ throw new Error(`.fixture(): the $FIXTURES marker resolves to <specs-root>/fixtures, but no directory named "specs" was found walking up from ${startDir}. Move the specification under a specs/ directory, or use a feature-local fixtures/ path (no $FIXTURES marker).`);
2074
+ }
2075
+ /**
2076
+ * Resolve the absolute source path of a fixture, applying marker resolution.
2077
+ * The trailing slash (spread semantics) is preserved by callers via
2078
+ * {@link copyPlan} — it is irrelevant to source resolution.
2079
+ */
2080
+ function resolveFixtureSource(path, testDir) {
2081
+ const clean = path.replace(/\/+$/, "");
2082
+ if (clean === "$FIXTURES" || path.startsWith("$FIXTURES/")) {
2083
+ const rest = clean.slice(9).replace(/^\/+/, "");
2084
+ return resolve(discoverSpecsRoot(testDir), "fixtures", rest);
1030
2085
  }
1031
- /** Assert the container is running */
1032
- async toBeRunning() {
1033
- if (!await this.container.isRunning()) throw new Error("Expected container to be running");
1034
- return this;
2086
+ if (path.startsWith("$")) {
2087
+ const marker = clean.split("/")[0];
2088
+ throw new Error(`.fixture("${path}"): unknown marker "${marker}". Known markers: ${KNOWN_FIXTURE_MARKERS.join(", ")}. A path without a marker is feature-local (resolved under <test-dir>/fixtures/).`);
1035
2089
  }
1036
- /** Assert the container is NOT running / doesn't exist */
1037
- async toNotExist() {
1038
- if (await this.container.isRunning()) throw new Error("Expected container to not exist or not be running");
2090
+ return resolve(testDir, "fixtures", clean);
2091
+ }
2092
+ /**
2093
+ * Compute the source + destination for copying a fixture into the working
2094
+ * directory. Copy semantics follow rsync's trailing-slash rule:
2095
+ *
2096
+ * - `dir/` (trailing slash) — the CONTENTS are spread into the cwd.
2097
+ * - `dir` (no slash, a directory) — the directory is copied as `<cwd>/<basename>`.
2098
+ * - `file` — copied as `<cwd>/<basename>`.
2099
+ *
2100
+ * Chained `.fixture()` calls layer in order: a later fixture overwrites files
2101
+ * written by an earlier one.
2102
+ */
2103
+ function copyPlan(path, testDir, workDir) {
2104
+ const src = resolveFixtureSource(path, testDir);
2105
+ return {
2106
+ dest: /\/+$/.test(path) ? workDir : resolve(workDir, basename(src)),
2107
+ src
2108
+ };
2109
+ }
2110
+ //#endregion
2111
+ //#region src/core/specification/shared/builder.ts
2112
+ /**
2113
+ * Fluent builder for declaring a single test specification.
2114
+ *
2115
+ * Chain setup methods ({@link seed}, {@link fixture}, {@link env}), then call
2116
+ * an action ({@link get}, {@link post}, {@link exec}, {@link trigger}) —
2117
+ * actions are terminal: they execute the specification and resolve to a
2118
+ * typed result.
2119
+ *
2120
+ * Facets expose this class through the narrower {@link ApiSpecification} /
2121
+ * {@link JobsSpecification} / {@link CliSpecification} views so each facet
2122
+ * only surfaces the methods that make sense for it.
2123
+ */
2124
+ var SpecificationBuilder = class {
2125
+ commandEnv = {};
2126
+ config;
2127
+ fixtures = [];
2128
+ intercepts = [];
2129
+ requestHeaders = {};
2130
+ seeds = [];
2131
+ testDir;
2132
+ constructor(config, testDir) {
2133
+ this.config = config;
2134
+ this.testDir = testDir;
2135
+ }
2136
+ /**
2137
+ * Queue a SQL seed file to run before the action.
2138
+ *
2139
+ * With two or more declared databases the `database` option is mandatory;
2140
+ * with exactly one it is forbidden (CONVENTIONS A7).
2141
+ *
2142
+ * @example
2143
+ * api.seed("users.sql", { database: "db" }).get("/users");
2144
+ */
2145
+ seed(file, options) {
2146
+ validateDatabaseOption("seed", this.config, options?.database);
2147
+ this.seeds.push({
2148
+ database: options?.database,
2149
+ file
2150
+ });
1039
2151
  return this;
1040
2152
  }
1041
- /** Assert a file exists inside the container */
1042
- async toHaveFile(path, opts) {
1043
- const file = await this.container.file(path);
1044
- if (!file.exists) throw new Error(`Expected file ${path} to exist in container`);
1045
- if (opts?.containing && !file.content.includes(opts.containing)) throw new Error(`Expected file ${path} to contain "${opts.containing}", got: ${file.content.slice(0, 200)}`);
2153
+ /**
2154
+ * Copy a fixture into the working directory before execution.
2155
+ *
2156
+ * The path is feature-local (resolved under `<test-dir>/fixtures/`) or, with
2157
+ * a `$FIXTURES/` prefix, from the shared pool at `<specs-root>/fixtures/`.
2158
+ * Copy semantics follow rsync's trailing-slash rule: `dir/` spreads the
2159
+ * directory's contents into the cwd, while `dir` (or a plain file) is copied
2160
+ * under its own basename. Chained calls layer in order — a later fixture
2161
+ * overwrites files written by an earlier one.
2162
+ *
2163
+ * @example
2164
+ * cli.fixture('$FIXTURES/cli-app/').exec('build'); // shared project, spread
2165
+ * cli.fixture('config.toml').exec('migrate'); // feature-local file
2166
+ */
2167
+ fixture(path) {
2168
+ this.fixtures.push({ file: path });
1046
2169
  return this;
1047
2170
  }
1048
- /** Assert a file does NOT exist */
1049
- async toNotHaveFile(path) {
1050
- if (await this.container.exists(path)) throw new Error(`Expected ${path} to not exist in container`);
2171
+ /**
2172
+ * Set environment variables for the command process. Merged on top of process.env.
2173
+ * Use `null` to unset a variable. Multiple calls merge.
2174
+ *
2175
+ * The token `$WORKDIR` (in any value) is replaced with the actual working
2176
+ * directory at run-time — useful for tests that need a fully isolated `HOME`.
2177
+ *
2178
+ * @example
2179
+ * cli.env({ HOME: "$WORKDIR", TZ: "UTC" }).exec("status");
2180
+ */
2181
+ env(env) {
2182
+ this.commandEnv = {
2183
+ ...this.commandEnv,
2184
+ ...env
2185
+ };
1051
2186
  return this;
1052
2187
  }
1053
- /** Assert a directory exists */
1054
- async toHaveDirectory(path) {
1055
- if (!await this.container.exists(path)) throw new Error(`Expected directory ${path} to exist in container`);
2188
+ /**
2189
+ * Set HTTP headers for the request. Multiple calls merge; chain headers
2190
+ * win over headers from a `requests/*.http` file.
2191
+ *
2192
+ * @example
2193
+ * api.headers({ 'Accept-Language': 'fr' }).get("/articles");
2194
+ */
2195
+ headers(headers) {
2196
+ this.requestHeaders = {
2197
+ ...this.requestHeaders,
2198
+ ...headers
2199
+ };
1056
2200
  return this;
1057
2201
  }
1058
- /** Assert a mount exists */
1059
- async toHaveMount(destination) {
1060
- const info = await this.container.inspect();
1061
- if (!info.hostConfig.mounts.find((m) => m.destination === destination)) {
1062
- const available = info.hostConfig.mounts.map((m) => m.destination).join(", ");
1063
- throw new Error(`Expected mount at ${destination}, found: [${available}]`);
2202
+ intercept(triggerOrContracts, maybeResponse) {
2203
+ if (this.config.interceptDisabledReason) throw new Error(`.intercept(): ${this.config.interceptDisabledReason}`);
2204
+ if (Array.isArray(triggerOrContracts)) {
2205
+ for (const contract of triggerOrContracts) this.registerIntercept(contract);
2206
+ return this;
1064
2207
  }
2208
+ this.registerIntercept(triggerOrContracts, maybeResponse);
1065
2209
  return this;
1066
2210
  }
1067
- /** Assert network mode */
1068
- async toHaveNetwork(mode) {
1069
- const info = await this.container.inspect();
1070
- if (!info.hostConfig.networkMode.includes(mode)) throw new Error(`Expected network mode "${mode}", got "${info.hostConfig.networkMode}"`);
1071
- return this;
2211
+ /** Register a single intercept — a contract, or a trigger + response pair. */
2212
+ registerIntercept(triggerOrContract, maybeResponse) {
2213
+ const isContract = "trigger" in triggerOrContract && "response" in triggerOrContract;
2214
+ const trigger = isContract ? triggerOrContract.trigger : triggerOrContract;
2215
+ const response = isContract ? triggerOrContract.response : maybeResponse;
2216
+ if (typeof response === "string") {
2217
+ const slashIndex = response.indexOf("/");
2218
+ if (slashIndex === -1) throw new Error(`.intercept(): file path must be 'adapter/filename.json' (e.g. 'openai/ingest-tech.json'), got '${response}'`);
2219
+ const adapterName = response.slice(0, slashIndex);
2220
+ if (adapterName !== trigger.adapter) throw new Error(`.intercept(): adapter mismatch - trigger uses '${trigger.adapter}' but file path starts with '${adapterName}/'`);
2221
+ const filePath = resolve(this.testDir, "intercepts", response);
2222
+ const data = JSON.parse(readFileSync(filePath, "utf8"));
2223
+ const resolved = trigger.wrap(data);
2224
+ this.intercepts.push({
2225
+ trigger,
2226
+ response: resolved
2227
+ });
2228
+ } else this.intercepts.push({
2229
+ trigger,
2230
+ response
2231
+ });
1072
2232
  }
1073
- /** Assert memory limit */
1074
- async toHaveMemoryLimit(bytes) {
1075
- const info = await this.container.inspect();
1076
- if (info.hostConfig.memory !== bytes) throw new Error(`Expected memory limit ${bytes}, got ${info.hostConfig.memory}`);
1077
- return this;
2233
+ /**
2234
+ * Send the complete request described by `requests/<file>` — first line
2235
+ * `METHOD /path`, then headers until a blank line, then the body.
2236
+ * Headers set via `.headers()` merge on top of the file's headers.
2237
+ *
2238
+ * @example
2239
+ * const result = await api.request("create-user.http");
2240
+ */
2241
+ request(file) {
2242
+ return this.executeHttp({
2243
+ method: "",
2244
+ path: "",
2245
+ requestFile: file
2246
+ });
1078
2247
  }
1079
- /** Assert CPU quota */
1080
- async toHaveCpuQuota(quota) {
1081
- const info = await this.container.inspect();
1082
- if (info.hostConfig.cpuQuota !== quota) throw new Error(`Expected CPU quota ${quota}, got ${info.hostConfig.cpuQuota}`);
1083
- return this;
2248
+ /**
2249
+ * Send a GET request to the server adapter and resolve with the result.
2250
+ *
2251
+ * @example
2252
+ * const result = await api.get("/api/items");
2253
+ */
2254
+ get(path) {
2255
+ return this.executeHttp({
2256
+ method: "GET",
2257
+ path
2258
+ });
1084
2259
  }
1085
- /** Execute a command and return output for custom assertions */
1086
- async exec(cmd) {
1087
- return this.container.exec(cmd);
2260
+ /**
2261
+ * Send a POST request to the server adapter and resolve with the result.
2262
+ *
2263
+ * @param body - Optional inline JSON body. Prefer `.request('name.http')`
2264
+ * for file-based request bodies.
2265
+ * @example
2266
+ * const result = await api.post("/api/items", { name: "Widget" });
2267
+ */
2268
+ post(path, body) {
2269
+ return this.executeHttp({
2270
+ body,
2271
+ method: "POST",
2272
+ path
2273
+ });
2274
+ }
2275
+ /** Send a PUT request to the server adapter and resolve with the result. */
2276
+ put(path, body) {
2277
+ return this.executeHttp({
2278
+ body,
2279
+ method: "PUT",
2280
+ path
2281
+ });
2282
+ }
2283
+ /** Send a DELETE request to the server adapter and resolve with the result. */
2284
+ delete(path) {
2285
+ return this.executeHttp({
2286
+ method: "DELETE",
2287
+ path
2288
+ });
1088
2289
  }
1089
- /** Read a file for custom assertions */
1090
- async readFile(path) {
1091
- const file = await this.container.file(path);
1092
- if (!file.exists) throw new Error(`File ${path} does not exist`);
1093
- return file.content;
2290
+ /**
2291
+ * Execute a command (or a sequence of commands) in an isolated working
2292
+ * directory and resolve with the result. When an array is passed, commands
2293
+ * run sequentially and stop on the first non-zero exit code.
2294
+ *
2295
+ * With `{ waitFor, timeout }` the process is treated as long-running: it
2296
+ * resolves (exit code 0) as soon as `waitFor` appears in stdout/stderr,
2297
+ * and is killed at `timeout` (exit code 124). This is the single
2298
+ * execution method — there is no `.spawn()` (CONVENTIONS B2).
2299
+ *
2300
+ * Invoked with no arguments, the binary runs bare (no CLI args) — clearer
2301
+ * than the `.exec('')` idiom. An empty ARRAY stays an error: a command
2302
+ * sequence must name at least one command.
2303
+ *
2304
+ * @example
2305
+ * const result = await cli.exec(); // run the binary bare
2306
+ * const result = await cli.exec("init --name demo");
2307
+ * const result = await cli.exec(["init", "build"]);
2308
+ * const result = await cli.exec("dev --port 0", { waitFor: "Listening on", timeout: 10_000 });
2309
+ */
2310
+ exec(args = "", options) {
2311
+ if (Array.isArray(args) && args.length === 0) throw new Error("exec([]) requires at least one command");
2312
+ if (options && Array.isArray(args)) throw new Error(".exec(): waitFor/timeout options are not supported with a command sequence");
2313
+ return this.executeCommand({
2314
+ args,
2315
+ options
2316
+ });
1094
2317
  }
1095
- /** Get logs for custom assertions */
1096
- async getLogs(tail) {
1097
- return this.container.logs(tail);
2318
+ /**
2319
+ * Execute the named job registered via the `jobs` option of
2320
+ * `specification.jobs()` and resolve with the result.
2321
+ *
2322
+ * @example
2323
+ * const result = await jobs.intercept(classifyArticle).trigger('report-refresh');
2324
+ */
2325
+ async trigger(name) {
2326
+ return this.executeSetup(null, () => this.runJobAction(name));
1098
2327
  }
1099
- };
1100
- //#endregion
1101
- //#region src/infra/adapters/compose.adapter.ts
1102
- /**
1103
- * Start the full compose stack and stop it all on cleanup.
1104
- * Supports per-worker project names for parallel execution.
1105
- */
1106
- var ComposeStackAdapter = class {
1107
- composeFile;
1108
- projectName;
1109
- started = false;
1110
- constructor(composeFile, projectName) {
1111
- this.composeFile = composeFile;
1112
- this.projectName = projectName ?? null;
2328
+ async executeHttp(request) {
2329
+ return this.executeSetup(null, () => this.runHttpAction(request));
1113
2330
  }
1114
- get projectFlag() {
1115
- return this.projectName ? `-p ${this.projectName}` : "";
2331
+ async executeCommand(action) {
2332
+ const workDir = this.prepareWorkDir();
2333
+ return this.executeSetup(workDir, () => this.runCommandAction(workDir, action));
1116
2334
  }
1117
- run(command) {
2335
+ /**
2336
+ * Shared setup pipeline: reset databases, run seeds, copy fixtures,
2337
+ * register intercepts — then run the action and clean up intercepts.
2338
+ */
2339
+ async executeSetup(workDir, action) {
2340
+ if (this.config.databases) for (const db of this.config.databases.values()) await db.reset();
2341
+ else if (this.config.database) await this.config.database.reset();
2342
+ for (const entry of this.seeds) {
2343
+ let db;
2344
+ if (entry.database && this.config.databases) {
2345
+ db = this.config.databases.get(entry.database);
2346
+ if (!db) throw new Error(`seed() targets database "${entry.database}" but it was not found. Available: ${[...this.config.databases.keys()].join(", ")}`);
2347
+ } else db = this.config.database;
2348
+ if (!db) throw new Error("seed() requires a database adapter");
2349
+ const sql = readFileSync(resolve(this.testDir, "seeds", entry.file), "utf8");
2350
+ await db.seed(sql);
2351
+ }
2352
+ if (this.fixtures.length > 0 && workDir) for (const entry of this.fixtures) {
2353
+ const { dest, src } = copyPlan(entry.file, this.testDir, workDir);
2354
+ cpSync(src, dest, { recursive: true });
2355
+ }
2356
+ let registration = null;
2357
+ if (this.intercepts.length > 0) {
2358
+ const { registerIntercepts } = await import("./intercept.js");
2359
+ registration = await registerIntercepts(this.intercepts);
2360
+ }
1118
2361
  try {
1119
- return execSync(command, {
1120
- cwd: dirname(this.composeFile),
1121
- encoding: "utf8",
1122
- timeout: 12e4
1123
- }).trim();
2362
+ const value = await action();
2363
+ const violation = registration?.violation();
2364
+ if (violation) throw violation;
2365
+ return value;
1124
2366
  } catch (error) {
1125
- const stderr = error.stderr?.toString().trim() ?? error.message;
1126
- throw new Error(`docker compose failed: ${stderr}`, { cause: error });
2367
+ throw registration?.violation() ?? error;
2368
+ } finally {
2369
+ registration?.cleanup();
1127
2370
  }
1128
2371
  }
1129
- async start() {
1130
- if (this.started) return;
1131
- this.run(`docker compose ${this.projectFlag} -f ${this.composeFile} up -d --wait`);
1132
- this.started = true;
1133
- }
1134
- async stop() {
1135
- if (!this.started) return;
1136
- this.run(`docker compose ${this.projectFlag} -f ${this.composeFile} down -v`);
1137
- this.started = false;
1138
- }
1139
- getMappedPort(serviceName, containerPort) {
1140
- const port = this.run(`docker compose ${this.projectFlag} -f ${this.composeFile} port ${serviceName} ${containerPort}`).split(":").pop();
1141
- return Number(port);
1142
- }
1143
- getHost() {
1144
- return "localhost";
1145
- }
1146
- };
1147
- //#endregion
1148
- //#region src/infra/adapters/testcontainers.adapter.ts
1149
- /**
1150
- * Container adapter using testcontainers.
1151
- * Wraps a GenericContainer for programmatic container lifecycle.
1152
- */
1153
- var TestcontainersAdapter = class {
1154
- image;
1155
- containerPort;
1156
- env;
1157
- reuse;
1158
- container = null;
1159
- constructor(options) {
1160
- this.image = options.image;
1161
- this.containerPort = options.port;
1162
- this.env = options.env ?? {};
1163
- this.reuse = options.reuse ?? false;
1164
- }
1165
- async start() {
1166
- const { GenericContainer, Wait } = await import("testcontainers");
1167
- let builder = new GenericContainer(this.image).withExposedPorts(this.containerPort);
1168
- for (const [key, value] of Object.entries(this.env)) builder = builder.withEnvironment({ [key]: value });
1169
- if (this.image.startsWith("postgres")) builder = builder.withWaitStrategy(Wait.forLogMessage(/database system is ready to accept connections/, 2));
1170
- if (this.reuse) builder = builder.withReuse();
1171
- this.container = await builder.start();
1172
- }
1173
- async stop() {
1174
- if (this.container && !this.reuse) {
1175
- await this.container.stop();
1176
- this.container = null;
2372
+ resolveEnv(workDir) {
2373
+ const keys = Object.keys(this.commandEnv);
2374
+ if (keys.length === 0) return;
2375
+ const resolved = {};
2376
+ for (const key of keys) {
2377
+ const value = this.commandEnv[key];
2378
+ resolved[key] = typeof value === "string" ? value.replace(/\$WORKDIR/g, workDir) : value;
1177
2379
  }
2380
+ return resolved;
1178
2381
  }
1179
- getMappedPort(containerPort) {
1180
- if (!this.container) throw new Error("Container not started");
1181
- return this.container.getMappedPort(containerPort);
1182
- }
1183
- getHost() {
1184
- if (!this.container) throw new Error("Container not started");
1185
- return this.container.getHost();
2382
+ prepareWorkDir() {
2383
+ return mkdtempSync(resolve(tmpdir(), "spec-command-"));
2384
+ }
2385
+ async runHttpAction(request) {
2386
+ if (!this.config.server) throw new Error("HTTP actions require a server adapter (use specification.api())");
2387
+ let { body, method, path } = request;
2388
+ let fileHeaders;
2389
+ if (request.requestFile) {
2390
+ const parsed = parseRequestFile(readFileSync(resolve(this.testDir, "requests", request.requestFile), "utf8"), `requests/${request.requestFile}`);
2391
+ body = parsed.body;
2392
+ fileHeaders = parsed.headers;
2393
+ method = parsed.method;
2394
+ path = parsed.path;
2395
+ }
2396
+ const headers = {
2397
+ ...fileHeaders,
2398
+ ...this.requestHeaders
2399
+ };
2400
+ const response = await this.config.server.request(method, path, body, Object.keys(headers).length > 0 ? headers : void 0);
2401
+ return new HttpResult({
2402
+ config: this.config,
2403
+ response,
2404
+ testDir: this.testDir
2405
+ });
1186
2406
  }
1187
- getConnectionString() {
1188
- return `${this.getHost()}:${this.getMappedPort(this.containerPort)}`;
2407
+ async runJobAction(name) {
2408
+ if (!this.config.jobs?.length) throw new Error("Job actions require jobs registered via the jobs option of specification.jobs()");
2409
+ const job = this.config.jobs.find((j) => j.name === name);
2410
+ if (!job) {
2411
+ const available = this.config.jobs.map((j) => j.name).join(", ");
2412
+ throw new Error(`trigger("${name}"): job not found. Available: ${available}`);
2413
+ }
2414
+ await job.execute();
2415
+ return new BaseResult({
2416
+ config: this.config,
2417
+ testDir: this.testDir
2418
+ });
1189
2419
  }
1190
- async getLogs() {
1191
- if (!this.container) return "";
1192
- const stream = await this.container.logs();
1193
- return new Promise((resolve) => {
1194
- let output = "";
1195
- stream.on("data", (chunk) => {
1196
- output += chunk.toString();
1197
- });
1198
- stream.on("end", () => {
1199
- resolve(output);
1200
- });
1201
- setTimeout(() => {
1202
- resolve(output);
1203
- }, 1e3);
2420
+ /**
2421
+ * Automatic connection-URL injection (CONVENTIONS B6): `<KEY>_URL` for
2422
+ * every declared service, plus the standard aliases when unambiguous —
2423
+ * `DATABASE_URL` (exactly one SQL database) and `REDIS_URL` (exactly one
2424
+ * redis). `.env()` overrides; `null` unsets.
2425
+ */
2426
+ serviceEnv() {
2427
+ const services = this.config.services;
2428
+ if (!services || Object.keys(services).length === 0) return;
2429
+ const env = {};
2430
+ const sqlHandles = [];
2431
+ const redisHandles = [];
2432
+ for (const [key, handle] of Object.entries(services)) {
2433
+ env[`${toConstantCase(key)}_URL`] = handle.connectionString;
2434
+ if (handle.createDatabaseAdapter() !== null) sqlHandles.push(handle);
2435
+ if (handle.type === "redis") redisHandles.push(handle);
2436
+ }
2437
+ if (sqlHandles.length === 1) env.DATABASE_URL = sqlHandles[0].connectionString;
2438
+ if (redisHandles.length === 1) env.REDIS_URL = redisHandles[0].connectionString;
2439
+ return env;
2440
+ }
2441
+ async runCommandAction(workDir, action) {
2442
+ if (!this.config.command) throw new Error("Command actions require a command adapter");
2443
+ const dockerConfig = this.config.dockerConfig;
2444
+ const testRunId = this.config.dockerTestRunId;
2445
+ let env = this.serviceEnv();
2446
+ if (dockerConfig && testRunId) env = {
2447
+ ...env,
2448
+ [dockerConfig.envVar]: testRunId
2449
+ };
2450
+ const userEnv = this.resolveEnv(workDir);
2451
+ if (userEnv) env = {
2452
+ ...env,
2453
+ ...userEnv
2454
+ };
2455
+ let commandOutput;
2456
+ if (action.options) commandOutput = await this.config.command.watch(action.args, workDir, action.options, env);
2457
+ else if (Array.isArray(action.args)) {
2458
+ commandOutput = {
2459
+ exitCode: 0,
2460
+ stderr: "",
2461
+ stdout: ""
2462
+ };
2463
+ for (const args of action.args) {
2464
+ commandOutput = await this.config.command.exec(args, workDir, env);
2465
+ if (commandOutput.exitCode !== 0) break;
2466
+ }
2467
+ } else commandOutput = await this.config.command.exec(action.args, workDir, env);
2468
+ return new CliResult({
2469
+ commandOutput,
2470
+ config: this.config,
2471
+ dockerConfig: dockerConfig ?? void 0,
2472
+ testDir: this.testDir,
2473
+ testRunId: testRunId ?? void 0,
2474
+ transform: this.config.transform,
2475
+ workDir
1204
2476
  });
1205
2477
  }
1206
2478
  };
1207
- //#endregion
1208
- //#region src/infra/compose-parser.ts
2479
+ function withDockerTestRunId(config) {
2480
+ if (config.dockerConfig && !config.dockerTestRunId) return {
2481
+ ...config,
2482
+ dockerTestRunId: `t-${Math.random().toString(36).slice(2, 10)}-${Date.now().toString(36)}`
2483
+ };
2484
+ return config;
2485
+ }
1209
2486
  /**
1210
- * Detect the service type from the image name.
2487
+ * Create the `api` facet bound to the given adapter configuration. The test
2488
+ * file directory is auto-detected from the call stack at each chain start.
1211
2489
  */
1212
- function detectServiceType(image) {
1213
- if (!image) return "app";
1214
- const lower = image.toLowerCase();
1215
- if (lower.startsWith("postgres")) return "postgres";
1216
- if (lower.startsWith("redis")) return "redis";
1217
- return "unknown";
2490
+ function createApiFacet(config) {
2491
+ const start = () => new SpecificationBuilder(config, getCallerDir());
2492
+ return {
2493
+ delete: (path) => start().delete(path),
2494
+ get: (path) => start().get(path),
2495
+ headers: (headers) => start().headers(headers),
2496
+ intercept: (triggerOrContracts, response) => Array.isArray(triggerOrContracts) ? start().intercept(triggerOrContracts) : start().intercept(triggerOrContracts, response),
2497
+ post: (path, body) => start().post(path, body),
2498
+ put: (path, body) => start().put(path, body),
2499
+ request: (file) => start().request(file),
2500
+ seed: (file, options) => start().seed(file, options)
2501
+ };
1218
2502
  }
1219
2503
  /**
1220
- * Find the compose file in the project.
1221
- * Looks for docker/compose.test.yaml or docker-compose.test.yaml.
2504
+ * Create the `jobs` facet bound to the given adapter configuration.
1222
2505
  */
1223
- function findComposeFile(projectRoot) {
1224
- const candidates = [
1225
- resolve(projectRoot, "docker/compose.test.yaml"),
1226
- resolve(projectRoot, "docker/compose.test.yml"),
1227
- resolve(projectRoot, "docker-compose.test.yaml"),
1228
- resolve(projectRoot, "docker-compose.test.yml")
1229
- ];
1230
- for (const candidate of candidates) if (existsSync(candidate)) return candidate;
1231
- return null;
2506
+ function createJobsFacet(config) {
2507
+ const start = () => new SpecificationBuilder(config, getCallerDir());
2508
+ return {
2509
+ intercept: (triggerOrContracts, response) => Array.isArray(triggerOrContracts) ? start().intercept(triggerOrContracts) : start().intercept(triggerOrContracts, response),
2510
+ seed: (file, options) => start().seed(file, options),
2511
+ trigger: (name) => start().trigger(name)
2512
+ };
1232
2513
  }
1233
2514
  /**
1234
- * Parse a docker-compose file and extract service definitions.
2515
+ * Create the `cli` facet bound to the given adapter configuration.
1235
2516
  */
1236
- function parseComposeFile(filePath) {
1237
- const doc = parse(readFileSync(filePath, "utf8"));
1238
- if (!doc?.services) return {
1239
- services: [],
1240
- appService: null,
1241
- infraServices: []
2517
+ function createCliFacet(config) {
2518
+ const resolved = withDockerTestRunId(config);
2519
+ const start = () => new SpecificationBuilder(resolved, getCallerDir());
2520
+ return {
2521
+ env: (env) => start().env(env),
2522
+ exec: (args, options) => start().exec(args, options),
2523
+ fixture: (path) => start().fixture(path),
2524
+ seed: (file, options) => start().seed(file, options)
1242
2525
  };
1243
- const services = Object.entries(doc.services).map(([name, def]) => {
1244
- const ports = [];
1245
- if (def.ports) for (const port of def.ports) {
1246
- const str = String(port);
1247
- if (str.includes(":")) {
1248
- const [host, container] = str.split(":");
1249
- ports.push({
1250
- container: Number(container),
1251
- host: Number(host)
1252
- });
1253
- } else ports.push({ container: Number(str) });
1254
- }
1255
- const environment = {};
1256
- if (def.environment) if (Array.isArray(def.environment)) for (const env of def.environment) {
1257
- const [key, ...rest] = String(env).split("=");
1258
- environment[key] = rest.join("=");
2526
+ }
2527
+ //#endregion
2528
+ //#region src/core/specification/shared/docker-reader.ts
2529
+ /**
2530
+ * Build the `docker(containerId)` reader handed out by `specification.api()`
2531
+ * and `specification.cli()` — a lazy accessor over `docker inspect`.
2532
+ */
2533
+ function createDockerReader(testDir) {
2534
+ return (containerId) => {
2535
+ try {
2536
+ return new ContainerAccessor(containerId, inspectContainer(containerId), testDir);
2537
+ } catch {
2538
+ return new ContainerAccessor(null, null, testDir);
1259
2539
  }
1260
- else Object.assign(environment, def.environment);
1261
- const volumes = def.volumes ? def.volumes.map((v) => String(v)) : [];
1262
- let dependsOn = [];
1263
- if (def.depends_on) dependsOn = Array.isArray(def.depends_on) ? def.depends_on : Object.keys(def.depends_on);
1264
- return {
1265
- name,
1266
- image: def.image,
1267
- build: def.build,
1268
- ports,
1269
- environment,
1270
- volumes,
1271
- dependsOn
1272
- };
1273
- });
1274
- return {
1275
- services,
1276
- appService: services.find((s) => s.build !== void 0) ?? null,
1277
- infraServices: services.filter((s) => s.build === void 0)
1278
2540
  };
1279
2541
  }
1280
2542
  //#endregion
1281
- //#region src/infra/orchestrator.ts
2543
+ //#region src/core/specification/shared/compose-file.ts
2544
+ /**
2545
+ * Detect the service type from the image name.
2546
+ */
2547
+ function detectServiceType(image) {
2548
+ if (!image) return "app";
2549
+ const lower = image.toLowerCase();
2550
+ if (lower.startsWith("postgres")) return "postgres";
2551
+ if (lower.startsWith("redis")) return "redis";
2552
+ return "unknown";
2553
+ }
2554
+ /**
2555
+ * Find the compose file in the project.
2556
+ * Looks for docker/compose.test.yaml or docker-compose.test.yaml.
2557
+ */
2558
+ function findComposeFile(projectRoot) {
2559
+ const candidates = [
2560
+ resolve(projectRoot, "docker/compose.test.yaml"),
2561
+ resolve(projectRoot, "docker/compose.test.yml"),
2562
+ resolve(projectRoot, "docker-compose.test.yaml"),
2563
+ resolve(projectRoot, "docker-compose.test.yml")
2564
+ ];
2565
+ for (const candidate of candidates) if (existsSync(candidate)) return candidate;
2566
+ return null;
2567
+ }
2568
+ //#endregion
2569
+ //#region src/core/specification/shared/orchestrator.ts
1282
2570
  /**
1283
2571
  * Orchestrator for test infrastructure.
1284
2572
  * Integration: starts services via testcontainers.
@@ -1300,6 +2588,17 @@ var Orchestrator = class {
1300
2588
  this.projectName = options.projectName;
1301
2589
  }
1302
2590
  /**
2591
+ * Bind each declared handle to a compose service (CONVENTIONS A6): a record
2592
+ * key resolves to the service named exactly like it, else the kebab-case
2593
+ * conversion of the key; an explicit `composeService` wins. Throws on an
2594
+ * ambiguous binding (both names present). Runs once compose config is known
2595
+ * so the two-step resolution can consult the real service list.
2596
+ */
2597
+ resolveBindings(composeConfig) {
2598
+ const serviceNames = composeConfig?.services.map((s) => s.name) ?? [];
2599
+ for (const [key, handle] of Object.entries(this.services)) handle.composeName = resolveComposeBinding(key, handle.composeName, serviceNames);
2600
+ }
2601
+ /**
1303
2602
  * Start declared services via testcontainers (integration mode).
1304
2603
  * Phase 1: start all containers in parallel (the slow part).
1305
2604
  * Phase 2: wire connections, healthcheck, and init sequentially (fast).
@@ -1308,10 +2607,11 @@ var Orchestrator = class {
1308
2607
  if (this.started) return;
1309
2608
  const composePath = findComposeFile(this.root);
1310
2609
  const composeDir = composePath ? dirname(composePath) : this.root;
1311
- const composeConfig = composePath ? parseComposeFile(composePath) : null;
2610
+ const composeConfig = composePath ? getContainerIntegrations().parseComposeFile(composePath) : null;
2611
+ this.resolveBindings(composeConfig);
1312
2612
  const containerServices = [];
1313
2613
  const embeddedServices = [];
1314
- for (const handle of this.services) {
2614
+ for (const handle of Object.values(this.services)) {
1315
2615
  if (handle.defaultPort === 0) {
1316
2616
  embeddedServices.push(handle);
1317
2617
  continue;
@@ -1329,7 +2629,7 @@ var Orchestrator = class {
1329
2629
  Object.assign(handle.environment, composeService.environment);
1330
2630
  }
1331
2631
  }
1332
- const container = new TestcontainersAdapter({
2632
+ const container = getContainerIntegrations().createContainer({
1333
2633
  image,
1334
2634
  port: handle.defaultPort,
1335
2635
  env
@@ -1392,395 +2692,1137 @@ var Orchestrator = class {
1392
2692
  console.log(output);
1393
2693
  }
1394
2694
  /**
1395
- * Stop testcontainers (integration mode).
2695
+ * Stop testcontainers (integration mode).
2696
+ */
2697
+ async stop() {
2698
+ for (const { container } of this.running) if (container) await container.stop();
2699
+ this.running = [];
2700
+ this.started = false;
2701
+ }
2702
+ /**
2703
+ * Start full docker compose stack (e2e mode).
2704
+ * Auto-detects infra services and creates handles for them.
2705
+ */
2706
+ async startCompose() {
2707
+ const composePath = findComposeFile(this.root);
2708
+ if (!composePath) throw new Error(`E2E: no compose file found in ${this.root}`);
2709
+ const startTime = Date.now();
2710
+ const composeDir = dirname(composePath);
2711
+ const composeConfig = getContainerIntegrations().parseComposeFile(composePath);
2712
+ this.resolveBindings(composeConfig);
2713
+ this.composeStack = getContainerIntegrations().createComposeStack(composePath, this.projectName);
2714
+ await this.composeStack.start();
2715
+ const declaredComposeNames = /* @__PURE__ */ new Set();
2716
+ for (const handle of Object.values(this.services)) {
2717
+ if (handle.defaultPort === 0) {
2718
+ await handle.initialize(composeDir);
2719
+ handle.started = true;
2720
+ continue;
2721
+ }
2722
+ const composeService = composeConfig.services.find((s) => s.name === handle.composeName);
2723
+ if (!composeService || !handle.composeName) continue;
2724
+ declaredComposeNames.add(handle.composeName);
2725
+ Object.assign(handle.environment, composeService.environment);
2726
+ const port = this.composeStack.getMappedPort(handle.composeName, handle.defaultPort);
2727
+ handle.connectionString = handle.buildConnectionString("localhost", port);
2728
+ await handle.healthcheck();
2729
+ await handle.initialize(composeDir);
2730
+ handle.started = true;
2731
+ }
2732
+ for (const service of composeConfig.infraServices) {
2733
+ if (declaredComposeNames.has(service.name)) continue;
2734
+ const factory = getComposeServiceFactory(detectServiceType(service.image));
2735
+ if (!factory) continue;
2736
+ const handle = factory(service);
2737
+ const port = this.composeStack.getMappedPort(service.name, handle.defaultPort);
2738
+ handle.connectionString = handle.buildConnectionString("localhost", port);
2739
+ await handle.initialize(composeDir);
2740
+ handle.started = true;
2741
+ this.composeHandles.push(handle);
2742
+ }
2743
+ const durationMs = Date.now() - startTime;
2744
+ const output = formatStartupReport("e2e", [...this.allHandles().values()].filter((h) => h.started).map((h) => ({
2745
+ name: h.composeName ?? h.type,
2746
+ type: h.type,
2747
+ connectionString: h.connectionString,
2748
+ durationMs
2749
+ })), {
2750
+ type: "http",
2751
+ url: this.getAppUrl() ?? void 0
2752
+ });
2753
+ console.log(output);
2754
+ }
2755
+ /**
2756
+ * Stop docker compose stack (e2e mode).
2757
+ */
2758
+ async stopCompose() {
2759
+ if (this.composeStack) {
2760
+ await this.composeStack.stop();
2761
+ this.composeStack = null;
2762
+ }
2763
+ this.composeHandles = [];
2764
+ }
2765
+ /**
2766
+ * Get the default database — the first declared handle that is one.
2767
+ */
2768
+ getDatabase() {
2769
+ for (const handle of this.allHandles().values()) {
2770
+ const adapter = handle.createDatabaseAdapter();
2771
+ if (adapter) return adapter;
2772
+ }
2773
+ return null;
2774
+ }
2775
+ /**
2776
+ * Get all database services keyed by their record key (declared services)
2777
+ * or compose service name (stack-detected services).
2778
+ */
2779
+ getDatabases() {
2780
+ const map = /* @__PURE__ */ new Map();
2781
+ for (const [key, handle] of this.allHandles()) {
2782
+ const adapter = handle.createDatabaseAdapter();
2783
+ if (adapter) map.set(key, adapter);
2784
+ }
2785
+ return map;
2786
+ }
2787
+ allHandles() {
2788
+ const map = new Map(Object.entries(this.services));
2789
+ for (const handle of this.composeHandles) {
2790
+ const key = handle.composeName ?? handle.type;
2791
+ if (!map.has(key)) map.set(key, handle);
2792
+ }
2793
+ return map;
2794
+ }
2795
+ /**
2796
+ * Get app URL from compose (e2e mode).
2797
+ */
2798
+ getAppUrl() {
2799
+ const composePath = findComposeFile(this.root);
2800
+ if (!composePath || !this.composeStack) return null;
2801
+ const appService = getContainerIntegrations().parseComposeFile(composePath).appService;
2802
+ if (!appService || appService.ports.length === 0) return null;
2803
+ return `http://localhost:${this.composeStack.getMappedPort(appService.name, appService.ports[0].container)}`;
2804
+ }
2805
+ };
2806
+ //#endregion
2807
+ //#region src/core/specification/shared/resolve.ts
2808
+ /**
2809
+ * Auto-discover the project root from a starting directory (CONVENTIONS A9):
2810
+ * walk up to the first directory containing `docker/compose.test.yaml`;
2811
+ * if none, walk up to the first directory containing `package.json`;
2812
+ * if none, the starting directory itself.
2813
+ */
2814
+ function discoverRoot(startDir) {
2815
+ for (const marker of ["docker/compose.test.yaml", "package.json"]) {
2816
+ let dir = startDir;
2817
+ for (;;) {
2818
+ if (existsSync(resolve(dir, marker))) return dir;
2819
+ const parent = dirname(dir);
2820
+ if (parent === dir) break;
2821
+ dir = parent;
2822
+ }
2823
+ }
2824
+ return startDir;
2825
+ }
2826
+ /**
2827
+ * Resolve the project root for a specification. An explicit `root` option is
2828
+ * an override (resolved from the caller's directory when relative); when
2829
+ * absent, the root is auto-discovered by walking up from the calling
2830
+ * specification file.
2831
+ */
2832
+ function resolveRoot(root, callerDir) {
2833
+ if (!root) return discoverRoot(callerDir);
2834
+ if (isAbsolute(root)) return root;
2835
+ return resolve(callerDir, root);
2836
+ }
2837
+ /**
2838
+ * Resolve a command — checks node_modules/.bin, then treats as absolute/PATH.
2839
+ */
2840
+ function resolveCommand(command, root) {
2841
+ if (isAbsolute(command)) return command;
2842
+ const binPath = resolve(root, "node_modules/.bin", command);
2843
+ if (existsSync(binPath)) return binPath;
2844
+ const cwdBinPath = resolve(process.cwd(), "node_modules/.bin", command);
2845
+ if (existsSync(cwdBinPath)) return cwdBinPath;
2846
+ return command;
2847
+ }
2848
+ //#endregion
2849
+ //#region src/core/specification/shared/services.ts
2850
+ function getWorkerId() {
2851
+ return process.env.VITEST_POOL_ID ?? "0";
2852
+ }
2853
+ async function acquireIsolation(services) {
2854
+ const workerId = getWorkerId();
2855
+ for (const service of Object.values(services)) await service.isolation().acquire(workerId);
2856
+ }
2857
+ async function releaseIsolation(services) {
2858
+ for (const service of Object.values(services)) await service.isolation().release();
2859
+ }
2860
+ function declaredDatabaseKeys(services) {
2861
+ return Object.keys(services).filter((key) => services[key].createDatabaseAdapter() !== null);
2862
+ }
2863
+ /** Start a services record via testcontainers and acquire worker isolation. */
2864
+ async function startServices(services, root) {
2865
+ const orchestrator = new Orchestrator({
2866
+ mode: "integration",
2867
+ root,
2868
+ services
2869
+ });
2870
+ await orchestrator.start();
2871
+ await acquireIsolation(services);
2872
+ const databases = orchestrator.getDatabases();
2873
+ return {
2874
+ database: orchestrator.getDatabase() ?? void 0,
2875
+ databases: databases.size > 0 ? databases : void 0,
2876
+ orchestrator
2877
+ };
2878
+ }
2879
+ //#endregion
2880
+ //#region src/core/specification/api/fetch.adapter.ts
2881
+ /**
2882
+ * Transient connection-level failures against a live compose app. Under
2883
+ * parallel load (every `api-stack` worker hammering its own compose project)
2884
+ * `fetch` intermittently rejects before the app's keep-alive socket is ready —
2885
+ * `undici` surfaces these as `UND_ERR_SOCKET` / `ECONNRESET` / `ECONNREFUSED`.
2886
+ * They are races, not real failures, so the request path retries them; an HTTP
2887
+ * *response* (any status) is never retried — it is a real answer.
2888
+ */
2889
+ const TRANSIENT_CONNECTION_CODES = /* @__PURE__ */ new Set([
2890
+ "ECONNREFUSED",
2891
+ "ECONNRESET",
2892
+ "UND_ERR_CONNECT_TIMEOUT",
2893
+ "UND_ERR_SOCKET"
2894
+ ]);
2895
+ /** Bounded retry budget: attempts and the base backoff (doubled each retry). */
2896
+ const MAX_ATTEMPTS = 5;
2897
+ const BASE_BACKOFF_MS = 50;
2898
+ function isTransientConnectionError(error) {
2899
+ if (!(error instanceof Error)) return false;
2900
+ const cause = error.cause;
2901
+ return cause?.code !== void 0 && TRANSIENT_CONNECTION_CODES.has(cause.code) || error.message.includes("fetch failed");
2902
+ }
2903
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
2904
+ /**
2905
+ * Server adapter that sends real HTTP requests via the Fetch API.
2906
+ * Used by the `e2e()` specification runner to hit a live server.
2907
+ */
2908
+ var FetchAdapter = class {
2909
+ baseUrl;
2910
+ constructor(url) {
2911
+ this.baseUrl = url.replace(/\/$/, "");
2912
+ }
2913
+ async request(method, path, body, headers) {
2914
+ const init = {
2915
+ method,
2916
+ headers: {
2917
+ "Content-Type": "application/json",
2918
+ ...headers
2919
+ }
2920
+ };
2921
+ if (body !== void 0) init.body = typeof body === "string" ? body : JSON.stringify(body);
2922
+ const response = await this.fetchWithRetry(`${this.baseUrl}${path}`, init);
2923
+ const responseBody = await response.json().catch(() => null);
2924
+ const responseHeaders = {};
2925
+ response.headers.forEach((value, key) => {
2926
+ responseHeaders[key] = value;
2927
+ });
2928
+ return {
2929
+ status: response.status,
2930
+ body: responseBody,
2931
+ headers: responseHeaders
2932
+ };
2933
+ }
2934
+ /**
2935
+ * `fetch` with a bounded, backing-off retry over transient connection
2936
+ * races (see {@link isTransientConnectionError}). The last error is rethrown
2937
+ * once the budget is spent so a genuinely-down server still surfaces.
2938
+ */
2939
+ async fetchWithRetry(url, init) {
2940
+ for (let attempt = 1;; attempt += 1) try {
2941
+ return await fetch(url, init);
2942
+ } catch (error) {
2943
+ if (attempt >= MAX_ATTEMPTS || !isTransientConnectionError(error)) throw error;
2944
+ await delay(BASE_BACKOFF_MS * 2 ** (attempt - 1));
2945
+ }
2946
+ }
2947
+ };
2948
+ //#endregion
2949
+ //#region src/core/specification/api/start-api.ts
2950
+ function resolveMode(explicit) {
2951
+ const value = explicit ?? process.env.TEST_MODE ?? "node";
2952
+ if (value !== "compose" && value !== "node") throw new Error(`Invalid test mode "${value}" — expected 'node' or 'compose' (options.mode or TEST_MODE).`);
2953
+ return value;
2954
+ }
2955
+ async function startApi(options) {
2956
+ const callerDir = getCallerDir();
2957
+ await registerMatchers();
2958
+ const root = resolveRoot(options.root, callerDir);
2959
+ const mode = resolveMode(options.mode);
2960
+ const services = options.services ?? {};
2961
+ const databaseKeys = declaredDatabaseKeys(services);
2962
+ if (mode === "node") {
2963
+ if (!options.server) throw new Error("specification.api(): 'server' is required in node mode — provide server: (services) => app, or run in compose mode (TEST_MODE=compose).");
2964
+ const { database, databases, orchestrator } = await startServices(services, root);
2965
+ return {
2966
+ api: createApiFacet({
2967
+ database,
2968
+ databaseKeys,
2969
+ databases,
2970
+ server: new HonoAdapter(options.server(services))
2971
+ }),
2972
+ cleanup: async () => {
2973
+ await releaseIsolation(services);
2974
+ await orchestrator.stop();
2975
+ },
2976
+ docker: createDockerReader(callerDir),
2977
+ orchestrator
2978
+ };
2979
+ }
2980
+ const orchestrator = new Orchestrator({
2981
+ mode: "e2e",
2982
+ projectName: `test-worker-${getWorkerId()}`,
2983
+ root,
2984
+ services
2985
+ });
2986
+ await orchestrator.startCompose();
2987
+ const appUrl = orchestrator.getAppUrl();
2988
+ if (!appUrl) throw new Error("specification.api(): could not detect app URL from compose. Ensure an app service with ports is defined.");
2989
+ const databases = orchestrator.getDatabases();
2990
+ return {
2991
+ api: createApiFacet({
2992
+ database: orchestrator.getDatabase() ?? void 0,
2993
+ databaseKeys,
2994
+ databases: databases.size > 0 ? databases : void 0,
2995
+ interceptDisabledReason: "intercepts are in-process (MSW) and not available in compose mode — keep intercept specs in node-only vitest projects.",
2996
+ server: new FetchAdapter(appUrl)
2997
+ }),
2998
+ cleanup: () => orchestrator.stopCompose(),
2999
+ docker: createDockerReader(callerDir),
3000
+ orchestrator
3001
+ };
3002
+ }
3003
+ //#endregion
3004
+ //#region src/core/specification/cli/exec.adapter.ts
3005
+ const DEFAULT_WATCH_TIMEOUT = 1e4;
3006
+ /** Grace period between SIGTERM and the SIGKILL escalation. */
3007
+ const KILL_GRACE_MS = 2e3;
3008
+ /**
3009
+ * Build a child-process env from the parent env plus user overrides.
3010
+ * `null` overrides delete keys (e.g. `INIT_CWD: null`).
3011
+ *
3012
+ * @internal Exported for unit tests.
3013
+ */
3014
+ function buildEnv(extra) {
3015
+ const env = {
3016
+ ...process.env,
3017
+ INIT_CWD: void 0
3018
+ };
3019
+ if (extra) for (const [key, value] of Object.entries(extra)) if (value === null) delete env[key];
3020
+ else env[key] = value;
3021
+ return env;
3022
+ }
3023
+ /**
3024
+ * Observe a long-running child (`.exec(args, { waitFor, timeout })`):
3025
+ * resolve with exit code 0 as soon as `waitFor` appears in stdout/stderr,
3026
+ * with the process's own code when it exits first, and with 124 at the
3027
+ * timeout. On resolution the child is terminated with SIGTERM, escalating
3028
+ * to SIGKILL after a 2 s grace period; all timers are cleared on exit and
3029
+ * the timeout timer is unref'd so it never holds the runner open.
3030
+ *
3031
+ * @internal Exported for unit tests (driven with a fake child).
3032
+ */
3033
+ function observeProcess(child, options) {
3034
+ const timeout = options.timeout ?? DEFAULT_WATCH_TIMEOUT;
3035
+ const waitFor = options.waitFor;
3036
+ return new Promise((resolve) => {
3037
+ let stdout = "";
3038
+ let stderr = "";
3039
+ let resolved = false;
3040
+ let exited = false;
3041
+ let patternMatched = false;
3042
+ let killTimer = null;
3043
+ const timeoutTimer = setTimeout(() => finish(124), timeout);
3044
+ timeoutTimer.unref?.();
3045
+ const terminate = () => {
3046
+ if (exited) return;
3047
+ child.kill("SIGTERM");
3048
+ killTimer = setTimeout(() => {
3049
+ if (!exited) child.kill("SIGKILL");
3050
+ }, KILL_GRACE_MS);
3051
+ };
3052
+ const finish = (exitCode) => {
3053
+ if (resolved) return;
3054
+ resolved = true;
3055
+ clearTimeout(timeoutTimer);
3056
+ terminate();
3057
+ resolve({
3058
+ exitCode,
3059
+ stderr,
3060
+ stdout
3061
+ });
3062
+ };
3063
+ const checkPattern = () => {
3064
+ if (waitFor !== void 0 && !patternMatched && (stdout.includes(waitFor) || stderr.includes(waitFor))) {
3065
+ patternMatched = true;
3066
+ finish(0);
3067
+ }
3068
+ };
3069
+ child.stdout?.on("data", (data) => {
3070
+ stdout += data.toString();
3071
+ checkPattern();
3072
+ });
3073
+ child.stderr?.on("data", (data) => {
3074
+ stderr += data.toString();
3075
+ checkPattern();
3076
+ });
3077
+ child.on("exit", (code) => {
3078
+ exited = true;
3079
+ if (killTimer) clearTimeout(killTimer);
3080
+ if (patternMatched) return;
3081
+ if (waitFor === void 0) {
3082
+ finish(code ?? 1);
3083
+ return;
3084
+ }
3085
+ finish(code === 0 ? 1 : code ?? 1);
3086
+ });
3087
+ });
3088
+ }
3089
+ /**
3090
+ * Executes commands via Node.js child_process.
3091
+ * Uses `spawnSync` for one-shot commands and `spawn` for long-running
3092
+ * processes (`.exec(args, { waitFor, timeout })`).
3093
+ *
3094
+ * Both paths run the full command line through the shell — quoting behaves
3095
+ * identically whether or not `waitFor` is used.
3096
+ *
3097
+ * `spawnSync` is used (over the simpler `execSync`) so stdout AND stderr are
3098
+ * captured regardless of exit code. Many CLIs follow the Unix convention of
3099
+ * writing status banners to stderr on success — `execSync` would silently
3100
+ * discard them, leaving snapshot tests with no output to assert on.
3101
+ */
3102
+ var ExecAdapter = class {
3103
+ command;
3104
+ constructor(command) {
3105
+ this.command = command;
3106
+ }
3107
+ async exec(args, cwd, extraEnv) {
3108
+ const env = buildEnv(extraEnv);
3109
+ const result = spawnSync(`${this.command} ${args}`, [], {
3110
+ cwd,
3111
+ encoding: "utf8",
3112
+ env,
3113
+ shell: true,
3114
+ stdio: [
3115
+ "pipe",
3116
+ "pipe",
3117
+ "pipe"
3118
+ ]
3119
+ });
3120
+ return {
3121
+ exitCode: result.status ?? 1,
3122
+ stderr: result.stderr ?? "",
3123
+ stdout: result.stdout ?? ""
3124
+ };
3125
+ }
3126
+ async watch(args, cwd, options, extraEnv) {
3127
+ const env = buildEnv(extraEnv);
3128
+ const child = spawn(`${this.command} ${args}`, [], {
3129
+ cwd,
3130
+ detached: process.platform !== "win32",
3131
+ env,
3132
+ shell: true,
3133
+ stdio: [
3134
+ "pipe",
3135
+ "pipe",
3136
+ "pipe"
3137
+ ]
3138
+ });
3139
+ const kill = (signal) => {
3140
+ if (child.pid !== void 0 && process.platform !== "win32") try {
3141
+ process.kill(-child.pid, signal ?? "SIGTERM");
3142
+ return true;
3143
+ } catch {}
3144
+ return child.kill(signal);
3145
+ };
3146
+ return observeProcess({
3147
+ kill,
3148
+ on: child.on.bind(child),
3149
+ stderr: child.stderr,
3150
+ stdout: child.stdout
3151
+ }, options);
3152
+ }
3153
+ };
3154
+ //#endregion
3155
+ //#region src/core/specification/cli/start-cli.ts
3156
+ async function startCli(bin, options = {}) {
3157
+ const callerDir = getCallerDir();
3158
+ await registerMatchers();
3159
+ const root = resolveRoot(options.root, callerDir);
3160
+ const resolvedBin = resolveCommand(bin, root);
3161
+ const services = options.services ?? {};
3162
+ const databaseKeys = declaredDatabaseKeys(services);
3163
+ let started = null;
3164
+ if (Object.keys(services).length > 0) started = await startServices(services, root);
3165
+ return {
3166
+ cleanup: async () => {
3167
+ await releaseIsolation(services);
3168
+ if (started) await started.orchestrator.stop();
3169
+ },
3170
+ cli: createCliFacet({
3171
+ command: new ExecAdapter(resolvedBin),
3172
+ database: started?.database,
3173
+ databaseKeys,
3174
+ databases: started?.databases,
3175
+ dockerConfig: options.docker,
3176
+ services: Object.keys(services).length > 0 ? services : void 0,
3177
+ transform: options.transform
3178
+ }),
3179
+ docker: createDockerReader(callerDir),
3180
+ orchestrator: started?.orchestrator ?? null
3181
+ };
3182
+ }
3183
+ //#endregion
3184
+ //#region src/core/specification/jobs/start-jobs.ts
3185
+ async function startJobs(options) {
3186
+ const callerDir = getCallerDir();
3187
+ await registerMatchers();
3188
+ const root = resolveRoot(options.root, callerDir);
3189
+ const services = options.services ?? {};
3190
+ const databaseKeys = declaredDatabaseKeys(services);
3191
+ let started = null;
3192
+ if (Object.keys(services).length > 0) started = await startServices(services, root);
3193
+ const jobHandles = typeof options.jobs === "function" ? options.jobs(services) : options.jobs;
3194
+ return {
3195
+ cleanup: async () => {
3196
+ await releaseIsolation(services);
3197
+ if (started) await started.orchestrator.stop();
3198
+ },
3199
+ jobs: createJobsFacet({
3200
+ database: started?.database,
3201
+ databaseKeys,
3202
+ databases: started?.databases,
3203
+ jobs: jobHandles
3204
+ }),
3205
+ orchestrator: started?.orchestrator ?? null
3206
+ };
3207
+ }
3208
+ //#endregion
3209
+ //#region src/core/specification/shared/specification.ts
3210
+ /**
3211
+ * The three specification constructors (CONVENTIONS A2) — created in a
3212
+ * `*.specification.ts` file under `specs/`, destructured with canonical
3213
+ * names, and cleaned up via `afterAll(cleanup)` (A1/A3/A4).
3214
+ *
3215
+ * @example
3216
+ * // specs/api/api.specification.ts
3217
+ * export const { api, cleanup } = await specification.api({
3218
+ * services: { db: postgres() },
3219
+ * server: ({ db }) => createApp({ databaseUrl: db.connectionString }),
3220
+ * });
3221
+ * afterAll(cleanup);
3222
+ *
3223
+ * // specs/jobs/jobs.specification.ts
3224
+ * export const { jobs, cleanup } = await specification.jobs({
3225
+ * services: { db: postgres() },
3226
+ * jobs: ({ db }) => [nightlyReport(db)],
3227
+ * });
3228
+ * afterAll(cleanup);
3229
+ *
3230
+ * // specs/setup/cli.specification.ts
3231
+ * export const { cli, cleanup } = await specification.cli('my-cli');
3232
+ * afterAll(cleanup);
3233
+ */
3234
+ const specification = {
3235
+ /**
3236
+ * Test an HTTP app. Mode `'node'` (default) starts the declared services
3237
+ * via testcontainers and runs the app in-process; mode `'compose'` runs
3238
+ * `docker compose up` on `docker/compose.test.yaml` and sends real HTTP
3239
+ * requests to the app service. Resolution: `options.mode` > `TEST_MODE`
3240
+ * env var > `'node'`. Only `.api()` has a mode.
1396
3241
  */
1397
- async stop() {
1398
- for (const { container } of this.running) if (container) await container.stop();
1399
- this.running = [];
1400
- this.started = false;
1401
- }
3242
+ api: startApi,
1402
3243
  /**
1403
- * Start full docker compose stack (e2e mode).
1404
- * Auto-detects infra services and creates handles for them.
3244
+ * Test a command binary. Each spec runs in a fresh temp directory.
3245
+ *
3246
+ * @param bin - Path to the binary (resolved from node_modules/.bin or PATH).
1405
3247
  */
1406
- async startCompose() {
1407
- const composePath = findComposeFile(this.root);
1408
- if (!composePath) throw new Error(`E2E: no compose file found in ${this.root}`);
1409
- const startTime = Date.now();
1410
- const composeDir = dirname(composePath);
1411
- const composeConfig = parseComposeFile(composePath);
1412
- this.composeStack = new ComposeStackAdapter(composePath, this.projectName);
1413
- await this.composeStack.start();
1414
- for (const service of composeConfig.infraServices) {
1415
- const type = detectServiceType(service.image);
1416
- if (type === "postgres") {
1417
- const handle = postgres({
1418
- compose: service.name,
1419
- env: service.environment
1420
- });
1421
- const port = this.composeStack.getMappedPort(service.name, 5432);
1422
- handle.connectionString = handle.buildConnectionString("localhost", port);
1423
- await handle.initialize(composeDir);
1424
- handle.started = true;
1425
- this.composeHandles.push(handle);
1426
- } else if (type === "redis") {
1427
- const handle = redis({ compose: service.name });
1428
- const port = this.composeStack.getMappedPort(service.name, 6379);
1429
- handle.connectionString = handle.buildConnectionString("localhost", port);
1430
- handle.started = true;
1431
- this.composeHandles.push(handle);
1432
- }
1433
- }
1434
- const durationMs = Date.now() - startTime;
1435
- const output = formatStartupReport("e2e", this.composeHandles.map((h) => ({
1436
- name: h.composeName ?? h.type,
1437
- type: h.type,
1438
- connectionString: h.connectionString,
1439
- durationMs
1440
- })), {
1441
- type: "http",
1442
- url: this.getAppUrl() ?? void 0
1443
- });
1444
- console.log(output);
1445
- }
3248
+ cli: startCli,
1446
3249
  /**
1447
- * Stop docker compose stack (e2e mode).
3250
+ * Test background jobs. Jobs run in-process by definition — no HTTP
3251
+ * server, no mode. `.trigger(name)` is the terminal action.
1448
3252
  */
1449
- async stopCompose() {
1450
- if (this.composeStack) {
1451
- await this.composeStack.stop();
1452
- this.composeStack = null;
3253
+ jobs: startJobs
3254
+ };
3255
+ //#endregion
3256
+ //#region src/integrations/sqlite/sqlite.ts
3257
+ var SqliteHandle = class {
3258
+ type = "sqlite";
3259
+ composeName = null;
3260
+ defaultPort = 0;
3261
+ defaultImage = "";
3262
+ environment = {};
3263
+ connectionString = "";
3264
+ started = false;
3265
+ db = null;
3266
+ templatePath = "";
3267
+ workerDbPath = "";
3268
+ initSql;
3269
+ prismaSchema;
3270
+ constructor(options = {}) {
3271
+ this.initSql = options.init ?? null;
3272
+ this.prismaSchema = options.prismaSchema ?? null;
3273
+ }
3274
+ buildConnectionString() {
3275
+ return `file:${this.workerDbPath || this.templatePath}`;
3276
+ }
3277
+ createDatabaseAdapter() {
3278
+ return this;
3279
+ }
3280
+ async healthcheck() {}
3281
+ async initialize() {
3282
+ this.templatePath = resolve(tmpdir(), "jterrazz-test-sqlite-template.sqlite");
3283
+ const lockPath = `${this.templatePath}.lock`;
3284
+ if (existsSync(lockPath)) {
3285
+ const start = Date.now();
3286
+ while (existsSync(lockPath) && Date.now() - start < 3e4) await new Promise((r) => setTimeout(r, 100));
1453
3287
  }
1454
- this.composeHandles = [];
3288
+ if (existsSync(this.templatePath)) {
3289
+ this.connectionString = `file:${this.templatePath}`;
3290
+ this.started = true;
3291
+ return;
3292
+ }
3293
+ const { writeFileSync } = await import("node:fs");
3294
+ writeFileSync(lockPath, process.pid.toString());
3295
+ if (this.prismaSchema) {
3296
+ const { execSync } = await import("node:child_process");
3297
+ execSync("npx prisma db push --force-reset", {
3298
+ env: {
3299
+ ...process.env,
3300
+ DATABASE_URL: `file:${this.templatePath}`,
3301
+ PRISMA_USER_CONSENT_FOR_DANGEROUS_AI_ACTION: "yes"
3302
+ },
3303
+ stdio: "pipe"
3304
+ });
3305
+ const tmpDb = new Database(this.templatePath);
3306
+ tmpDb.pragma("wal_checkpoint(TRUNCATE)");
3307
+ tmpDb.close();
3308
+ } else if (this.initSql) {
3309
+ const sql = readFileSync(this.initSql, "utf8");
3310
+ const templateDb = new Database(this.templatePath);
3311
+ templateDb.exec(sql);
3312
+ templateDb.close();
3313
+ } else new Database(this.templatePath).close();
3314
+ try {
3315
+ unlinkSync(lockPath);
3316
+ } catch {}
3317
+ this.connectionString = `file:${this.templatePath}`;
3318
+ this.started = true;
1455
3319
  }
1456
- /**
1457
- * Get a database service by compose name, or the first one if no name given.
1458
- */
1459
- getDatabase(serviceName) {
1460
- for (const handle of [...this.services, ...this.composeHandles]) {
1461
- if (serviceName && handle.composeName !== serviceName) continue;
1462
- const adapter = handle.createDatabaseAdapter();
1463
- if (adapter) return adapter;
3320
+ getDb() {
3321
+ const dbPath = this.workerDbPath || this.templatePath;
3322
+ if (!this.db) {
3323
+ this.db = new Database(dbPath);
3324
+ this.db.pragma("journal_mode = WAL");
1464
3325
  }
1465
- return null;
3326
+ return this.db;
1466
3327
  }
1467
- /**
1468
- * Get all database services keyed by compose name.
1469
- */
1470
- getDatabases() {
1471
- const map = /* @__PURE__ */ new Map();
1472
- for (const handle of [...this.services, ...this.composeHandles]) {
1473
- const adapter = handle.createDatabaseAdapter();
1474
- if (adapter && handle.composeName) map.set(handle.composeName, adapter);
3328
+ closeDb() {
3329
+ if (this.db) {
3330
+ this.db.close();
3331
+ this.db = null;
1475
3332
  }
1476
- return map;
1477
3333
  }
1478
- /**
1479
- * Get app URL from compose (e2e mode).
1480
- */
1481
- getAppUrl() {
1482
- const composePath = findComposeFile(this.root);
1483
- if (!composePath || !this.composeStack) return null;
1484
- const appService = parseComposeFile(composePath).appService;
1485
- if (!appService || appService.ports.length === 0) return null;
1486
- return `http://localhost:${this.composeStack.getMappedPort(appService.name, appService.ports[0].container)}`;
3334
+ async seed(sql) {
3335
+ this.getDb().exec(sql);
1487
3336
  }
1488
- };
1489
- //#endregion
1490
- //#region src/spec/resolve.ts
1491
- /**
1492
- * Resolve root — if relative, resolves from the caller's directory.
1493
- */
1494
- function resolveProjectRoot(root) {
1495
- if (!root) return process.cwd();
1496
- if (isAbsolute(root)) return root;
1497
- const stack = (/* @__PURE__ */ new Error("resolve root")).stack;
1498
- if (stack) {
1499
- const lines = stack.split("\n");
1500
- for (const line of lines) {
1501
- const match = line.match(/at\s+(?:.*?\()?(?:file:\/\/)?([^:)]+):\d+:\d+/);
1502
- if (!match) continue;
1503
- const filePath = match[1];
1504
- if (filePath.includes("node_modules") || filePath.includes("/src/spec/")) continue;
1505
- return resolve(filePath, "..", root);
1506
- }
3337
+ async query(table, columns) {
3338
+ const columnList = columns.join(", ");
3339
+ return this.getDb().prepare(`SELECT ${columnList} FROM "${table}" ORDER BY 1`).all().map((row) => columns.map((col) => row[col]));
1507
3340
  }
1508
- return resolve(process.cwd(), root);
1509
- }
1510
- /**
1511
- * Resolve a CLI command checks node_modules/.bin, then treats as absolute/PATH.
1512
- */
1513
- function resolveCommand(command, root) {
1514
- if (isAbsolute(command)) return command;
1515
- const binPath = resolve(root, "node_modules/.bin", command);
1516
- if (existsSync(binPath)) return binPath;
1517
- const cwdBinPath = resolve(process.cwd(), "node_modules/.bin", command);
1518
- if (existsSync(cwdBinPath)) return cwdBinPath;
1519
- return command;
1520
- }
1521
- //#endregion
1522
- //#region src/spec/spec.ts
3341
+ async reset() {
3342
+ const db = this.getDb();
3343
+ const tables = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE '_prisma%' AND name != 'sqlite_sequence'`).all();
3344
+ for (const { name } of tables) db.exec(`DELETE FROM "${name}"`);
3345
+ }
3346
+ isolation() {
3347
+ return {
3348
+ acquire: async (workerId) => {
3349
+ this.closeDb();
3350
+ this.workerDbPath = resolve(tmpdir(), `test-worker-${workerId}-${Date.now()}.sqlite`);
3351
+ copyFileSync(this.templatePath, this.workerDbPath);
3352
+ this.connectionString = `file:${this.workerDbPath}`;
3353
+ },
3354
+ reset: async () => {
3355
+ await this.reset();
3356
+ },
3357
+ release: async () => {
3358
+ this.closeDb();
3359
+ if (this.workerDbPath && existsSync(this.workerDbPath)) unlinkSync(this.workerDbPath);
3360
+ this.workerDbPath = "";
3361
+ this.connectionString = `file:${this.templatePath}`;
3362
+ }
3363
+ };
3364
+ }
3365
+ };
1523
3366
  /**
1524
- * Create a specification runner for the given target.
1525
- *
1526
- * @param target - What to test against: {@link app}, {@link stack}, or {@link command}.
1527
- * @param options - Shared options: root directory, infrastructure services.
3367
+ * Create a SQLite service handle. Uses file-copy isolation for parallel tests.
1528
3368
  *
1529
3369
  * @example
1530
- * // HTTP in-process app with testcontainers
1531
- * const s = await spec(app(() => createApp(db)), { services: [db] });
3370
+ * // With Prisma schema
3371
+ * const db = sqlite({ prismaSchema: './prisma/schema' });
1532
3372
  *
1533
- * // HTTP full docker compose stack
1534
- * const s = await spec(stack('../../'));
3373
+ * // With raw SQL init
3374
+ * const db = sqlite({ init: './schema.sql' });
1535
3375
  *
1536
- * // CLI — command binary
1537
- * const s = await spec(command('my-cli'), { root: '../fixtures' });
3376
+ * // Empty database
3377
+ * const db = sqlite();
1538
3378
  */
1539
- async function spec(target, options = {}) {
1540
- switch (target.kind) {
1541
- case "app": return startApp(target, options);
1542
- case "stack": return startStack(target, options);
1543
- case "command": return startCommand(target, options);
1544
- }
1545
- }
1546
- function getWorkerId() {
1547
- return process.env.VITEST_POOL_ID ?? "0";
1548
- }
1549
- async function acquireIsolation(services) {
1550
- const workerId = getWorkerId();
1551
- for (const service of services) await service.isolation().acquire(workerId);
3379
+ function sqlite(options = {}) {
3380
+ return new SqliteHandle(options);
1552
3381
  }
1553
- async function releaseIsolation(services) {
1554
- for (const service of services) await service.isolation().release();
1555
- }
1556
- async function startApp(target, options) {
1557
- const services = options.services ?? [];
1558
- const orchestrator = new Orchestrator({
1559
- mode: "integration",
1560
- root: resolveProjectRoot(options.root),
1561
- services
1562
- });
1563
- await orchestrator.start();
1564
- await acquireIsolation(services);
1565
- const servicesMap = {};
1566
- for (const svc of services) {
1567
- const key = svc.composeName ?? svc.type;
1568
- servicesMap[key] = svc;
1569
- }
1570
- const factoryResult = target.factory(servicesMap);
1571
- const isComposite = Array.isArray(factoryResult.jobs);
1572
- const honoApp = isComposite ? factoryResult.server : factoryResult;
1573
- const jobs = isComposite ? factoryResult.jobs : void 0;
1574
- const database = orchestrator.getDatabase() ?? void 0;
1575
- const databases = orchestrator.getDatabases();
1576
- const runner = createSpecificationRunner({
1577
- database,
1578
- databases: databases.size > 0 ? databases : void 0,
1579
- jobs,
1580
- server: new HonoAdapter(honoApp)
1581
- });
1582
- runner.cleanup = async () => {
1583
- await releaseIsolation(services);
1584
- await orchestrator.stop();
1585
- };
1586
- runner.docker = (id) => new DockerAssertion(dockerContainer(id));
1587
- runner.orchestrator = orchestrator;
1588
- return runner;
3382
+ //#endregion
3383
+ //#region src/integrations/anthropic/anthropic.ts
3384
+ const ANTHROPIC_MESSAGES_URL = "https://api.anthropic.com/v1/messages";
3385
+ function matchesFilter(body, filter) {
3386
+ if (filter.model) {
3387
+ const model = body?.model;
3388
+ if (typeof filter.model === "string" && model !== filter.model) return false;
3389
+ if (filter.model instanceof RegExp && !filter.model.test(model ?? "")) return false;
3390
+ }
3391
+ if (filter.system) {
3392
+ const system = typeof body?.system === "string" ? body.system : "";
3393
+ if (typeof filter.system === "string" && !system.includes(filter.system)) return false;
3394
+ if (filter.system instanceof RegExp && !filter.system.test(system)) return false;
3395
+ }
3396
+ if (filter.user) {
3397
+ const userMsg = body?.messages?.find((m) => m.role === "user")?.content ?? "";
3398
+ const text = typeof userMsg === "string" ? userMsg : JSON.stringify(userMsg);
3399
+ if (typeof filter.user === "string" && !text.includes(filter.user)) return false;
3400
+ if (filter.user instanceof RegExp && !filter.user.test(text)) return false;
3401
+ }
3402
+ if (filter.tools) {
3403
+ const names = body?.tools?.map((t) => t.name).filter(Boolean) ?? [];
3404
+ if (!filter.tools.every((t) => names.includes(t))) return false;
3405
+ }
3406
+ return true;
1589
3407
  }
1590
- async function startStack(target, options) {
1591
- const root = resolveProjectRoot(target.root ?? options.root);
1592
- const projectName = `test-worker-${getWorkerId()}`;
1593
- const orchestrator = new Orchestrator({
1594
- mode: "e2e",
1595
- root,
1596
- services: options.services ?? [],
1597
- projectName
1598
- });
1599
- await orchestrator.startCompose();
1600
- const appUrl = orchestrator.getAppUrl();
1601
- if (!appUrl) throw new Error("stack(): could not detect app URL from compose. Ensure an app service with ports is defined.");
1602
- const database = orchestrator.getDatabase() ?? void 0;
1603
- const databases = orchestrator.getDatabases();
1604
- const runner = createSpecificationRunner({
1605
- database,
1606
- databases: databases.size > 0 ? databases : void 0,
1607
- server: new FetchAdapter(appUrl)
1608
- });
1609
- runner.cleanup = () => orchestrator.stopCompose();
1610
- runner.docker = (id) => new DockerAssertion(dockerContainer(id));
1611
- runner.orchestrator = orchestrator;
1612
- return runner;
1613
- }
1614
- async function startCommand(target, options) {
1615
- const root = resolveProjectRoot(options.root);
1616
- const bin = resolveCommand(target.bin, root);
1617
- const services = options.services ?? [];
1618
- let orchestrator = null;
1619
- let database;
1620
- let databases;
1621
- if (services.length) {
1622
- orchestrator = new Orchestrator({
1623
- mode: "integration",
1624
- root,
1625
- services
1626
- });
1627
- await orchestrator.start();
1628
- await acquireIsolation(services);
1629
- database = orchestrator.getDatabase() ?? void 0;
1630
- const dbMap = orchestrator.getDatabases();
1631
- databases = dbMap.size > 0 ? dbMap : void 0;
1632
- }
1633
- const runner = createSpecificationRunner({
1634
- command: new ExecAdapter(bin),
1635
- database,
1636
- databases,
1637
- fixturesRoot: root
1638
- });
1639
- runner.cleanup = async () => {
1640
- await releaseIsolation(services);
1641
- if (orchestrator) await orchestrator.stop();
3408
+ function buildReply(data) {
3409
+ return {
3410
+ status: 200,
3411
+ body: {
3412
+ id: "msg-test",
3413
+ type: "message",
3414
+ role: "assistant",
3415
+ content: [{
3416
+ type: "text",
3417
+ text: typeof data === "string" ? data : JSON.stringify(data)
3418
+ }],
3419
+ model: "claude-sonnet-4-20250514",
3420
+ stop_reason: "end_turn",
3421
+ usage: {
3422
+ input_tokens: 10,
3423
+ output_tokens: 10
3424
+ }
3425
+ }
1642
3426
  };
1643
- runner.docker = (id) => new DockerAssertion(dockerContainer(id));
1644
- runner.orchestrator = orchestrator;
1645
- return runner;
1646
3427
  }
1647
- //#endregion
1648
- //#region src/spec/targets.ts
1649
3428
  /**
1650
- * Test against an in-process Hono app. The factory receives started services
1651
- * so you can wire connection strings into your app/DI container.
1652
- *
1653
- * @param factory - Function that receives services and returns a Hono app instance.
1654
- *
1655
- * @example
1656
- * const db = postgres({ compose: 'db' });
1657
- * await spec(
1658
- * app((services) => createApp({ databaseUrl: services.db.connectionString })),
1659
- * { services: [db] },
1660
- * );
3429
+ * Anthropic API intercept helpers.
1661
3430
  */
1662
- function app(factory) {
3431
+ const anthropic = {
3432
+ /**
3433
+ * Trigger: match Messages API requests, optionally routed through a
3434
+ * custom gateway URL. When used with a JSON fixture file, the data is
3435
+ * returned as-is (no wrapping) because Anthropic fixtures are typically
3436
+ * already in the Messages API response shape.
3437
+ *
3438
+ * @example
3439
+ * anthropic.messages()
3440
+ * anthropic.messages({ system: /classify/ })
3441
+ * anthropic.messages({ user: /classify/ }, GATEWAY)
3442
+ */
3443
+ messages(filter, url) {
3444
+ return {
3445
+ adapter: "anthropic",
3446
+ method: "POST",
3447
+ url: url ?? ANTHROPIC_MESSAGES_URL,
3448
+ match: filter ? ({ body }) => matchesFilter(body, filter) : void 0,
3449
+ wrap(data) {
3450
+ if (data && typeof data === "object" && !Array.isArray(data)) return {
3451
+ status: 200,
3452
+ body: data
3453
+ };
3454
+ return buildReply(data);
3455
+ }
3456
+ };
3457
+ },
3458
+ /** Response: wrap data in Anthropic messages format. */
3459
+ reply: buildReply,
3460
+ /** Response: return an Anthropic error. */
3461
+ error(status, message) {
3462
+ return {
3463
+ status,
3464
+ body: {
3465
+ type: "error",
3466
+ error: {
3467
+ type: status === 429 ? "rate_limit_error" : "api_error",
3468
+ message: message ?? `Anthropic error (${status})`
3469
+ }
3470
+ }
3471
+ };
3472
+ },
3473
+ /** Response: simulate a timeout. */
3474
+ timeout() {
3475
+ return {
3476
+ status: 200,
3477
+ body: {},
3478
+ delay: 3e4
3479
+ };
3480
+ }
3481
+ };
3482
+ //#endregion
3483
+ //#region src/core/contracts/http.ts
3484
+ function wrapJson(data) {
1663
3485
  return {
1664
- kind: "app",
1665
- factory
3486
+ status: 200,
3487
+ body: data
1666
3488
  };
1667
3489
  }
3490
+ function matchesBody(body, expected) {
3491
+ if (typeof expected === "string") return (typeof body === "string" ? body : JSON.stringify(body ?? "")).includes(expected);
3492
+ if (expected instanceof RegExp) {
3493
+ const text = typeof body === "string" ? body : JSON.stringify(body ?? "");
3494
+ return expected.test(text);
3495
+ }
3496
+ return structuralSubset(expected, body, new CaptureScope());
3497
+ }
3498
+ function matchesEntries(expected, lookup) {
3499
+ return Object.entries(expected).every(([key, value]) => {
3500
+ const actual = lookup(key);
3501
+ if (actual === void 0) return false;
3502
+ return value instanceof RegExp ? value.test(actual) : actual === value;
3503
+ });
3504
+ }
1668
3505
  /**
1669
- * Test against a full docker compose stack. The stack is started with
1670
- * `docker compose up` and real HTTP requests are sent to the app service.
1671
- *
1672
- * @param root - Project root containing `docker/compose.test.yaml`.
1673
- *
1674
- * @example
1675
- * await spec(stack('../../'));
3506
+ * Build the `match` predicate for an HTTP trigger filter, or `undefined` when
3507
+ * no filter is supplied (fires on any URL/method match).
1676
3508
  */
1677
- function stack(root) {
1678
- return {
1679
- kind: "stack",
1680
- root
3509
+ function buildMatch(filter) {
3510
+ if (!filter) return;
3511
+ return (request) => {
3512
+ if (filter.body !== void 0 && !matchesBody(request.body, filter.body)) return false;
3513
+ if (filter.headers && !matchesEntries(filter.headers, (key) => request.headers[key.toLowerCase()])) return false;
3514
+ if (filter.query) {
3515
+ let params;
3516
+ try {
3517
+ params = new URL(request.url).searchParams;
3518
+ } catch {
3519
+ return false;
3520
+ }
3521
+ if (!matchesEntries(filter.query, (key) => params.get(key) ?? void 0)) return false;
3522
+ }
3523
+ return true;
1681
3524
  };
1682
3525
  }
1683
3526
  /**
1684
- * Test a CLI binary. Each spec runs in a fresh temp directory.
1685
- *
1686
- * @param bin - Path to the CLI binary or command name (resolved from node_modules/.bin or PATH).
3527
+ * Generic HTTP intercept helpers for any URL. An optional {@link
3528
+ * HttpInterceptFilter} narrows matching by request body, headers, or query —
3529
+ * a request that hits the URL/method but fails the filter counts as unmatched
3530
+ * (strict intercepts, CONVENTIONS D7).
1687
3531
  *
1688
3532
  * @example
1689
- * await spec(command('my-cli'), { root: '../fixtures' });
3533
+ * .intercept(http.get('https://api.example.com/data'), 'http/response.json')
3534
+ * .intercept(http.post(URL, { body: { user: 'alice' } }), http.json({ ok: true }))
1690
3535
  */
1691
- function command(bin) {
3536
+ const http = {
3537
+ get(url, filter) {
3538
+ return {
3539
+ adapter: "http",
3540
+ match: buildMatch(filter),
3541
+ method: "GET",
3542
+ url,
3543
+ wrap: wrapJson
3544
+ };
3545
+ },
3546
+ post(url, filter) {
3547
+ return {
3548
+ adapter: "http",
3549
+ match: buildMatch(filter),
3550
+ method: "POST",
3551
+ url,
3552
+ wrap: wrapJson
3553
+ };
3554
+ },
3555
+ put(url, filter) {
3556
+ return {
3557
+ adapter: "http",
3558
+ match: buildMatch(filter),
3559
+ method: "PUT",
3560
+ url,
3561
+ wrap: wrapJson
3562
+ };
3563
+ },
3564
+ delete(url, filter) {
3565
+ return {
3566
+ adapter: "http",
3567
+ match: buildMatch(filter),
3568
+ method: "DELETE",
3569
+ url,
3570
+ wrap: wrapJson
3571
+ };
3572
+ },
3573
+ any(url, filter) {
3574
+ return {
3575
+ adapter: "http",
3576
+ match: buildMatch(filter),
3577
+ method: "*",
3578
+ url,
3579
+ wrap: wrapJson
3580
+ };
3581
+ },
3582
+ /** Response: simple JSON success. */
3583
+ json(data, status = 200) {
3584
+ return {
3585
+ status,
3586
+ body: data
3587
+ };
3588
+ },
3589
+ /** Response: error with message. */
3590
+ error(status, message) {
3591
+ return {
3592
+ status,
3593
+ body: { error: message ?? `HTTP ${status}` }
3594
+ };
3595
+ }
3596
+ };
3597
+ //#endregion
3598
+ //#region src/integrations/openai/openai.ts
3599
+ const OPENAI_CHAT_URL = "https://api.openai.com/v1/chat/completions";
3600
+ const OPENAI_RESPONSES_URL = "https://api.openai.com/v1/responses";
3601
+ function matchesChatFilter(body, filter) {
3602
+ if (filter.model) {
3603
+ const model = body?.model;
3604
+ if (typeof filter.model === "string" && model !== filter.model) return false;
3605
+ if (filter.model instanceof RegExp && !filter.model.test(model ?? "")) return false;
3606
+ }
3607
+ if (filter.system) {
3608
+ const msg = body?.messages?.find((m) => m.role === "system")?.content ?? "";
3609
+ if (typeof filter.system === "string" && !msg.includes(filter.system)) return false;
3610
+ if (filter.system instanceof RegExp && !filter.system.test(msg)) return false;
3611
+ }
3612
+ if (filter.user) {
3613
+ const msg = body?.messages?.find((m) => m.role === "user")?.content ?? "";
3614
+ if (typeof filter.user === "string" && !msg.includes(filter.user)) return false;
3615
+ if (filter.user instanceof RegExp && !filter.user.test(msg)) return false;
3616
+ }
3617
+ if (filter.tools) {
3618
+ const names = body?.tools?.map((t) => t.function?.name).filter(Boolean) ?? [];
3619
+ if (!filter.tools.every((t) => names.includes(t))) return false;
3620
+ }
3621
+ if (filter.temperature !== void 0 && body?.temperature !== filter.temperature) return false;
3622
+ return true;
3623
+ }
3624
+ function matchesResponsesFilter(body, filter) {
3625
+ if (filter.model) {
3626
+ const model = body?.model;
3627
+ if (typeof filter.model === "string" && model !== filter.model) return false;
3628
+ if (filter.model instanceof RegExp && !filter.model.test(model ?? "")) return false;
3629
+ }
3630
+ if (filter.system) {
3631
+ const instructions = body?.instructions ?? "";
3632
+ const systemInput = body?.input?.find?.((m) => m.role === "system")?.content ?? "";
3633
+ const text = instructions || systemInput;
3634
+ if (typeof filter.system === "string" && !text.includes(filter.system)) return false;
3635
+ if (filter.system instanceof RegExp && !filter.system.test(text)) return false;
3636
+ }
3637
+ if (filter.user) {
3638
+ const msgs = (body?.input ?? []).filter((m) => m.role === "user").map((m) => typeof m.content === "string" ? m.content : JSON.stringify(m.content)).join(" ");
3639
+ if (typeof filter.user === "string" && !msgs.includes(filter.user)) return false;
3640
+ if (filter.user instanceof RegExp && !filter.user.test(msgs)) return false;
3641
+ }
3642
+ if (filter.tools) {
3643
+ const names = body?.tools?.map((t) => t.name ?? t.function?.name).filter(Boolean) ?? [];
3644
+ if (!filter.tools.every((t) => names.includes(t))) return false;
3645
+ }
3646
+ return true;
3647
+ }
3648
+ function buildChatReply(data) {
3649
+ const content = typeof data === "string" ? data : JSON.stringify(data);
1692
3650
  return {
1693
- kind: "command",
1694
- bin
3651
+ status: 200,
3652
+ body: {
3653
+ id: "chatcmpl-test",
3654
+ object: "chat.completion",
3655
+ created: Math.floor(Date.now() / 1e3),
3656
+ model: "gpt-4o-test",
3657
+ choices: [{
3658
+ index: 0,
3659
+ message: {
3660
+ role: "assistant",
3661
+ content
3662
+ },
3663
+ finish_reason: "stop"
3664
+ }],
3665
+ usage: {
3666
+ prompt_tokens: 10,
3667
+ completion_tokens: 10,
3668
+ total_tokens: 20
3669
+ }
3670
+ }
1695
3671
  };
1696
3672
  }
1697
- //#endregion
1698
- //#region src/spec/legacy-cli.ts
1699
- /**
1700
- * Create a CLI specification runner.
1701
- * Runs CLI commands against fixture projects. Optionally starts infrastructure.
1702
- */
1703
- async function cli(options) {
1704
- const root = resolveProjectRoot(options.root);
1705
- const command = resolveCommand(options.command, root);
1706
- let orchestrator = null;
1707
- let database;
1708
- let databases;
1709
- if (options.services?.length) {
1710
- orchestrator = new Orchestrator({
1711
- mode: "integration",
1712
- root,
1713
- services: options.services
1714
- });
1715
- await orchestrator.start();
1716
- database = orchestrator.getDatabase() ?? void 0;
1717
- const dbMap = orchestrator.getDatabases();
1718
- databases = dbMap.size > 0 ? dbMap : void 0;
1719
- }
1720
- const runner = createSpecificationRunner({
1721
- command: new ExecAdapter(command),
1722
- database,
1723
- databases,
1724
- fixturesRoot: root
1725
- });
1726
- runner.cleanup = async () => {
1727
- if (orchestrator) await orchestrator.stop();
3673
+ function buildResponsesReply(data) {
3674
+ const text = typeof data === "string" ? data : JSON.stringify(data);
3675
+ return {
3676
+ status: 200,
3677
+ body: {
3678
+ id: "resp-test",
3679
+ object: "response",
3680
+ created_at: Math.floor(Date.now() / 1e3),
3681
+ model: "gpt-4o-test",
3682
+ output: [{
3683
+ type: "message",
3684
+ id: "msg-test",
3685
+ role: "assistant",
3686
+ content: [{
3687
+ type: "output_text",
3688
+ text,
3689
+ annotations: []
3690
+ }]
3691
+ }],
3692
+ usage: {
3693
+ input_tokens: 10,
3694
+ output_tokens: 10,
3695
+ total_tokens: 20
3696
+ }
3697
+ }
1728
3698
  };
1729
- runner.orchestrator = orchestrator;
1730
- return runner;
1731
3699
  }
3700
+ /**
3701
+ * OpenAI API intercept helpers.
3702
+ */
3703
+ const openai = {
3704
+ /**
3705
+ * Trigger: match Chat Completions API requests.
3706
+ *
3707
+ * @example
3708
+ * openai.chat() // any chat call
3709
+ * openai.chat({ model: 'gpt-4o' }) // specific model
3710
+ * openai.chat({ system: /classify/ }) // system prompt match
3711
+ */
3712
+ chat(filter) {
3713
+ return {
3714
+ adapter: "openai",
3715
+ method: "POST",
3716
+ url: OPENAI_CHAT_URL,
3717
+ match: filter ? ({ body }) => matchesChatFilter(body, filter) : void 0,
3718
+ wrap: buildChatReply
3719
+ };
3720
+ },
3721
+ /**
3722
+ * Trigger: match Responses API requests (AI SDK v5+) with auto-wrapping.
3723
+ * When used with a JSON file, the data is automatically wrapped in the
3724
+ * Responses API envelope.
3725
+ *
3726
+ * @param filter - Optional body filters.
3727
+ * @param url - Custom gateway URL (default: api.openai.com).
3728
+ *
3729
+ * @example
3730
+ * openai.responses({ user: /Report Ingestion/ }, GATEWAY)
3731
+ */
3732
+ responses(filter, url) {
3733
+ return {
3734
+ adapter: "openai",
3735
+ method: "POST",
3736
+ url: url ?? OPENAI_RESPONSES_URL,
3737
+ match: filter ? ({ body }) => matchesResponsesFilter(body, filter) : void 0,
3738
+ wrap: buildResponsesReply
3739
+ };
3740
+ },
3741
+ /**
3742
+ * Response: wrap data in Chat Completions format.
3743
+ *
3744
+ * @example
3745
+ * openai.reply({ categories: ['TECH'] })
3746
+ */
3747
+ reply: buildChatReply,
3748
+ /** Response: return an OpenAI error. */
3749
+ error(status, message) {
3750
+ return {
3751
+ status,
3752
+ body: { error: {
3753
+ message: message ?? `OpenAI error (${status})`,
3754
+ type: status === 429 ? "rate_limit_error" : "api_error",
3755
+ code: status === 429 ? "rate_limit_exceeded" : null
3756
+ } }
3757
+ };
3758
+ },
3759
+ /** Response: return malformed content. */
3760
+ malformed(content) {
3761
+ return buildChatReply(content);
3762
+ },
3763
+ /** Response: simulate a timeout. */
3764
+ timeout() {
3765
+ return {
3766
+ status: 200,
3767
+ body: {},
3768
+ delay: 3e4
3769
+ };
3770
+ }
3771
+ };
1732
3772
  //#endregion
1733
- //#region src/spec/legacy-e2e.ts
3773
+ //#region src/core/contracts/contract.ts
1734
3774
  /**
1735
- * Create an E2E specification runner.
1736
- * Starts full docker compose stack. App URL and database auto-detected.
3775
+ * Declare an intercept contract. Identity function — its value is the
3776
+ * enforced shape and the naming convention:
3777
+ *
3778
+ * @example
3779
+ * // specs/api/reports/contracts/classify-article.openai.ts
3780
+ * import { defineContract, openai } from '@jterrazz/test';
3781
+ *
3782
+ * export default defineContract({
3783
+ * trigger: openai.responses({ user: /Report Ingestion/, tools: ['classify'] }),
3784
+ * response: openai.reply({ categories: ['TECH'] }),
3785
+ * });
3786
+ *
3787
+ * // Dynamic — the response is computed from the observed request:
3788
+ * export default defineContract({
3789
+ * trigger: http.post('https://api.example.com/echo'),
3790
+ * response: (request) => http.json({ received: request.body }),
3791
+ * });
3792
+ *
3793
+ * // specs/api/reports/reports.test.ts
3794
+ * import classifyArticle from '../../spec/intercept/contracts/classify-article.openai.js';
3795
+ *
3796
+ * const result = await jobs.intercept(classifyArticle).trigger('report-ingestion');
1737
3797
  */
1738
- async function e2e(options = {}) {
1739
- const orchestrator = new Orchestrator({
1740
- mode: "e2e",
1741
- root: resolveProjectRoot(options.root),
1742
- services: []
1743
- });
1744
- await orchestrator.startCompose();
1745
- const appUrl = orchestrator.getAppUrl();
1746
- if (!appUrl) throw new Error("E2E: could not detect app URL from compose. Ensure an app service with ports is defined.");
1747
- const database = orchestrator.getDatabase() ?? void 0;
1748
- const databases = orchestrator.getDatabases();
1749
- const runner = createSpecificationRunner({
1750
- database,
1751
- databases: databases.size > 0 ? databases : void 0,
1752
- server: new FetchAdapter(appUrl)
1753
- });
1754
- runner.cleanup = () => orchestrator.stopCompose();
1755
- runner.orchestrator = orchestrator;
1756
- return runner;
3798
+ function defineContract(contract) {
3799
+ return contract;
1757
3800
  }
1758
3801
  //#endregion
1759
- //#region src/spec/legacy-integration.ts
3802
+ //#region src/vitest/mock-of.ts
1760
3803
  /**
1761
- * Create an integration specification runner.
1762
- * Starts infra containers via testcontainers, app runs in-process.
3804
+ * Create a deep mock proxy for a given type.
3805
+ * Wraps `vitest-mock-extended`'s `mockDeep` for convenient port mocking.
1763
3806
  */
1764
- async function integration(options) {
1765
- const orchestrator = new Orchestrator({
1766
- mode: "integration",
1767
- root: resolveProjectRoot(options.root),
1768
- services: options.services
1769
- });
1770
- await orchestrator.start();
1771
- const app = options.app();
1772
- const database = orchestrator.getDatabase() ?? void 0;
1773
- const databases = orchestrator.getDatabases();
1774
- const runner = createSpecificationRunner({
1775
- database,
1776
- databases: databases.size > 0 ? databases : void 0,
1777
- server: new HonoAdapter(app)
1778
- });
1779
- runner.cleanup = () => orchestrator.stop();
1780
- runner.orchestrator = orchestrator;
1781
- return runner;
1782
- }
3807
+ const mockOf = mockDeep;
3808
+ //#endregion
3809
+ //#region src/vitest/mock-of-date.ts
3810
+ /**
3811
+ * Freeze or reset the global Date for deterministic time-dependent tests.
3812
+ * Wraps the `mockdate` package.
3813
+ */
3814
+ const mockOfDate = MockDatePackage;
3815
+ //#endregion
3816
+ //#region src/index.ts
3817
+ registerContainerIntegrations({
3818
+ createComposeStack: (composeFile, projectName) => new ComposeStackAdapter(composeFile, projectName),
3819
+ createContainer: (options) => new TestcontainersAdapter(options),
3820
+ parseComposeFile
3821
+ });
3822
+ registerComposeServiceFactory("postgres", (service) => postgres({
3823
+ composeService: service.name,
3824
+ env: service.environment
3825
+ }));
3826
+ registerComposeServiceFactory("redis", (service) => redis({ composeService: service.name }));
1783
3827
  //#endregion
1784
- export { DirectoryAccessor, DockerAssertion, ExecAdapter, FetchAdapter, HonoAdapter, Orchestrator, ResponseAccessor, SpecificationBuilder, SpecificationResult, TableAssertion, app, cli, command, createSpecificationRunner, dockerContainer, e2e, grep, integration, mockOf, mockOfDate, normalizeOutput, postgres, redis, spec, sqlite, stack, stripAnsi };
1785
-
1786
- //# sourceMappingURL=index.js.map
3828
+ export { BaseResult, CliResult, ContainerAccessor, DirectoryAccessor, FilesystemAccessor, HttpResult, JsonAccessor, Matcher, Orchestrator, ResponseAccessor, TableAccessor, TextAccessor, anthropic, defineContract, findContainersByLabel, http, inspectContainer, match, mockOf, mockOfDate, openai, postgres, redis, removeContainers, specification, sqlite, text };