@jterrazz/test 5.2.0 → 5.3.2

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