@jterrazz/test 5.2.0 → 5.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -21,197 +21,182 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
21
21
  enumerable: true
22
22
  }) : target, mod));
23
23
  //#endregion
24
- let mockdate = require("mockdate");
25
- mockdate = __toESM(mockdate);
26
- let vitest_mock_extended = require("vitest-mock-extended");
27
- let node_fs = require("node:fs");
28
- let node_path = require("node:path");
29
24
  let node_child_process = require("node:child_process");
30
- let yaml = require("yaml");
31
- let pg = require("pg");
25
+ let node_fs = require("node:fs");
32
26
  let node_os = require("node:os");
27
+ let node_path = require("node:path");
33
28
  let node_fs_promises = require("node:fs/promises");
34
- //#region src/mocking/mock-of-date.ts
35
- const mockOfDate = mockdate.default;
36
- //#endregion
37
- //#region src/mocking/mock-of.ts
38
- const mockOf = vitest_mock_extended.mockDeep;
39
- //#endregion
40
- //#region src/infrastructure/adapters/compose.adapter.ts
29
+ let pg = require("pg");
30
+ let yaml = require("yaml");
31
+ let mockdate = require("mockdate");
32
+ mockdate = __toESM(mockdate);
33
+ let vitest_mock_extended = require("vitest-mock-extended");
34
+ //#region src/adapters/exec.adapter.ts
41
35
  /**
42
- * Start the full compose stack and stop it all on cleanup.
36
+ * Build a child-process env from the parent env plus user overrides.
37
+ * `null` overrides delete keys (e.g. `INIT_CWD: null`).
43
38
  */
44
- var ComposeStackAdapter = class {
45
- composeFile;
46
- started = false;
47
- constructor(composeFile) {
48
- this.composeFile = composeFile;
49
- }
50
- run(command) {
51
- try {
52
- return (0, node_child_process.execSync)(command, {
53
- cwd: (0, node_path.dirname)(this.composeFile),
54
- encoding: "utf8",
55
- timeout: 12e4
56
- }).trim();
57
- } catch (error) {
58
- const stderr = error.stderr?.toString().trim() ?? error.message;
59
- throw new Error(`docker compose failed: ${stderr}`, { cause: error });
60
- }
61
- }
62
- async start() {
63
- if (this.started) return;
64
- this.run(`docker compose -f ${this.composeFile} up -d --wait`);
65
- this.started = true;
66
- }
67
- async stop() {
68
- if (!this.started) return;
69
- this.run(`docker compose -f ${this.composeFile} down -v`);
70
- this.started = false;
71
- }
72
- getMappedPort(serviceName, containerPort) {
73
- const port = this.run(`docker compose -f ${this.composeFile} port ${serviceName} ${containerPort}`).split(":").pop();
74
- return Number(port);
75
- }
76
- getHost() {
77
- return "localhost";
78
- }
79
- };
80
- //#endregion
81
- //#region src/infrastructure/adapters/testcontainers.adapter.ts
39
+ function buildEnv(extra) {
40
+ const env = {
41
+ ...process.env,
42
+ INIT_CWD: void 0
43
+ };
44
+ if (extra) for (const [key, value] of Object.entries(extra)) if (value === null) delete env[key];
45
+ else env[key] = value;
46
+ return env;
47
+ }
82
48
  /**
83
- * Container adapter using testcontainers.
84
- * Wraps a GenericContainer for programmatic container lifecycle.
49
+ * Executes CLI commands via execSync (blocking) or spawn (long-running).
50
+ * Used by cli() for local command execution.
85
51
  */
