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