86
- var TestcontainersAdapter = class {
87
- image;
88
- containerPort;
89
- env;
90
- reuse;
91
- container = null;
92
- constructor(options) {
93
- this.image = options.image;
94
- this.containerPort = options.port;
95
- this.env = options.env ?? {};
96
- this.reuse = options.reuse ?? false;
97
- }
98
- async start() {
99
- const { GenericContainer, Wait } = await import("testcontainers");
100
- let builder = new GenericContainer(this.image).withExposedPorts(this.containerPort);
101
- for (const [key, value] of Object.entries(this.env)) builder = builder.withEnvironment({ [key]: value });
102
- if (this.image.startsWith("postgres")) builder = builder.withWaitStrategy(Wait.forLogMessage(/database system is ready to accept connections/, 2));
103
- if (this.reuse) builder = builder.withReuse();
104
- this.container = await builder.start();
52
+ var ExecAdapter = class {
53
+ command;
54
+ constructor(command) {
55
+ this.command = command;
105
56
  }
106
- async stop() {
107
- if (this.container && !this.reuse) {
108
- await this.container.stop();
109
- this.container = null;
57
+ async exec(args, cwd, extraEnv) {
58
+ const env = buildEnv(extraEnv);
59
+ try {
60
+ return {
61
+ exitCode: 0,
62
+ stdout: (0, node_child_process.execSync)(`${this.command} ${args}`, {
63
+ cwd,
64
+ encoding: "utf8",
65
+ env,
66
+ stdio: [
67
+ "pipe",
68
+ "pipe",
69
+ "pipe"
70
+ ]
71
+ }),
72
+ stderr: ""
73
+ };
74
+ } catch (error) {
75
+ return {
76
+ exitCode: error.status ?? 1,
77
+ stdout: error.stdout?.toString() ?? "",
78
+ stderr: error.stderr?.toString() ?? ""
79
+ };
110
80
  }
111
81
  }
112
- getMappedPort(containerPort) {
113
- if (!this.container) throw new Error("Container not started");
114
- return this.container.getMappedPort(containerPort);
115
- }
116
- getHost() {
117
- if (!this.container) throw new Error("Container not started");
118
- return this.container.getHost();
119
- }
120
- getConnectionString() {
121
- return `${this.getHost()}:${this.getMappedPort(this.containerPort)}`;
122
- }
123
- async getLogs() {
124
- if (!this.container) return "";
125
- const stream = await this.container.logs();
82
+ async spawn(args, cwd, options, extraEnv) {
83
+ const env = buildEnv(extraEnv);
126
84
  return new Promise((resolve) => {
127
- let output = "";
128
- stream.on("data", (chunk) => {
129
- output += chunk.toString();
85
+ let stdout = "";
86
+ let stderr = "";
87
+ let resolved = false;
88
+ const child = (0, node_child_process.spawn)(this.command, args.split(/\s+/).filter(Boolean), {
89
+ cwd,
90
+ env,
91
+ stdio: [
92
+ "pipe",
93
+ "pipe",
94
+ "pipe"
95
+ ]
130
96
  });
131
- stream.on("end", () => {
132
- resolve(output);
97
+ const finish = (exitCode) => {
98
+ if (resolved) return;
99
+ resolved = true;
100
+ child.kill("SIGTERM");
101
+ resolve({
102
+ exitCode,
103
+ stdout,
104
+ stderr
105
+ });
106
+ };
107
+ let patternMatched = false;
108
+ const checkPattern = () => {
109
+ if (!patternMatched && (stdout.includes(options.waitFor) || stderr.includes(options.waitFor))) {
110
+ patternMatched = true;
111
+ finish(0);
112
+ }
113
+ };
114
+ child.stdout?.on("data", (data) => {
115
+ stdout += data.toString();
116
+ checkPattern();
133
117
  });
134
- setTimeout(() => {
135
- resolve(output);
136
- }, 1e3);
118
+ child.stderr?.on("data", (data) => {
119
+ stderr += data.toString();
120
+ checkPattern();
121
+ });
122
+ child.on("exit", (code) => {
123
+ if (!patternMatched) finish(code === 0 ? 1 : code ?? 1);
124
+ });
125
+ setTimeout(() => finish(124), options.timeout);
137
126
  });
138
127
  }
139
128
  };
140
129
  //#endregion
141
- //#region src/infrastructure/compose-parser.ts
130
+ //#region src/utilities/directory.ts
142
131
  /**
143
- * Detect the service type from the image name.
132
+ * Default ignore patterns paths that should never appear in a tracked snapshot.
133
+ * Each entry is matched against any path segment OR a path prefix.
144
134
  */
145
- function detectServiceType(image) {
146
- if (!image) return "app";
147
- const lower = image.toLowerCase();
148
- if (lower.startsWith("postgres")) return "postgres";
149
- if (lower.startsWith("redis")) return "redis";
150
- return "unknown";
151
- }
135
+ const DEFAULT_IGNORES = [
136
+ ".git",
137
+ ".DS_Store",
138
+ "node_modules",
139
+ ".next",
140
+ "dist",
141
+ ".turbo",
142
+ ".cache"
143
+ ];
152
144
  /**
153
- * Find the compose file in the project.
154
- * Looks for docker/compose.test.yaml or docker-compose.test.yaml.
145
+ * Recursively walk a directory, returning sorted relative paths of files only.
146
+ * Ignored entries (default + caller-supplied) are skipped.
155
147
  */
156
- function findComposeFile(projectRoot) {
157
- const candidates = [
158
- (0, node_path.resolve)(projectRoot, "docker/compose.test.yaml"),
159
- (0, node_path.resolve)(projectRoot, "docker/compose.test.yml"),
160
- (0, node_path.resolve)(projectRoot, "docker-compose.test.yaml"),
161
- (0, node_path.resolve)(projectRoot, "docker-compose.test.yml")
162
- ];
163
- for (const candidate of candidates) if ((0, node_fs.existsSync)(candidate)) return candidate;
164
- return null;
148
+ async function walkDirectory(root, options = {}) {
149
+ const ignores = new Set([...DEFAULT_IGNORES, ...options.ignore ?? []]);
150
+ const out = [];
151
+ async function walk(current) {
152
+ let entries;
153
+ try {
154
+ entries = await (0, node_fs_promises.readdir)(current);
155
+ } catch {
156
+ return;
157
+ }
158
+ for (const entry of entries) {
159
+ if (ignores.has(entry)) continue;
160
+ const abs = (0, node_path.resolve)(current, entry);
161
+ const stat = (0, node_fs.statSync)(abs);
162
+ if (stat.isDirectory()) await walk(abs);
163
+ else if (stat.isFile()) out.push((0, node_path.relative)(root, abs).split(node_path.sep).join("/"));
164
+ }
165
+ }
166
+ await walk(root);
167
+ out.sort();
168
+ return out;
165
169
  }
166
170
  /**
167
- * Parse a docker-compose file and extract service definitions.
171
+ * Compare two directory trees file-by-file.
172
+ * Binary files are compared by byte equality but reported without inline diff.
168
173
  */
169
- function parseComposeFile(filePath) {
170
- const doc = (0, yaml.parse)((0, node_fs.readFileSync)(filePath, "utf8"));
171
- if (!doc?.services) return {
172
- services: [],
173
- appService: null,
174
- infraServices: []
175
- };
176
- const services = Object.entries(doc.services).map(([name, def]) => {
177
- const ports = [];
178
- if (def.ports) for (const port of def.ports) {
179
- const str = String(port);
180
- if (str.includes(":")) {
181
- const [host, container] = str.split(":");
182
- ports.push({
183
- container: Number(container),
184
- host: Number(host)
185
- });
186
- } else ports.push({ container: Number(str) });
187
- }
188
- const environment = {};
189
- if (def.environment) if (Array.isArray(def.environment)) for (const env of def.environment) {
190
- const [key, ...rest] = String(env).split("=");
191
- environment[key] = rest.join("=");
192
- }
193
- else Object.assign(environment, def.environment);
194
- const volumes = def.volumes ? def.volumes.map((v) => String(v)) : [];
195
- let dependsOn = [];
196
- if (def.depends_on) dependsOn = Array.isArray(def.depends_on) ? def.depends_on : Object.keys(def.depends_on);
197
- return {
198
- name,
199
- image: def.image,
200
- build: def.build,
201
- ports,
202
- environment,
203
- volumes,
204
- dependsOn
205
- };
206
- });
174
+ async function diffDirectories(expectedRoot, actualRoot, options = {}) {
175
+ const expectedFiles = await walkDirectory(expectedRoot, options);
176
+ const actualFiles = await walkDirectory(actualRoot, options);
177
+ const expectedSet = new Set(expectedFiles);
178
+ const actualSet = new Set(actualFiles);
179
+ const added = actualFiles.filter((f) => !expectedSet.has(f));
180
+ const removed = expectedFiles.filter((f) => !actualSet.has(f));
181
+ const changed = [];
182
+ for (const file of expectedFiles) {
183
+ if (!actualSet.has(file)) continue;
184
+ const expected = (0, node_fs.readFileSync)((0, node_path.resolve)(expectedRoot, file), "utf8");
185
+ const actual = (0, node_fs.readFileSync)((0, node_path.resolve)(actualRoot, file), "utf8");
186
+ if (expected !== actual) changed.push({
187
+ actual,
188
+ expected,
189
+ path: file
190
+ });
191
+ }
207
192
  return {
208
- services,
209
- appService: services.find((s) => s.build !== void 0) ?? null,
210
- infraServices: services.filter((s) => s.build === void 0)
193
+ added,
194
+ changed,
195
+ removed
211
196
  };
212
197
  }
213
198
  //#endregion
214
- //#region src/infrastructure/reporter.ts
199
+ //#region src/utilities/reporter.ts
215
200
  const GREEN = "\x1B[32m";
216
201
  const RED = "\x1B[31m";
217
202
  const DIM = "\x1B[2m";
@@ -347,915 +332,859 @@ function normalizeOutput(str) {
347
332
  return stripAnsi(str).replace(/localhost:\d+/g, "localhost:PORT").replace(/\d+ms/g, "Xms").replace(/\d+\.\d+s/g, "X.Xs").trim();
348
333
  }
349
334
  //#endregion
350
- //#region src/infrastructure/services/postgres.ts
351
- var PostgresHandle = class {
352
- type = "postgres";
353
- composeName;
354
- defaultPort = 5432;
355
- defaultImage;
356
- environment;
357
- connectionString = "";
358
- started = false;
359
- client = null;
360
- constructor(options = {}) {
361
- this.composeName = options.compose ?? null;
362
- this.defaultImage = options.image ?? "postgres:17";
363
- this.environment = {
364
- POSTGRES_DB: "test",
365
- POSTGRES_PASSWORD: "test",
366
- POSTGRES_USER: "test",
367
- ...options.env
368
- };
369
- }
370
- buildConnectionString(host, port) {
371
- return `postgresql://${this.environment.POSTGRES_USER ?? "test"}:${this.environment.POSTGRES_PASSWORD ?? "test"}@${host}:${port}/${this.environment.POSTGRES_DB ?? "test"}`;
372
- }
373
- createDatabaseAdapter() {
374
- return this;
375
- }
376
- async healthcheck() {
377
- if (!this.connectionString) throw new Error("postgres: cannot healthcheck — no connection string");
378
- try {
379
- const client = new pg.Client({ connectionString: this.connectionString });
380
- await client.connect();
381
- await client.query("SELECT 1");
382
- await client.end();
383
- } catch (error) {
384
- throw new Error(`postgres healthcheck failed: ${error.message || error.code || String(error)}`, { cause: error });
385
- }
335
+ //#region src/builder/directory-accessor.ts
336
+ /**
337
+ * Detect whether the user wants to update snapshots — `true` for any of:
338
+ * - vitest run with `-u` / `--update`
339
+ * - JTERRAZZ_TEST_UPDATE=1
340
+ * - UPDATE_SNAPSHOTS=1
341
+ */
342
+ function shouldUpdateSnapshots() {
343
+ if (process.env.JTERRAZZ_TEST_UPDATE === "1") return true;
344
+ if (process.env.UPDATE_SNAPSHOTS === "1") return true;
345
+ if (process.argv.includes("-u") || process.argv.includes("--update")) return true;
346
+ return false;
347
+ }
348
+ var DirectoryAccessor = class {
349
+ absPath;
350
+ testDir;
351
+ constructor(absPath, testDir) {
352
+ this.absPath = absPath;
353
+ this.testDir = testDir;
386
354
  }
387
- async initialize(composeDir) {
388
- if (!this.composeName) return;
389
- const initPaths = [(0, node_path.resolve)(composeDir, `${this.composeName}/init.sql`), (0, node_path.resolve)(composeDir, "postgres/init.sql")];
390
- for (const initPath of initPaths) if ((0, node_fs.existsSync)(initPath)) {
391
- const sql = (0, node_fs.readFileSync)(initPath, "utf8");
392
- try {
393
- await this.seed(sql);
394
- } catch (error) {
395
- throw new Error(`postgres init script failed (${initPath}):\n${error.message}`, { cause: error });
396
- }
355
+ /**
356
+ * Compare the directory tree against `expected/{name}/` (relative to the test file).
357
+ * On mismatch, throws with a structured diff. With update mode enabled, the
358
+ * fixture is overwritten with the current contents instead.
359
+ */
360
+ async toMatchFixture(name, options = {}) {
361
+ const fixtureDir = (0, node_path.resolve)(this.testDir, "expected", name);
362
+ if (options.update ?? shouldUpdateSnapshots()) {
363
+ (0, node_fs.rmSync)(fixtureDir, {
364
+ force: true,
365
+ recursive: true
366
+ });
367
+ (0, node_fs.mkdirSync)(fixtureDir, { recursive: true });
368
+ (0, node_fs.cpSync)(this.absPath, fixtureDir, { recursive: true });
397
369
  return;
398
370
  }
371
+ 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.`);
372
+ const diff = await diffDirectories(fixtureDir, this.absPath, { ignore: options.ignore });
373
+ if (diff.added.length === 0 && diff.removed.length === 0 && diff.changed.length === 0) return;
374
+ throw new Error(formatDirectoryDiff(name, diff, "Run with JTERRAZZ_TEST_UPDATE=1 to update the fixture."));
399
375
  }
400
- async getClient() {
401
- if (this.client) return this.client;
402
- const client = new pg.Client({ connectionString: this.connectionString });
403
- client.on("error", () => {
404
- this.client = null;
405
- });
406
- await client.connect();
407
- this.client = client;
408
- return client;
409
- }
410
- async seed(sql) {
411
- await (await this.getClient()).query(sql);
412
- }
413
- async query(table, columns) {
414
- const client = await this.getClient();
415
- const columnList = columns.join(", ");
416
- return (await client.query(`SELECT ${columnList} FROM "${table}" ORDER BY 1`)).rows.map((row) => columns.map((col) => row[col]));
417
- }
418
- async reset() {
419
- const client = await this.getClient();
420
- const result = await client.query(`
421
- SELECT tablename FROM pg_tables
422
- WHERE schemaname = 'public'
423
- AND tablename NOT LIKE '_prisma%'
424
- `);
425
- for (const row of result.rows) await client.query(`TRUNCATE "${row.tablename}" CASCADE`);
376
+ /**
377
+ * List all files in the directory (recursive, sorted, ignoring defaults).
378
+ * Useful for ad-hoc assertions when you don't want a full snapshot.
379
+ */
380
+ async files(options = {}) {
381
+ return walkDirectory(this.absPath, options);
426
382
  }
427
383
  };
428
- /**
429
- * Create a PostgreSQL service handle.
430
- *
431
- * @example
432
- * const db = postgres({ compose: "db" });
433
- * // After start: db.connectionString is populated
434
- */
435
- function postgres(options = {}) {
436
- return new PostgresHandle(options);
437
- }
438
384
  //#endregion
439
- //#region src/infrastructure/services/redis.ts
440
- var RedisHandle = class {
441
- type = "redis";
442
- composeName;
443
- defaultPort = 6379;
444
- defaultImage;
445
- environment = {};
446
- connectionString = "";
447
- started = false;
448
- constructor(options = {}) {
449
- this.composeName = options.compose ?? null;
450
- this.defaultImage = options.image ?? "redis:7";
385
+ //#region src/builder/response-accessor.ts
386
+ var ResponseAccessor = class {
387
+ body;
388
+ testDir;
389
+ constructor(body, testDir) {
390
+ this.body = body;
391
+ this.testDir = testDir;
451
392
  }
452
- buildConnectionString(host, port) {
453
- return `redis://${host}:${port}`;
393
+ toMatchFile(file) {
394
+ const expected = JSON.parse((0, node_fs.readFileSync)((0, node_path.resolve)(this.testDir, "responses", file), "utf8"));
395
+ if (JSON.stringify(this.body) !== JSON.stringify(expected)) throw new Error(formatResponseDiff(file, expected, this.body));
454
396
  }
455
- createDatabaseAdapter() {
456
- return null;
397
+ };
398
+ //#endregion
399
+ //#region src/builder/table-assertion.ts
400
+ var TableAssertion = class {
401
+ tableName;
402
+ db;
403
+ constructor(tableName, db) {
404
+ this.tableName = tableName;
405
+ this.db = db;
457
406
  }
458
- async healthcheck() {
459
- if (!this.connectionString) throw new Error("redis: cannot healthcheck — no connection string");
460
- try {
461
- const { createClient } = await import("redis");
462
- const client = createClient({ url: this.connectionString });
463
- await client.connect();
464
- await client.ping();
465
- await client.disconnect();
466
- } catch (error) {
467
- throw new Error(`redis healthcheck failed: ${error.message || error.code || String(error)}`, { cause: error });
468
- }
407
+ async toMatch(expected) {
408
+ const actual = await this.db.query(this.tableName, expected.columns);
409
+ if (JSON.stringify(actual) !== JSON.stringify(expected.rows)) throw new Error(formatTableDiff(this.tableName, expected.columns, expected.rows, actual));
469
410
  }
470
- async initialize() {}
471
- async reset() {
472
- const { createClient } = await import("redis");
473
- const client = createClient({ url: this.connectionString });
474
- await client.connect();
475
- try {
476
- await client.flushAll();
477
- } finally {
478
- await client.disconnect();
479
- }
411
+ async toBeEmpty() {
412
+ const actual = await this.db.query(this.tableName, ["*"]);
413
+ if (actual.length !== 0) throw new Error(`Expected table "${this.tableName}" to be empty, but it has ${actual.length} rows`);
480
414
  }
481
415
  };
482
- /**
483
- * Create a Redis service handle.
484
- *
485
- * @example
486
- * const cache = redis({ compose: "cache" });
487
- * // After start: cache.connectionString is populated
488
- */
489
- function redis(options = {}) {
490
- return new RedisHandle(options);
491
- }
492
416
  //#endregion
493
- //#region src/infrastructure/orchestrator.ts
494
- /**
495
- * Orchestrator for test infrastructure.
496
- * Integration: starts services via testcontainers.
497
- * E2E: runs full docker compose up.
498
- */
499
- var Orchestrator = class {
500
- services;
501
- mode;
502
- root;
503
- running = [];
504
- composeStack = null;
505
- composeHandles = [];
506
- started = false;
507
- constructor(options) {
508
- this.services = options.services;
509
- this.mode = options.mode;
510
- this.root = options.root ?? process.cwd();
417
+ //#region src/builder/specification-result.ts
418
+ var SpecificationResult = class {
419
+ commandResult;
420
+ config;
421
+ requestInfo;
422
+ responseData;
423
+ testDir;
424
+ workDir;
425
+ constructor(options) {
426
+ this.responseData = options.response;
427
+ this.commandResult = options.commandResult;
428
+ this.config = options.config;
429
+ this.testDir = options.testDir;
430
+ this.requestInfo = options.requestInfo;
431
+ this.workDir = options.workDir;
511
432
  }
512
- /**
513
- * Start declared services via testcontainers (integration mode).
514
- * Phase 1: start all containers in parallel (the slow part).
515
- * Phase 2: wire connections, healthcheck, and init sequentially (fast).
516
- */
517
- async start() {
518
- if (this.started) return;
519
- const composePath = findComposeFile(this.root);
520
- const composeDir = composePath ? (0, node_path.dirname)(composePath) : this.root;
521
- const composeConfig = composePath ? parseComposeFile(composePath) : null;
522
- const containerTasks = this.services.map((handle) => {
523
- let image = handle.defaultImage;
524
- let env = { ...handle.environment };
525
- if (handle.composeName && composeConfig) {
526
- const composeService = composeConfig.services.find((s) => s.name === handle.composeName);
527
- if (composeService) {
528
- image = composeService.image ?? image;
529
- env = {
530
- ...env,
531
- ...composeService.environment
532
- };
533
- Object.assign(handle.environment, composeService.environment);
534
- }
535
- }
536
- return {
537
- container: new TestcontainersAdapter({
538
- image,
539
- port: handle.defaultPort,
540
- env
541
- }),
542
- handle
543
- };
544
- });
545
- await Promise.all(containerTasks.map(({ container }) => container.start()));
546
- const reports = [];
547
- for (const { container, handle } of containerTasks) {
548
- const serviceStartTime = Date.now();
549
- try {
550
- const host = container.getHost();
551
- const port = container.getMappedPort(handle.defaultPort);
552
- handle.connectionString = handle.buildConnectionString(host, port);
553
- await handle.healthcheck();
554
- await handle.initialize(composeDir);
555
- handle.started = true;
556
- reports.push({
557
- name: handle.composeName ?? handle.type,
558
- type: handle.type,
559
- connectionString: handle.connectionString,
560
- durationMs: Date.now() - serviceStartTime
561
- });
562
- this.running.push({
563
- handle,
564
- container
565
- });
566
- } catch (error) {
567
- let logs = "";
568
- try {
569
- logs = await container.getLogs();
570
- } catch {}
571
- try {
572
- await container.stop();
573
- } catch {}
574
- reports.push({
575
- name: handle.composeName ?? handle.type,
576
- type: handle.type,
577
- durationMs: Date.now() - serviceStartTime,
578
- error: error.message,
579
- logs
580
- });
581
- const output = formatStartupReport("integration", reports, { type: "in-process" });
582
- console.error(output);
583
- throw error;
584
- }
585
- }
586
- this.started = true;
587
- const output = formatStartupReport("integration", reports, { type: "in-process" });
588
- console.log(output);
433
+ get exitCode() {
434
+ if (!this.commandResult) throw new Error(".exitCode requires a CLI action (.exec())");
435
+ return this.commandResult.exitCode;
589
436
  }
590
- /**
591
- * Stop testcontainers (integration mode).
592
- */
593
- async stop() {
594
- for (const { container } of this.running) if (container) await container.stop();
595
- this.running = [];
596
- this.started = false;
437
+ get status() {
438
+ if (!this.responseData) throw new Error(".status requires an HTTP action (.get(), .post(), etc.)");
439
+ return this.responseData.status;
597
440
  }
598
- /**
599
- * Start full docker compose stack (e2e mode).
600
- * Auto-detects infra services and creates handles for them.
601
- */
602
- async startCompose() {
603
- const composePath = findComposeFile(this.root);
604
- if (!composePath) throw new Error(`E2E: no compose file found in ${this.root}`);
605
- const startTime = Date.now();
606
- const composeDir = (0, node_path.dirname)(composePath);
607
- const composeConfig = parseComposeFile(composePath);
608
- this.composeStack = new ComposeStackAdapter(composePath);
609
- await this.composeStack.start();
610
- for (const service of composeConfig.infraServices) {
611
- const type = detectServiceType(service.image);
612
- if (type === "postgres") {
613
- const handle = postgres({
614
- compose: service.name,
615
- env: service.environment
616
- });
617
- const port = this.composeStack.getMappedPort(service.name, 5432);
618
- handle.connectionString = handle.buildConnectionString("localhost", port);
619
- await handle.initialize(composeDir);
620
- handle.started = true;
621
- this.composeHandles.push(handle);
622
- } else if (type === "redis") {
623
- const handle = redis({ compose: service.name });
624
- const port = this.composeStack.getMappedPort(service.name, 6379);
625
- handle.connectionString = handle.buildConnectionString("localhost", port);
626
- handle.started = true;
627
- this.composeHandles.push(handle);
628
- }
629
- }
630
- const durationMs = Date.now() - startTime;
631
- const output = formatStartupReport("e2e", this.composeHandles.map((h) => ({
632
- name: h.composeName ?? h.type,
633
- type: h.type,
634
- connectionString: h.connectionString,
635
- durationMs
636
- })), {
637
- type: "http",
638
- url: this.getAppUrl() ?? void 0
639
- });
640
- console.log(output);
441
+ get stdout() {
442
+ if (!this.commandResult) throw new Error(".stdout requires a CLI action (.exec())");
443
+ return this.commandResult.stdout;
641
444
  }
642
- /**
643
- * Stop docker compose stack (e2e mode).
644
- */
645
- async stopCompose() {
646
- if (this.composeStack) {
647
- await this.composeStack.stop();
648
- this.composeStack = null;
649
- }
650
- this.composeHandles = [];
445
+ get stderr() {
446
+ if (!this.commandResult) throw new Error(".stderr requires a CLI action (.exec())");
447
+ return this.commandResult.stderr;
651
448
  }
652
- /**
653
- * Get a database service by compose name, or the first one if no name given.
654
- */
655
- getDatabase(serviceName) {
656
- for (const handle of [...this.services, ...this.composeHandles]) {
657
- if (serviceName && handle.composeName !== serviceName) continue;
658
- const adapter = handle.createDatabaseAdapter();
659
- if (adapter) return adapter;
660
- }
661
- return null;
449
+ get response() {
450
+ if (!this.responseData) throw new Error(".response requires an HTTP action (.get(), .post(), etc.)");
451
+ return new ResponseAccessor(this.responseData.body, this.testDir);
662
452
  }
663
- /**
664
- * Get all database services keyed by compose name.
665
- */
666
- getDatabases() {
667
- const map = /* @__PURE__ */ new Map();
668
- for (const handle of [...this.services, ...this.composeHandles]) {
669
- const adapter = handle.createDatabaseAdapter();
670
- if (adapter && handle.composeName) map.set(handle.composeName, adapter);
671
- }
672
- return map;
453
+ directory(path = ".") {
454
+ return new DirectoryAccessor((0, node_path.resolve)(this.workDir ?? this.testDir, path), this.testDir);
673
455
  }
674
- /**
675
- * Get app URL from compose (e2e mode).
676
- */
677
- getAppUrl() {
678
- const composePath = findComposeFile(this.root);
679
- if (!composePath || !this.composeStack) return null;
680
- const appService = parseComposeFile(composePath).appService;
681
- if (!appService || appService.ports.length === 0) return null;
682
- return `http://localhost:${this.composeStack.getMappedPort(appService.name, appService.ports[0].container)}`;
456
+ file(path) {
457
+ const resolvedPath = (0, node_path.resolve)(this.workDir ?? this.testDir, path);
458
+ const exists = (0, node_fs.existsSync)(resolvedPath);
459
+ return {
460
+ get content() {
461
+ if (!exists) throw new Error(`File not found: ${path}`);
462
+ return (0, node_fs.readFileSync)(resolvedPath, "utf8");
463
+ },
464
+ exists
465
+ };
466
+ }
467
+ table(tableName, options) {
468
+ const db = this.resolveDatabase(options?.service);
469
+ if (!db) throw new Error(options?.service ? `table("${tableName}") requires database "${options.service}" but it was not found` : `table("${tableName}") requires a database adapter`);
470
+ return new TableAssertion(tableName, db);
471
+ }
472
+ resolveDatabase(serviceName) {
473
+ if (serviceName && this.config.databases) return this.config.databases.get(serviceName);
474
+ return this.config.database;
683
475
  }
684
476
  };
685
477
  //#endregion
686
- //#region src/specification/adapters/exec.adapter.ts
687
- /**
688
- * Build a child-process env from the parent env plus user overrides.
689
- * `null` overrides delete keys (e.g. `INIT_CWD: null`).
690
- */
691
- function buildEnv(extra) {
692
- const env = {
693
- ...process.env,
694
- INIT_CWD: void 0
695
- };
696
- if (extra) for (const [key, value] of Object.entries(extra)) if (value === null) delete env[key];
697
- else env[key] = value;
698
- return env;
699
- }
700
- /**
701
- * Executes CLI commands via execSync (blocking) or spawn (long-running).
702
- * Used by cli() for local command execution.
703
- */
704
- var ExecAdapter = class {
705
- command;
706
- constructor(command) {
707
- this.command = command;
478
+ //#region src/builder/specification-builder.ts
479
+ var SpecificationBuilder = class {
480
+ commandArgs = null;
481
+ commandEnv = {};
482
+ config;
483
+ fixtures = [];
484
+ label;
485
+ mocks = [];
486
+ projectName = null;
487
+ request = null;
488
+ seeds = [];
489
+ spawnConfig = null;
490
+ testDir;
491
+ constructor(config, testDir, label) {
492
+ this.config = config;
493
+ this.testDir = testDir;
494
+ this.label = label;
708
495
  }
709
- async exec(args, cwd, extraEnv) {
710
- const env = buildEnv(extraEnv);
711
- try {
712
- return {
713
- exitCode: 0,
714
- stdout: (0, node_child_process.execSync)(`${this.command} ${args}`, {
715
- cwd,
716
- encoding: "utf8",
717
- env,
718
- stdio: [
719
- "pipe",
720
- "pipe",
721
- "pipe"
722
- ]
723
- }),
724
- stderr: ""
725
- };
726
- } catch (error) {
727
- return {
728
- exitCode: error.status ?? 1,
729
- stdout: error.stdout?.toString() ?? "",
730
- stderr: error.stderr?.toString() ?? ""
731
- };
732
- }
733
- }
734
- async spawn(args, cwd, options, extraEnv) {
735
- const env = buildEnv(extraEnv);
736
- return new Promise((resolve) => {
737
- let stdout = "";
738
- let stderr = "";
739
- let resolved = false;
740
- const child = (0, node_child_process.spawn)(this.command, args.split(/\s+/).filter(Boolean), {
741
- cwd,
742
- env,
743
- stdio: [
744
- "pipe",
745
- "pipe",
746
- "pipe"
747
- ]
748
- });
749
- const finish = (exitCode) => {
750
- if (resolved) return;
751
- resolved = true;
752
- child.kill("SIGTERM");
753
- resolve({
754
- exitCode,
755
- stdout,
756
- stderr
757
- });
758
- };
759
- let patternMatched = false;
760
- const checkPattern = () => {
761
- if (!patternMatched && (stdout.includes(options.waitFor) || stderr.includes(options.waitFor))) {
762
- patternMatched = true;
763
- finish(0);
764
- }
765
- };
766
- child.stdout?.on("data", (data) => {
767
- stdout += data.toString();
768
- checkPattern();
769
- });
770
- child.stderr?.on("data", (data) => {
771
- stderr += data.toString();
772
- checkPattern();
773
- });
774
- child.on("exit", (code) => {
775
- if (!patternMatched) finish(code === 0 ? 1 : code ?? 1);
776
- });
777
- setTimeout(() => finish(124), options.timeout);
496
+ seed(file, options) {
497
+ this.seeds.push({
498
+ file,
499
+ service: options?.service
778
500
  });
501
+ return this;
779
502
  }
780
- };
781
- //#endregion
782
- //#region src/specification/adapters/fetch.adapter.ts
783
- /**
784
- * Server adapter for real HTTP — sends actual fetch requests.
785
- * Used by e2e() specification runner.
786
- */
787
- var FetchAdapter = class {
788
- baseUrl;
789
- constructor(url) {
790
- this.baseUrl = url.replace(/\/$/, "");
503
+ fixture(file) {
504
+ this.fixtures.push({ file });
505
+ return this;
791
506
  }
792
- async request(method, path, body) {
793
- const init = {
794
- method,
795
- headers: { "Content-Type": "application/json" }
507
+ project(name) {
508
+ this.projectName = name;
509
+ return this;
510
+ }
511
+ mock(file) {
512
+ this.mocks.push({ file });
513
+ return this;
514
+ }
515
+ /**
516
+ * Set environment variables for the CLI process. Merged on top of process.env.
517
+ * Use `null` to unset a variable. Multiple calls merge.
518
+ *
519
+ * The token `$WORKDIR` (in any value) is replaced with the actual working
520
+ * directory at run-time — useful for tests that need a fully isolated `HOME`.
521
+ *
522
+ * @example
523
+ * spec("...").env({ HOME: "$WORKDIR", TZ: "UTC" }).exec("status").run();
524
+ */
525
+ env(env) {
526
+ this.commandEnv = {
527
+ ...this.commandEnv,
528
+ ...env
796
529
  };
797
- if (body !== void 0) init.body = JSON.stringify(body);
798
- const response = await fetch(`${this.baseUrl}${path}`, init);
799
- const responseBody = await response.json().catch(() => null);
800
- const headers = {};
801
- response.headers.forEach((value, key) => {
802
- headers[key] = value;
803
- });
804
- return {
805
- status: response.status,
806
- body: responseBody,
807
- headers
530
+ return this;
531
+ }
532
+ get(path) {
533
+ this.request = {
534
+ method: "GET",
535
+ path
536
+ };
537
+ return this;
538
+ }
539
+ post(path, bodyFile) {
540
+ this.request = {
541
+ bodyFile,
542
+ method: "POST",
543
+ path
808
544
  };
545
+ return this;
809
546
  }
810
- };
811
- //#endregion
812
- //#region src/specification/adapters/hono.adapter.ts
813
- /**
814
- * Server adapter for Hono — in-process requests, no real HTTP.
815
- * Used by integration() specification runner.
816
- */
817
- var HonoAdapter = class {
818
- app;
819
- constructor(app) {
820
- this.app = app;
547
+ put(path, bodyFile) {
548
+ this.request = {
549
+ bodyFile,
550
+ method: "PUT",
551
+ path
552
+ };
553
+ return this;
821
554
  }
822
- async request(method, path, body) {
823
- const init = {
824
- method,
825
- headers: { "Content-Type": "application/json" }
555
+ delete(path) {
556
+ this.request = {
557
+ method: "DELETE",
558
+ path
826
559
  };
827
- if (body !== void 0) init.body = JSON.stringify(body);
828
- const response = await this.app.request(path, init);
829
- const responseBody = await response.json().catch(() => null);
830
- const headers = {};
831
- response.headers.forEach((value, key) => {
832
- headers[key] = value;
833
- });
834
- return {
835
- status: response.status,
836
- body: responseBody,
837
- headers
560
+ return this;
561
+ }
562
+ exec(args) {
563
+ this.commandArgs = args;
564
+ return this;
565
+ }
566
+ spawn(args, options) {
567
+ this.spawnConfig = {
568
+ args,
569
+ options
838
570
  };
571
+ return this;
839
572
  }
840
- };
841
- //#endregion
842
- //#region src/specification/directory.ts
843
- /**
844
- * Default ignore patterns paths that should never appear in a tracked snapshot.
845
- * Each entry is matched against any path segment OR a path prefix.
846
- */
847
- const DEFAULT_IGNORES = [
848
- ".git",
849
- ".DS_Store",
850
- "node_modules",
851
- ".next",
852
- "dist",
853
- ".turbo",
854
- ".cache"
855
- ];
856
- /**
857
- * Recursively walk a directory, returning sorted relative paths of files only.
858
- * Ignored entries (default + caller-supplied) are skipped.
859
- */
860
- async function walkDirectory(root, options = {}) {
861
- const ignores = new Set([...DEFAULT_IGNORES, ...options.ignore ?? []]);
862
- const out = [];
863
- async function walk(current) {
864
- let entries;
865
- try {
866
- entries = await (0, node_fs_promises.readdir)(current);
867
- } catch {
868
- return;
573
+ async run() {
574
+ const hasHttpAction = this.request !== null;
575
+ const hasCliAction = this.commandArgs !== null || this.spawnConfig !== null;
576
+ if (!hasHttpAction && !hasCliAction) throw new Error(`Specification "${this.label}": no action defined. Call .get(), .post(), .exec(), etc. before .run()`);
577
+ if (hasHttpAction && hasCliAction) throw new Error(`Specification "${this.label}": cannot mix HTTP (.get/.post) and CLI (.exec/.spawn) actions`);
578
+ let workDir = null;
579
+ if (hasCliAction) workDir = this.prepareWorkDir();
580
+ if (this.config.databases) for (const db of this.config.databases.values()) await db.reset();
581
+ else if (this.config.database) await this.config.database.reset();
582
+ for (const entry of this.seeds) {
583
+ let db;
584
+ if (entry.service && this.config.databases) {
585
+ db = this.config.databases.get(entry.service);
586
+ if (!db) throw new Error(`seed() targets database "${entry.service}" but it was not found. Available: ${[...this.config.databases.keys()].join(", ")}`);
587
+ } else db = this.config.database;
588
+ if (!db) throw new Error("seed() requires a database adapter");
589
+ const sql = (0, node_fs.readFileSync)((0, node_path.resolve)(this.testDir, "seeds", entry.file), "utf8");
590
+ await db.seed(sql);
869
591
  }
870
- for (const entry of entries) {
871
- if (ignores.has(entry)) continue;
872
- const abs = (0, node_path.resolve)(current, entry);
873
- const stat = (0, node_fs.statSync)(abs);
874
- if (stat.isDirectory()) await walk(abs);
875
- else if (stat.isFile()) out.push((0, node_path.relative)(root, abs).split(node_path.sep).join("/"));
592
+ 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 });
593
+ for (const entry of this.mocks) JSON.parse((0, node_fs.readFileSync)((0, node_path.resolve)(this.testDir, "mock", entry.file), "utf8"));
594
+ if (hasHttpAction) return this.runHttpAction();
595
+ return this.runCliAction(workDir);
596
+ }
597
+ resolveEnv(workDir) {
598
+ const keys = Object.keys(this.commandEnv);
599
+ if (keys.length === 0) return;
600
+ const resolved = {};
601
+ for (const key of keys) {
602
+ const value = this.commandEnv[key];
603
+ resolved[key] = typeof value === "string" ? value.replace(/\$WORKDIR/g, workDir) : value;
876
604
  }
605
+ return resolved;
877
606
  }
878
- await walk(root);
879
- out.sort();
880
- return out;
881
- }
882
- /**
883
- * Compare two directory trees file-by-file.
884
- * Binary files are compared by byte equality but reported without inline diff.
885
- */
886
- async function diffDirectories(expectedRoot, actualRoot, options = {}) {
887
- const expectedFiles = await walkDirectory(expectedRoot, options);
888
- const actualFiles = await walkDirectory(actualRoot, options);
889
- const expectedSet = new Set(expectedFiles);
890
- const actualSet = new Set(actualFiles);
891
- const added = actualFiles.filter((f) => !expectedSet.has(f));
892
- const removed = expectedFiles.filter((f) => !actualSet.has(f));
893
- const changed = [];
894
- for (const file of expectedFiles) {
895
- if (!actualSet.has(file)) continue;
896
- const expected = (0, node_fs.readFileSync)((0, node_path.resolve)(expectedRoot, file), "utf8");
897
- const actual = (0, node_fs.readFileSync)((0, node_path.resolve)(actualRoot, file), "utf8");
898
- if (expected !== actual) changed.push({
899
- actual,
900
- expected,
901
- path: file
607
+ prepareWorkDir() {
608
+ const tempDir = (0, node_fs.mkdtempSync)((0, node_path.resolve)((0, node_os.tmpdir)(), "spec-cli-"));
609
+ if (this.projectName && this.config.fixturesRoot) {
610
+ const projectDir = (0, node_path.resolve)(this.config.fixturesRoot, this.projectName);
611
+ if (!(0, node_fs.existsSync)(projectDir)) throw new Error(`project("${this.projectName}"): fixture project not found at ${projectDir}`);
612
+ (0, node_fs.cpSync)(projectDir, tempDir, { recursive: true });
613
+ }
614
+ return tempDir;
615
+ }
616
+ async runHttpAction() {
617
+ if (!this.config.server) throw new Error("HTTP actions require a server adapter (use integration() or e2e())");
618
+ let body;
619
+ if (this.request.bodyFile) body = JSON.parse((0, node_fs.readFileSync)((0, node_path.resolve)(this.testDir, "requests", this.request.bodyFile), "utf8"));
620
+ const response = await this.config.server.request(this.request.method, this.request.path, body);
621
+ return new SpecificationResult({
622
+ config: this.config,
623
+ requestInfo: {
624
+ body,
625
+ method: this.request.method,
626
+ path: this.request.path
627
+ },
628
+ response,
629
+ testDir: this.testDir
902
630
  });
903
631
  }
904
- return {
905
- added,
906
- changed,
907
- removed
632
+ async runCliAction(workDir) {
633
+ if (!this.config.command) throw new Error("CLI actions require a command adapter (use cli())");
634
+ const env = this.resolveEnv(workDir);
635
+ let commandResult;
636
+ if (this.spawnConfig) commandResult = await this.config.command.spawn(this.spawnConfig.args, workDir, this.spawnConfig.options, env);
637
+ else if (Array.isArray(this.commandArgs)) {
638
+ commandResult = {
639
+ exitCode: 0,
640
+ stderr: "",
641
+ stdout: ""
642
+ };
643
+ for (const args of this.commandArgs) {
644
+ commandResult = await this.config.command.exec(args, workDir, env);
645
+ if (commandResult.exitCode !== 0) break;
646
+ }
647
+ } else commandResult = await this.config.command.exec(this.commandArgs, workDir, env);
648
+ return new SpecificationResult({
649
+ commandResult,
650
+ config: this.config,
651
+ testDir: this.testDir,
652
+ workDir
653
+ });
654
+ }
655
+ };
656
+ function getCallerDir() {
657
+ const stack = (/* @__PURE__ */ new Error("caller detection")).stack;
658
+ if (!stack) throw new Error("Cannot detect caller directory: no stack trace");
659
+ const lines = stack.split("\n");
660
+ for (const line of lines) {
661
+ const match = line.match(/at\s+(?:.*?\()?(?:file:\/\/)?([^:)]+):\d+:\d+/);
662
+ if (!match) continue;
663
+ const filePath = match[1];
664
+ if (filePath.includes("node_modules")) continue;
665
+ if (filePath.includes("/src/builder/") || filePath.includes("/src/runner/")) continue;
666
+ if (filePath.includes("/package-test/dist/") || filePath.includes("/package-test/src/")) continue;
667
+ return (0, node_path.resolve)(filePath, "..");
668
+ }
669
+ throw new Error("Cannot detect caller directory from stack trace");
670
+ }
671
+ function createSpecificationRunner(config) {
672
+ return (label) => {
673
+ return new SpecificationBuilder(config, getCallerDir(), label);
908
674
  };
909
675
  }
910
676
  //#endregion
911
- //#region src/specification/specification.ts
912
- var TableAssertion = class {
913
- tableName;
914
- db;
915
- constructor(tableName, db) {
916
- this.tableName = tableName;
917
- this.db = db;
918
- }
919
- async toMatch(expected) {
920
- const actual = await this.db.query(this.tableName, expected.columns);
921
- if (JSON.stringify(actual) !== JSON.stringify(expected.rows)) throw new Error(formatTableDiff(this.tableName, expected.columns, expected.rows, actual));
922
- }
923
- async toBeEmpty() {
924
- const actual = await this.db.query(this.tableName, ["*"]);
925
- if (actual.length !== 0) throw new Error(`Expected table "${this.tableName}" to be empty, but it has ${actual.length} rows`);
926
- }
927
- };
677
+ //#region src/adapters/compose.adapter.ts
928
678
  /**
929
- * Detect whether the user wants to update snapshots `true` for any of:
930
- * - vitest run with `-u` / `--update`
931
- * - JTERRAZZ_TEST_UPDATE=1
932
- * - UPDATE_SNAPSHOTS=1
679
+ * Start the full compose stack and stop it all on cleanup.
933
680
  */
934
- function shouldUpdateSnapshots() {
935
- if (process.env.JTERRAZZ_TEST_UPDATE === "1") return true;
936
- if (process.env.UPDATE_SNAPSHOTS === "1") return true;
937
- if (process.argv.includes("-u") || process.argv.includes("--update")) return true;
938
- return false;
939
- }
940
- var DirectoryAccessor = class {
941
- absPath;
942
- testDir;
943
- constructor(absPath, testDir) {
944
- this.absPath = absPath;
945
- this.testDir = testDir;
681
+ var ComposeStackAdapter = class {
682
+ composeFile;
683
+ started = false;
684
+ constructor(composeFile) {
685
+ this.composeFile = composeFile;
946
686
  }
947
- /**
948
- * Compare the directory tree against `expected/{name}/` (relative to the test file).
949
- * On mismatch, throws with a structured diff. With update mode enabled, the
950
- * fixture is overwritten with the current contents instead.
951
- */
952
- async toMatchFixture(name, options = {}) {
953
- const fixtureDir = (0, node_path.resolve)(this.testDir, "expected", name);
954
- if (options.update ?? shouldUpdateSnapshots()) {
955
- (0, node_fs.rmSync)(fixtureDir, {
956
- force: true,
957
- recursive: true
958
- });
959
- (0, node_fs.mkdirSync)(fixtureDir, { recursive: true });
960
- (0, node_fs.cpSync)(this.absPath, fixtureDir, { recursive: true });
961
- return;
687
+ run(command) {
688
+ try {
689
+ return (0, node_child_process.execSync)(command, {
690
+ cwd: (0, node_path.dirname)(this.composeFile),
691
+ encoding: "utf8",
692
+ timeout: 12e4
693
+ }).trim();
694
+ } catch (error) {
695
+ const stderr = error.stderr?.toString().trim() ?? error.message;
696
+ throw new Error(`docker compose failed: ${stderr}`, { cause: error });
962
697
  }
963
- 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.`);
964
- const diff = await diffDirectories(fixtureDir, this.absPath, { ignore: options.ignore });
965
- if (diff.added.length === 0 && diff.removed.length === 0 && diff.changed.length === 0) return;
966
- throw new Error(formatDirectoryDiff(name, diff, "Run with JTERRAZZ_TEST_UPDATE=1 to update the fixture."));
967
698
  }
968
- /**
969
- * List all files in the directory (recursive, sorted, ignoring defaults).
970
- * Useful for ad-hoc assertions when you don't want a full snapshot.
971
- */
972
- async files(options = {}) {
973
- return walkDirectory(this.absPath, options);
699
+ async start() {
700
+ if (this.started) return;
701
+ this.run(`docker compose -f ${this.composeFile} up -d --wait`);
702
+ this.started = true;
974
703
  }
975
- };
976
- var ResponseAccessor = class {
977
- body;
978
- testDir;
979
- constructor(body, testDir) {
980
- this.body = body;
981
- this.testDir = testDir;
704
+ async stop() {
705
+ if (!this.started) return;
706
+ this.run(`docker compose -f ${this.composeFile} down -v`);
707
+ this.started = false;
982
708
  }
983
- toMatchFile(file) {
984
- const expected = JSON.parse((0, node_fs.readFileSync)((0, node_path.resolve)(this.testDir, "responses", file), "utf8"));
985
- if (JSON.stringify(this.body) !== JSON.stringify(expected)) throw new Error(formatResponseDiff(file, expected, this.body));
709
+ getMappedPort(serviceName, containerPort) {
710
+ const port = this.run(`docker compose -f ${this.composeFile} port ${serviceName} ${containerPort}`).split(":").pop();
711
+ return Number(port);
986
712
  }
987
- };
988
- var SpecificationResult = class {
989
- commandResult;
990
- config;
991
- requestInfo;
992
- responseData;
993
- testDir;
994
- workDir;
995
- constructor(options) {
996
- this.responseData = options.response;
997
- this.commandResult = options.commandResult;
998
- this.config = options.config;
999
- this.testDir = options.testDir;
1000
- this.requestInfo = options.requestInfo;
1001
- this.workDir = options.workDir;
713
+ getHost() {
714
+ return "localhost";
1002
715
  }
1003
- get exitCode() {
1004
- if (!this.commandResult) throw new Error(".exitCode requires a CLI action (.exec())");
1005
- return this.commandResult.exitCode;
716
+ };
717
+ //#endregion
718
+ //#region src/adapters/postgres.adapter.ts
719
+ var PostgresHandle = class {
720
+ type = "postgres";
721
+ composeName;
722
+ defaultPort = 5432;
723
+ defaultImage;
724
+ environment;
725
+ connectionString = "";
726
+ started = false;
727
+ client = null;
728
+ constructor(options = {}) {
729
+ this.composeName = options.compose ?? null;
730
+ this.defaultImage = options.image ?? "postgres:17";
731
+ this.environment = {
732
+ POSTGRES_DB: "test",
733
+ POSTGRES_PASSWORD: "test",
734
+ POSTGRES_USER: "test",
735
+ ...options.env
736
+ };
1006
737
  }
1007
- get status() {
1008
- if (!this.responseData) throw new Error(".status requires an HTTP action (.get(), .post(), etc.)");
1009
- return this.responseData.status;
738
+ buildConnectionString(host, port) {
739
+ return `postgresql://${this.environment.POSTGRES_USER ?? "test"}:${this.environment.POSTGRES_PASSWORD ?? "test"}@${host}:${port}/${this.environment.POSTGRES_DB ?? "test"}`;
1010
740
  }
1011
- get stdout() {
1012
- if (!this.commandResult) throw new Error(".stdout requires a CLI action (.exec())");
1013
- return this.commandResult.stdout;
741
+ createDatabaseAdapter() {
742
+ return this;
1014
743
  }
1015
- get stderr() {
1016
- if (!this.commandResult) throw new Error(".stderr requires a CLI action (.exec())");
1017
- return this.commandResult.stderr;
744
+ async healthcheck() {
745
+ if (!this.connectionString) throw new Error("postgres: cannot healthcheck no connection string");
746
+ try {
747
+ const client = new pg.Client({ connectionString: this.connectionString });
748
+ await client.connect();
749
+ await client.query("SELECT 1");
750
+ await client.end();
751
+ } catch (error) {
752
+ throw new Error(`postgres healthcheck failed: ${error.message || error.code || String(error)}`, { cause: error });
753
+ }
1018
754
  }
1019
- get response() {
1020
- if (!this.responseData) throw new Error(".response requires an HTTP action (.get(), .post(), etc.)");
1021
- return new ResponseAccessor(this.responseData.body, this.testDir);
755
+ async initialize(composeDir) {
756
+ if (!this.composeName) return;
757
+ const initPaths = [(0, node_path.resolve)(composeDir, `${this.composeName}/init.sql`), (0, node_path.resolve)(composeDir, "postgres/init.sql")];
758
+ for (const initPath of initPaths) if ((0, node_fs.existsSync)(initPath)) {
759
+ const sql = (0, node_fs.readFileSync)(initPath, "utf8");
760
+ try {
761
+ await this.seed(sql);
762
+ } catch (error) {
763
+ throw new Error(`postgres init script failed (${initPath}):\n${error.message}`, { cause: error });
764
+ }
765
+ return;
766
+ }
1022
767
  }
1023
- directory(path = ".") {
1024
- return new DirectoryAccessor((0, node_path.resolve)(this.workDir ?? this.testDir, path), this.testDir);
768
+ async getClient() {
769
+ if (this.client) return this.client;
770
+ const client = new pg.Client({ connectionString: this.connectionString });
771
+ client.on("error", () => {
772
+ this.client = null;
773
+ });
774
+ await client.connect();
775
+ this.client = client;
776
+ return client;
1025
777
  }
1026
- file(path) {
1027
- const resolvedPath = (0, node_path.resolve)(this.workDir ?? this.testDir, path);
1028
- const exists = (0, node_fs.existsSync)(resolvedPath);
1029
- return {
1030
- get content() {
1031
- if (!exists) throw new Error(`File not found: ${path}`);
1032
- return (0, node_fs.readFileSync)(resolvedPath, "utf8");
1033
- },
1034
- exists
1035
- };
778
+ async seed(sql) {
779
+ await (await this.getClient()).query(sql);
1036
780
  }
1037
- table(tableName, options) {
1038
- const db = this.resolveDatabase(options?.service);
1039
- if (!db) throw new Error(options?.service ? `table("${tableName}") requires database "${options.service}" but it was not found` : `table("${tableName}") requires a database adapter`);
1040
- return new TableAssertion(tableName, db);
781
+ async query(table, columns) {
782
+ const client = await this.getClient();
783
+ const columnList = columns.join(", ");
784
+ return (await client.query(`SELECT ${columnList} FROM "${table}" ORDER BY 1`)).rows.map((row) => columns.map((col) => row[col]));
1041
785
  }
1042
- resolveDatabase(serviceName) {
1043
- if (serviceName && this.config.databases) return this.config.databases.get(serviceName);
1044
- return this.config.database;
786
+ async reset() {
787
+ const client = await this.getClient();
788
+ const result = await client.query(`
789
+ SELECT tablename FROM pg_tables
790
+ WHERE schemaname = 'public'
791
+ AND tablename NOT LIKE '_prisma%'
792
+ `);
793
+ for (const row of result.rows) await client.query(`TRUNCATE "${row.tablename}" CASCADE`);
1045
794
  }
1046
795
  };
1047
- var SpecificationBuilder = class {
1048
- commandArgs = null;
1049
- commandEnv = {};
1050
- config;
1051
- fixtures = [];
1052
- label;
1053
- mocks = [];
1054
- projectName = null;
1055
- request = null;
1056
- seeds = [];
1057
- spawnConfig = null;
1058
- testDir;
1059
- constructor(config, testDir, label) {
1060
- this.config = config;
1061
- this.testDir = testDir;
1062
- this.label = label;
796
+ /**
797
+ * Create a PostgreSQL service handle.
798
+ *
799
+ * @example
800
+ * const db = postgres({ compose: "db" });
801
+ * // After start: db.connectionString is populated
802
+ */
803
+ function postgres(options = {}) {
804
+ return new PostgresHandle(options);
805
+ }
806
+ //#endregion
807
+ //#region src/adapters/redis.adapter.ts
808
+ var RedisHandle = class {
809
+ type = "redis";
810
+ composeName;
811
+ defaultPort = 6379;
812
+ defaultImage;
813
+ environment = {};
814
+ connectionString = "";
815
+ started = false;
816
+ constructor(options = {}) {
817
+ this.composeName = options.compose ?? null;
818
+ this.defaultImage = options.image ?? "redis:7";
1063
819
  }
1064
- seed(file, options) {
1065
- this.seeds.push({
1066
- file,
1067
- service: options?.service
1068
- });
1069
- return this;
820
+ buildConnectionString(host, port) {
821
+ return `redis://${host}:${port}`;
1070
822
  }
1071
- fixture(file) {
1072
- this.fixtures.push({ file });
1073
- return this;
823
+ createDatabaseAdapter() {
824
+ return null;
1074
825
  }
1075
- project(name) {
1076
- this.projectName = name;
1077
- return this;
826
+ async healthcheck() {
827
+ if (!this.connectionString) throw new Error("redis: cannot healthcheck — no connection string");
828
+ try {
829
+ const { createClient } = await import("redis");
830
+ const client = createClient({ url: this.connectionString });
831
+ await client.connect();
832
+ await client.ping();
833
+ await client.disconnect();
834
+ } catch (error) {
835
+ throw new Error(`redis healthcheck failed: ${error.message || error.code || String(error)}`, { cause: error });
836
+ }
1078
837
  }
1079
- mock(file) {
1080
- this.mocks.push({ file });
1081
- return this;
838
+ async initialize() {}
839
+ async reset() {
840
+ const { createClient } = await import("redis");
841
+ const client = createClient({ url: this.connectionString });
842
+ await client.connect();
843
+ try {
844
+ await client.flushAll();
845
+ } finally {
846
+ await client.disconnect();
847
+ }
1082
848
  }
1083
- /**
1084
- * Set environment variables for the CLI process. Merged on top of process.env.
1085
- * Use `null` to unset a variable. Multiple calls merge.
1086
- *
1087
- * The token `$WORKDIR` (in any value) is replaced with the actual working
1088
- * directory at run-time useful for tests that need a fully isolated `HOME`.
1089
- *
1090
- * @example
1091
- * spec("...").env({ HOME: "$WORKDIR", TZ: "UTC" }).exec("status").run();
1092
- */
1093
- env(env) {
1094
- this.commandEnv = {
1095
- ...this.commandEnv,
1096
- ...env
1097
- };
1098
- return this;
849
+ };
850
+ /**
851
+ * Create a Redis service handle.
852
+ *
853
+ * @example
854
+ * const cache = redis({ compose: "cache" });
855
+ * // After start: cache.connectionString is populated
856
+ */
857
+ function redis(options = {}) {
858
+ return new RedisHandle(options);
859
+ }
860
+ //#endregion
861
+ //#region src/adapters/testcontainers.adapter.ts
862
+ /**
863
+ * Container adapter using testcontainers.
864
+ * Wraps a GenericContainer for programmatic container lifecycle.
865
+ */
866
+ var TestcontainersAdapter = class {
867
+ image;
868
+ containerPort;
869
+ env;
870
+ reuse;
871
+ container = null;
872
+ constructor(options) {
873
+ this.image = options.image;
874
+ this.containerPort = options.port;
875
+ this.env = options.env ?? {};
876
+ this.reuse = options.reuse ?? false;
1099
877
  }
1100
- get(path) {
1101
- this.request = {
1102
- method: "GET",
1103
- path
1104
- };
1105
- return this;
878
+ async start() {
879
+ const { GenericContainer, Wait } = await import("testcontainers");
880
+ let builder = new GenericContainer(this.image).withExposedPorts(this.containerPort);
881
+ for (const [key, value] of Object.entries(this.env)) builder = builder.withEnvironment({ [key]: value });
882
+ if (this.image.startsWith("postgres")) builder = builder.withWaitStrategy(Wait.forLogMessage(/database system is ready to accept connections/, 2));
883
+ if (this.reuse) builder = builder.withReuse();
884
+ this.container = await builder.start();
1106
885
  }
1107
- post(path, bodyFile) {
1108
- this.request = {
1109
- bodyFile,
1110
- method: "POST",
1111
- path
1112
- };
1113
- return this;
886
+ async stop() {
887
+ if (this.container && !this.reuse) {
888
+ await this.container.stop();
889
+ this.container = null;
890
+ }
1114
891
  }
1115
- put(path, bodyFile) {
1116
- this.request = {
1117
- bodyFile,
1118
- method: "PUT",
1119
- path
1120
- };
1121
- return this;
892
+ getMappedPort(containerPort) {
893
+ if (!this.container) throw new Error("Container not started");
894
+ return this.container.getMappedPort(containerPort);
1122
895
  }
1123
- delete(path) {
1124
- this.request = {
1125
- method: "DELETE",
1126
- path
1127
- };
1128
- return this;
896
+ getHost() {
897
+ if (!this.container) throw new Error("Container not started");
898
+ return this.container.getHost();
1129
899
  }
1130
- exec(args) {
1131
- this.commandArgs = args;
1132
- return this;
900
+ getConnectionString() {
901
+ return `${this.getHost()}:${this.getMappedPort(this.containerPort)}`;
1133
902
  }
1134
- spawn(args, options) {
1135
- this.spawnConfig = {
1136
- args,
1137
- options
903
+ async getLogs() {
904
+ if (!this.container) return "";
905
+ const stream = await this.container.logs();
906
+ return new Promise((resolve) => {
907
+ let output = "";
908
+ stream.on("data", (chunk) => {
909
+ output += chunk.toString();
910
+ });
911
+ stream.on("end", () => {
912
+ resolve(output);
913
+ });
914
+ setTimeout(() => {
915
+ resolve(output);
916
+ }, 1e3);
917
+ });
918
+ }
919
+ };
920
+ //#endregion
921
+ //#region src/orchestrator/compose-parser.ts
922
+ /**
923
+ * Detect the service type from the image name.
924
+ */
925
+ function detectServiceType(image) {
926
+ if (!image) return "app";
927
+ const lower = image.toLowerCase();
928
+ if (lower.startsWith("postgres")) return "postgres";
929
+ if (lower.startsWith("redis")) return "redis";
930
+ return "unknown";
931
+ }
932
+ /**
933
+ * Find the compose file in the project.
934
+ * Looks for docker/compose.test.yaml or docker-compose.test.yaml.
935
+ */
936
+ function findComposeFile(projectRoot) {
937
+ const candidates = [
938
+ (0, node_path.resolve)(projectRoot, "docker/compose.test.yaml"),
939
+ (0, node_path.resolve)(projectRoot, "docker/compose.test.yml"),
940
+ (0, node_path.resolve)(projectRoot, "docker-compose.test.yaml"),
941
+ (0, node_path.resolve)(projectRoot, "docker-compose.test.yml")
942
+ ];
943
+ for (const candidate of candidates) if ((0, node_fs.existsSync)(candidate)) return candidate;
944
+ return null;
945
+ }
946
+ /**
947
+ * Parse a docker-compose file and extract service definitions.
948
+ */
949
+ function parseComposeFile(filePath) {
950
+ const doc = (0, yaml.parse)((0, node_fs.readFileSync)(filePath, "utf8"));
951
+ if (!doc?.services) return {
952
+ services: [],
953
+ appService: null,
954
+ infraServices: []
955
+ };
956
+ const services = Object.entries(doc.services).map(([name, def]) => {
957
+ const ports = [];
958
+ if (def.ports) for (const port of def.ports) {
959
+ const str = String(port);
960
+ if (str.includes(":")) {
961
+ const [host, container] = str.split(":");
962
+ ports.push({
963
+ container: Number(container),
964
+ host: Number(host)
965
+ });
966
+ } else ports.push({ container: Number(str) });
967
+ }
968
+ const environment = {};
969
+ if (def.environment) if (Array.isArray(def.environment)) for (const env of def.environment) {
970
+ const [key, ...rest] = String(env).split("=");
971
+ environment[key] = rest.join("=");
972
+ }
973
+ else Object.assign(environment, def.environment);
974
+ const volumes = def.volumes ? def.volumes.map((v) => String(v)) : [];
975
+ let dependsOn = [];
976
+ if (def.depends_on) dependsOn = Array.isArray(def.depends_on) ? def.depends_on : Object.keys(def.depends_on);
977
+ return {
978
+ name,
979
+ image: def.image,
980
+ build: def.build,
981
+ ports,
982
+ environment,
983
+ volumes,
984
+ dependsOn
1138
985
  };
1139
- return this;
986
+ });
987
+ return {
988
+ services,
989
+ appService: services.find((s) => s.build !== void 0) ?? null,
990
+ infraServices: services.filter((s) => s.build === void 0)
991
+ };
992
+ }
993
+ //#endregion
994
+ //#region src/orchestrator/orchestrator.ts
995
+ /**
996
+ * Orchestrator for test infrastructure.
997
+ * Integration: starts services via testcontainers.
998
+ * E2E: runs full docker compose up.
999
+ */
1000
+ var Orchestrator = class {
1001
+ services;
1002
+ mode;
1003
+ root;
1004
+ running = [];
1005
+ composeStack = null;
1006
+ composeHandles = [];
1007
+ started = false;
1008
+ constructor(options) {
1009
+ this.services = options.services;
1010
+ this.mode = options.mode;
1011
+ this.root = options.root ?? process.cwd();
1140
1012
  }
1141
- async run() {
1142
- const hasHttpAction = this.request !== null;
1143
- const hasCliAction = this.commandArgs !== null || this.spawnConfig !== null;
1144
- if (!hasHttpAction && !hasCliAction) throw new Error(`Specification "${this.label}": no action defined. Call .get(), .post(), .exec(), etc. before .run()`);
1145
- if (hasHttpAction && hasCliAction) throw new Error(`Specification "${this.label}": cannot mix HTTP (.get/.post) and CLI (.exec/.spawn) actions`);
1146
- let workDir = null;
1147
- if (hasCliAction) workDir = this.prepareWorkDir();
1148
- if (this.config.databases) for (const db of this.config.databases.values()) await db.reset();
1149
- else if (this.config.database) await this.config.database.reset();
1150
- for (const entry of this.seeds) {
1151
- let db;
1152
- if (entry.service && this.config.databases) {
1153
- db = this.config.databases.get(entry.service);
1154
- if (!db) throw new Error(`seed() targets database "${entry.service}" but it was not found. Available: ${[...this.config.databases.keys()].join(", ")}`);
1155
- } else db = this.config.database;
1156
- if (!db) throw new Error("seed() requires a database adapter");
1157
- const sql = (0, node_fs.readFileSync)((0, node_path.resolve)(this.testDir, "seeds", entry.file), "utf8");
1158
- await db.seed(sql);
1013
+ /**
1014
+ * Start declared services via testcontainers (integration mode).
1015
+ * Phase 1: start all containers in parallel (the slow part).
1016
+ * Phase 2: wire connections, healthcheck, and init sequentially (fast).
1017
+ */
1018
+ async start() {
1019
+ if (this.started) return;
1020
+ const composePath = findComposeFile(this.root);
1021
+ const composeDir = composePath ? (0, node_path.dirname)(composePath) : this.root;
1022
+ const composeConfig = composePath ? parseComposeFile(composePath) : null;
1023
+ const containerTasks = this.services.map((handle) => {
1024
+ let image = handle.defaultImage;
1025
+ let env = { ...handle.environment };
1026
+ if (handle.composeName && composeConfig) {
1027
+ const composeService = composeConfig.services.find((s) => s.name === handle.composeName);
1028
+ if (composeService) {
1029
+ image = composeService.image ?? image;
1030
+ env = {
1031
+ ...env,
1032
+ ...composeService.environment
1033
+ };
1034
+ Object.assign(handle.environment, composeService.environment);
1035
+ }
1036
+ }
1037
+ return {
1038
+ container: new TestcontainersAdapter({
1039
+ image,
1040
+ port: handle.defaultPort,
1041
+ env
1042
+ }),
1043
+ handle
1044
+ };
1045
+ });
1046
+ await Promise.all(containerTasks.map(({ container }) => container.start()));
1047
+ const reports = [];
1048
+ for (const { container, handle } of containerTasks) {
1049
+ const serviceStartTime = Date.now();
1050
+ try {
1051
+ const host = container.getHost();
1052
+ const port = container.getMappedPort(handle.defaultPort);
1053
+ handle.connectionString = handle.buildConnectionString(host, port);
1054
+ await handle.healthcheck();
1055
+ await handle.initialize(composeDir);
1056
+ handle.started = true;
1057
+ reports.push({
1058
+ name: handle.composeName ?? handle.type,
1059
+ type: handle.type,
1060
+ connectionString: handle.connectionString,
1061
+ durationMs: Date.now() - serviceStartTime
1062
+ });
1063
+ this.running.push({
1064
+ handle,
1065
+ container
1066
+ });
1067
+ } catch (error) {
1068
+ let logs = "";
1069
+ try {
1070
+ logs = await container.getLogs();
1071
+ } catch {}
1072
+ try {
1073
+ await container.stop();
1074
+ } catch {}
1075
+ reports.push({
1076
+ name: handle.composeName ?? handle.type,
1077
+ type: handle.type,
1078
+ durationMs: Date.now() - serviceStartTime,
1079
+ error: error.message,
1080
+ logs
1081
+ });
1082
+ const output = formatStartupReport("integration", reports, { type: "in-process" });
1083
+ console.error(output);
1084
+ throw error;
1085
+ }
1159
1086
  }
1160
- 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 });
1161
- for (const entry of this.mocks) JSON.parse((0, node_fs.readFileSync)((0, node_path.resolve)(this.testDir, "mock", entry.file), "utf8"));
1162
- if (hasHttpAction) return this.runHttpAction();
1163
- return this.runCliAction(workDir);
1087
+ this.started = true;
1088
+ const output = formatStartupReport("integration", reports, { type: "in-process" });
1089
+ console.log(output);
1164
1090
  }
1165
- resolveEnv(workDir) {
1166
- const keys = Object.keys(this.commandEnv);
1167
- if (keys.length === 0) return;
1168
- const resolved = {};
1169
- for (const key of keys) {
1170
- const value = this.commandEnv[key];
1171
- resolved[key] = typeof value === "string" ? value.replace(/\$WORKDIR/g, workDir) : value;
1091
+ /**
1092
+ * Stop testcontainers (integration mode).
1093
+ */
1094
+ async stop() {
1095
+ for (const { container } of this.running) if (container) await container.stop();
1096
+ this.running = [];
1097
+ this.started = false;
1098
+ }
1099
+ /**
1100
+ * Start full docker compose stack (e2e mode).
1101
+ * Auto-detects infra services and creates handles for them.
1102
+ */
1103
+ async startCompose() {
1104
+ const composePath = findComposeFile(this.root);
1105
+ if (!composePath) throw new Error(`E2E: no compose file found in ${this.root}`);
1106
+ const startTime = Date.now();
1107
+ const composeDir = (0, node_path.dirname)(composePath);
1108
+ const composeConfig = parseComposeFile(composePath);
1109
+ this.composeStack = new ComposeStackAdapter(composePath);
1110
+ await this.composeStack.start();
1111
+ for (const service of composeConfig.infraServices) {
1112
+ const type = detectServiceType(service.image);
1113
+ if (type === "postgres") {
1114
+ const handle = postgres({
1115
+ compose: service.name,
1116
+ env: service.environment
1117
+ });
1118
+ const port = this.composeStack.getMappedPort(service.name, 5432);
1119
+ handle.connectionString = handle.buildConnectionString("localhost", port);
1120
+ await handle.initialize(composeDir);
1121
+ handle.started = true;
1122
+ this.composeHandles.push(handle);
1123
+ } else if (type === "redis") {
1124
+ const handle = redis({ compose: service.name });
1125
+ const port = this.composeStack.getMappedPort(service.name, 6379);
1126
+ handle.connectionString = handle.buildConnectionString("localhost", port);
1127
+ handle.started = true;
1128
+ this.composeHandles.push(handle);
1129
+ }
1130
+ }
1131
+ const durationMs = Date.now() - startTime;
1132
+ const output = formatStartupReport("e2e", this.composeHandles.map((h) => ({
1133
+ name: h.composeName ?? h.type,
1134
+ type: h.type,
1135
+ connectionString: h.connectionString,
1136
+ durationMs
1137
+ })), {
1138
+ type: "http",
1139
+ url: this.getAppUrl() ?? void 0
1140
+ });
1141
+ console.log(output);
1142
+ }
1143
+ /**
1144
+ * Stop docker compose stack (e2e mode).
1145
+ */
1146
+ async stopCompose() {
1147
+ if (this.composeStack) {
1148
+ await this.composeStack.stop();
1149
+ this.composeStack = null;
1150
+ }
1151
+ this.composeHandles = [];
1152
+ }
1153
+ /**
1154
+ * Get a database service by compose name, or the first one if no name given.
1155
+ */
1156
+ getDatabase(serviceName) {
1157
+ for (const handle of [...this.services, ...this.composeHandles]) {
1158
+ if (serviceName && handle.composeName !== serviceName) continue;
1159
+ const adapter = handle.createDatabaseAdapter();
1160
+ if (adapter) return adapter;
1172
1161
  }
1173
- return resolved;
1162
+ return null;
1174
1163
  }
1175
- prepareWorkDir() {
1176
- const tempDir = (0, node_fs.mkdtempSync)((0, node_path.resolve)((0, node_os.tmpdir)(), "spec-cli-"));
1177
- if (this.projectName && this.config.fixturesRoot) {
1178
- const projectDir = (0, node_path.resolve)(this.config.fixturesRoot, this.projectName);
1179
- if (!(0, node_fs.existsSync)(projectDir)) throw new Error(`project("${this.projectName}"): fixture project not found at ${projectDir}`);
1180
- (0, node_fs.cpSync)(projectDir, tempDir, { recursive: true });
1164
+ /**
1165
+ * Get all database services keyed by compose name.
1166
+ */
1167
+ getDatabases() {
1168
+ const map = /* @__PURE__ */ new Map();
1169
+ for (const handle of [...this.services, ...this.composeHandles]) {
1170
+ const adapter = handle.createDatabaseAdapter();
1171
+ if (adapter && handle.composeName) map.set(handle.composeName, adapter);
1181
1172
  }
1182
- return tempDir;
1183
- }
1184
- async runHttpAction() {
1185
- if (!this.config.server) throw new Error("HTTP actions require a server adapter (use integration() or e2e())");
1186
- let body;
1187
- if (this.request.bodyFile) body = JSON.parse((0, node_fs.readFileSync)((0, node_path.resolve)(this.testDir, "requests", this.request.bodyFile), "utf8"));
1188
- const response = await this.config.server.request(this.request.method, this.request.path, body);
1189
- return new SpecificationResult({
1190
- config: this.config,
1191
- requestInfo: {
1192
- body,
1193
- method: this.request.method,
1194
- path: this.request.path
1195
- },
1196
- response,
1197
- testDir: this.testDir
1198
- });
1173
+ return map;
1199
1174
  }
1200
- async runCliAction(workDir) {
1201
- if (!this.config.command) throw new Error("CLI actions require a command adapter (use cli())");
1202
- const env = this.resolveEnv(workDir);
1203
- let commandResult;
1204
- if (this.spawnConfig) commandResult = await this.config.command.spawn(this.spawnConfig.args, workDir, this.spawnConfig.options, env);
1205
- else if (Array.isArray(this.commandArgs)) {
1206
- commandResult = {
1207
- exitCode: 0,
1208
- stderr: "",
1209
- stdout: ""
1210
- };
1211
- for (const args of this.commandArgs) {
1212
- commandResult = await this.config.command.exec(args, workDir, env);
1213
- if (commandResult.exitCode !== 0) break;
1214
- }
1215
- } else commandResult = await this.config.command.exec(this.commandArgs, workDir, env);
1216
- return new SpecificationResult({
1217
- commandResult,
1218
- config: this.config,
1219
- testDir: this.testDir,
1220
- workDir
1221
- });
1175
+ /**
1176
+ * Get app URL from compose (e2e mode).
1177
+ */
1178
+ getAppUrl() {
1179
+ const composePath = findComposeFile(this.root);
1180
+ if (!composePath || !this.composeStack) return null;
1181
+ const appService = parseComposeFile(composePath).appService;
1182
+ if (!appService || appService.ports.length === 0) return null;
1183
+ return `http://localhost:${this.composeStack.getMappedPort(appService.name, appService.ports[0].container)}`;
1222
1184
  }
1223
1185
  };
1224
- function getCallerDir() {
1225
- const stack = (/* @__PURE__ */ new Error("caller detection")).stack;
1226
- if (!stack) throw new Error("Cannot detect caller directory: no stack trace");
1227
- const lines = stack.split("\n");
1228
- for (const line of lines) {
1229
- const match = line.match(/at\s+(?:.*?\()?(?:file:\/\/)?([^:)]+):\d+:\d+/);
1230
- if (!match) continue;
1231
- const filePath = match[1];
1232
- if (filePath.includes("node_modules")) continue;
1233
- if (filePath.includes("/src/specification/")) continue;
1234
- return (0, node_path.resolve)(filePath, "..");
1235
- }
1236
- throw new Error("Cannot detect caller directory from stack trace");
1237
- }
1238
- function createSpecificationRunner(config) {
1239
- return (label) => {
1240
- return new SpecificationBuilder(config, getCallerDir(), label);
1241
- };
1242
- }
1243
- //#endregion
1244
- //#region src/specification/grep.ts
1245
- /**
1246
- * Extract text blocks from output that contain a pattern.
1247
- * Splits by blank lines (how linter/compiler output is structured),
1248
- * returns only blocks matching the pattern.
1249
- *
1250
- * @example
1251
- * expect(grep(result.stdout, "unused-var.ts")).toContain("no-unused-vars")
1252
- * expect(grep(result.stdout, "valid/sorted.ts")).not.toContain("sort-imports")
1253
- */
1254
- function grep(output, pattern) {
1255
- return output.replace(/\x1b\[[0-9;]*m/g, "").split(/\n\s*\n/).filter((block) => block.includes(pattern)).join("\n\n");
1256
- }
1257
1186
  //#endregion
1258
- //#region src/specification/index.ts
1187
+ //#region src/runner/resolve.ts
1259
1188
  /**
1260
1189
  * Resolve root — if relative, resolves from the caller's directory.
1261
1190
  */
@@ -1269,7 +1198,8 @@ function resolveProjectRoot(root) {
1269
1198
  const match = line.match(/at\s+(?:.*?\()?(?:file:\/\/)?([^:)]+):\d+:\d+/);
1270
1199
  if (!match) continue;
1271
1200
  const filePath = match[1];
1272
- if (filePath.includes("node_modules") || filePath.includes("/specification/")) continue;
1201
+ if (filePath.includes("node_modules")) continue;
1202
+ if (filePath.includes("/src/runner/") || filePath.includes("/dist/")) continue;
1273
1203
  return (0, node_path.resolve)(filePath, "..", root);
1274
1204
  }
1275
1205
  }
@@ -1286,29 +1216,73 @@ function resolveCommand(command, root) {
1286
1216
  if ((0, node_fs.existsSync)(cwdBinPath)) return cwdBinPath;
1287
1217
  return command;
1288
1218
  }
1219
+ //#endregion
1220
+ //#region src/runner/cli.ts
1289
1221
  /**
1290
- * Create an integration specification runner.
1291
- * Starts infra containers via testcontainers, app runs in-process.
1222
+ * Create a CLI specification runner.
1223
+ * Runs CLI commands against fixture projects. Optionally starts infrastructure.
1292
1224
  */
1293
- async function integration(options) {
1294
- const orchestrator = new Orchestrator({
1295
- mode: "integration",
1296
- root: resolveProjectRoot(options.root),
1297
- services: options.services
1298
- });
1299
- await orchestrator.start();
1300
- const app = options.app();
1301
- const database = orchestrator.getDatabase() ?? void 0;
1302
- const databases = orchestrator.getDatabases();
1225
+ async function cli(options) {
1226
+ const root = resolveProjectRoot(options.root);
1227
+ const command = resolveCommand(options.command, root);
1228
+ let orchestrator = null;
1229
+ let database;
1230
+ let databases;
1231
+ if (options.services?.length) {
1232
+ orchestrator = new Orchestrator({
1233
+ mode: "integration",
1234
+ root,
1235
+ services: options.services
1236
+ });
1237
+ await orchestrator.start();
1238
+ database = orchestrator.getDatabase() ?? void 0;
1239
+ const dbMap = orchestrator.getDatabases();
1240
+ databases = dbMap.size > 0 ? dbMap : void 0;
1241
+ }
1303
1242
  const runner = createSpecificationRunner({
1243
+ command: new ExecAdapter(command),
1304
1244
  database,
1305
- databases: databases.size > 0 ? databases : void 0,
1306
- server: new HonoAdapter(app)
1245
+ databases,
1246
+ fixturesRoot: root
1307
1247
  });
1308
- runner.cleanup = () => orchestrator.stop();
1248
+ runner.cleanup = async () => {
1249
+ if (orchestrator) await orchestrator.stop();
1250
+ };
1309
1251
  runner.orchestrator = orchestrator;
1310
1252
  return runner;
1311
1253
  }
1254
+ //#endregion
1255
+ //#region src/adapters/fetch.adapter.ts
1256
+ /**
1257
+ * Server adapter for real HTTP — sends actual fetch requests.
1258
+ * Used by e2e() specification runner.
1259
+ */
1260
+ var FetchAdapter = class {
1261
+ baseUrl;
1262
+ constructor(url) {
1263
+ this.baseUrl = url.replace(/\/$/, "");
1264
+ }
1265
+ async request(method, path, body) {
1266
+ const init = {
1267
+ method,
1268
+ headers: { "Content-Type": "application/json" }
1269
+ };
1270
+ if (body !== void 0) init.body = JSON.stringify(body);
1271
+ const response = await fetch(`${this.baseUrl}${path}`, init);
1272
+ const responseBody = await response.json().catch(() => null);
1273
+ const headers = {};
1274
+ response.headers.forEach((value, key) => {
1275
+ headers[key] = value;
1276
+ });
1277
+ return {
1278
+ status: response.status,
1279
+ body: responseBody,
1280
+ headers
1281
+ };
1282
+ }
1283
+ };
1284
+ //#endregion
1285
+ //#region src/runner/e2e.ts
1312
1286
  /**
1313
1287
  * Create an E2E specification runner.
1314
1288
  * Starts full docker compose stack. App URL and database auto-detected.
@@ -1333,47 +1307,63 @@ async function e2e(options = {}) {
1333
1307
  runner.orchestrator = orchestrator;
1334
1308
  return runner;
1335
1309
  }
1310
+ //#endregion
1311
+ //#region src/adapters/hono.adapter.ts
1336
1312
  /**
1337
- * Create a CLI specification runner.
1338
- * Runs CLI commands against fixture projects. Optionally starts infrastructure.
1339
- *
1340
- * @example
1341
- * export const spec = await cli({
1342
- * command: resolve(import.meta.dirname, "../../bin/my-cli.sh"),
1343
- * root: "../fixtures",
1344
- * });
1313
+ * Server adapter for Hono — in-process requests, no real HTTP.
1314
+ * Used by integration() specification runner.
1345
1315
  */
1346
- async function cli(options) {
1347
- const root = resolveProjectRoot(options.root);
1348
- const command = resolveCommand(options.command, root);
1349
- let orchestrator = null;
1350
- let database;
1351
- let databases;
1352
- if (options.services?.length) {
1353
- orchestrator = new Orchestrator({
1354
- mode: "integration",
1355
- root,
1356
- services: options.services
1316
+ var HonoAdapter = class {
1317
+ app;
1318
+ constructor(app) {
1319
+ this.app = app;
1320
+ }
1321
+ async request(method, path, body) {
1322
+ const init = {
1323
+ method,
1324
+ headers: { "Content-Type": "application/json" }
1325
+ };
1326
+ if (body !== void 0) init.body = JSON.stringify(body);
1327
+ const response = await this.app.request(path, init);
1328
+ const responseBody = await response.json().catch(() => null);
1329
+ const headers = {};
1330
+ response.headers.forEach((value, key) => {
1331
+ headers[key] = value;
1357
1332
  });
1358
- await orchestrator.start();
1359
- database = orchestrator.getDatabase() ?? void 0;
1360
- const dbMap = orchestrator.getDatabases();
1361
- databases = dbMap.size > 0 ? dbMap : void 0;
1333
+ return {
1334
+ status: response.status,
1335
+ body: responseBody,
1336
+ headers
1337
+ };
1362
1338
  }
1339
+ };
1340
+ //#endregion
1341
+ //#region src/runner/integration.ts
1342
+ /**
1343
+ * Create an integration specification runner.
1344
+ * Starts infra containers via testcontainers, app runs in-process.
1345
+ */
1346
+ async function integration(options) {
1347
+ const orchestrator = new Orchestrator({
1348
+ mode: "integration",
1349
+ root: resolveProjectRoot(options.root),
1350
+ services: options.services
1351
+ });
1352
+ await orchestrator.start();
1353
+ const app = options.app();
1354
+ const database = orchestrator.getDatabase() ?? void 0;
1355
+ const databases = orchestrator.getDatabases();
1363
1356
  const runner = createSpecificationRunner({
1364
- command: new ExecAdapter(command),
1365
1357
  database,
1366
- databases,
1367
- fixturesRoot: root
1358
+ databases: databases.size > 0 ? databases : void 0,
1359
+ server: new HonoAdapter(app)
1368
1360
  });
1369
- runner.cleanup = async () => {
1370
- if (orchestrator) await orchestrator.stop();
1371
- };
1361
+ runner.cleanup = () => orchestrator.stop();
1372
1362
  runner.orchestrator = orchestrator;
1373
1363
  return runner;
1374
1364
  }
1375
1365
  //#endregion
1376
- //#region src/infrastructure/docker/docker-adapter.ts
1366
+ //#region src/docker/docker-adapter.ts
1377
1367
  var DockerAdapter = class {
1378
1368
  containerId;
1379
1369
  constructor(containerId) {
@@ -1466,7 +1456,7 @@ function dockerContainer(containerId) {
1466
1456
  return new DockerAdapter(containerId);
1467
1457
  }
1468
1458
  //#endregion
1469
- //#region src/infrastructure/docker/docker-assertion.ts
1459
+ //#region src/docker/docker-assertion.ts
1470
1460
  /** Fluent assertion builder for Docker containers */
1471
1461
  var DockerAssertion = class {
1472
1462
  container;
@@ -1543,12 +1533,38 @@ var DockerAssertion = class {
1543
1533
  }
1544
1534
  };
1545
1535
  //#endregion
1536
+ //#region src/utilities/grep.ts
1537
+ /**
1538
+ * Extract text blocks from output that contain a pattern.
1539
+ * Splits by blank lines (how linter/compiler output is structured),
1540
+ * returns only blocks matching the pattern.
1541
+ *
1542
+ * @example
1543
+ * expect(grep(result.stdout, "unused-var.ts")).toContain("no-unused-vars")
1544
+ * expect(grep(result.stdout, "valid/sorted.ts")).not.toContain("sort-imports")
1545
+ */
1546
+ function grep(output, pattern) {
1547
+ return output.replace(/\x1b\[[0-9;]*m/g, "").split(/\n\s*\n/).filter((block) => block.includes(pattern)).join("\n\n");
1548
+ }
1549
+ //#endregion
1550
+ //#region src/mocking/mock-of-date.ts
1551
+ const mockOfDate = mockdate.default;
1552
+ //#endregion
1553
+ //#region src/mocking/mock-of.ts
1554
+ const mockOf = vitest_mock_extended.mockDeep;
1555
+ //#endregion
1556
+ exports.DirectoryAccessor = DirectoryAccessor;
1546
1557
  exports.DockerAssertion = DockerAssertion;
1547
1558
  exports.ExecAdapter = ExecAdapter;
1548
1559
  exports.FetchAdapter = FetchAdapter;
1549
1560
  exports.HonoAdapter = HonoAdapter;
1550
1561
  exports.Orchestrator = Orchestrator;
1562
+ exports.ResponseAccessor = ResponseAccessor;
1563
+ exports.SpecificationBuilder = SpecificationBuilder;
1564
+ exports.SpecificationResult = SpecificationResult;
1565
+ exports.TableAssertion = TableAssertion;
1551
1566
  exports.cli = cli;
1567
+ exports.createSpecificationRunner = createSpecificationRunner;
1552
1568
  exports.dockerContainer = dockerContainer;
1553
1569
  exports.e2e = e2e;
1554
1570
  exports.grep = grep;