@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/README.md +72 -72
- package/dist/index.cjs +1051 -1035
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +226 -199
- package/dist/index.d.ts +226 -199
- package/dist/index.js +1040 -1030
- package/dist/index.js.map +1 -1
- package/package.json +58 -54
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
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
//#
|
|
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
|
-
*
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
*
|
|
84
|
-
*
|
|
49
|
+
* Executes CLI commands via execSync (blocking) or spawn (long-running).
|
|
50
|
+
* Used by cli() for local command execution.
|
|
85
51
|
*/
|
|
86
|
-
var
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
132
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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/
|
|
130
|
+
//#region src/utilities/directory.ts
|
|
142
131
|
/**
|
|
143
|
-
*
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
*
|
|
154
|
-
*
|
|
145
|
+
* Recursively walk a directory, returning sorted relative paths of files only.
|
|
146
|
+
* Ignored entries (default + caller-supplied) are skipped.
|
|
155
147
|
*/
|
|
156
|
-
function
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
*
|
|
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
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
if (
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
193
|
+
added,
|
|
194
|
+
changed,
|
|
195
|
+
removed
|
|
211
196
|
};
|
|
212
197
|
}
|
|
213
198
|
//#endregion
|
|
214
|
-
//#region src/
|
|
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/
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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/
|
|
440
|
-
var
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
|
|
453
|
-
|
|
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
|
-
|
|
456
|
-
|
|
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
|
|
459
|
-
|
|
460
|
-
|
|
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
|
|
471
|
-
|
|
472
|
-
|
|
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/
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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
|
-
|
|
514
|
-
|
|
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
|
-
|
|
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
|
-
|
|
600
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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
|
-
|
|
782
|
-
|
|
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
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
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
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
}
|
|
804
|
-
return
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
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
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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
|
-
|
|
823
|
-
|
|
824
|
-
method,
|
|
825
|
-
|
|
555
|
+
delete(path) {
|
|
556
|
+
this.request = {
|
|
557
|
+
method: "DELETE",
|
|
558
|
+
path
|
|
826
559
|
};
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
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
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
const
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
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
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
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
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
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
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
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/
|
|
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
|
-
*
|
|
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
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
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
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
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
|
-
|
|
970
|
-
|
|
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
|
-
|
|
977
|
-
|
|
978
|
-
|
|
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
|
-
|
|
984
|
-
const
|
|
985
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
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
|
-
|
|
1008
|
-
|
|
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
|
-
|
|
1012
|
-
|
|
1013
|
-
return this.commandResult.stdout;
|
|
741
|
+
createDatabaseAdapter() {
|
|
742
|
+
return this;
|
|
1014
743
|
}
|
|
1015
|
-
|
|
1016
|
-
if (!this.
|
|
1017
|
-
|
|
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
|
-
|
|
1020
|
-
if (!this.
|
|
1021
|
-
|
|
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
|
-
|
|
1024
|
-
|
|
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
|
-
|
|
1027
|
-
|
|
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
|
|
1038
|
-
const
|
|
1039
|
-
|
|
1040
|
-
return
|
|
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
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
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
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
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
|
-
|
|
1065
|
-
|
|
1066
|
-
file,
|
|
1067
|
-
service: options?.service
|
|
1068
|
-
});
|
|
1069
|
-
return this;
|
|
820
|
+
buildConnectionString(host, port) {
|
|
821
|
+
return `redis://${host}:${port}`;
|
|
1070
822
|
}
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
return this;
|
|
823
|
+
createDatabaseAdapter() {
|
|
824
|
+
return null;
|
|
1074
825
|
}
|
|
1075
|
-
|
|
1076
|
-
this.
|
|
1077
|
-
|
|
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
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
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
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
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
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
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
|
-
|
|
1108
|
-
this.
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
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
|
-
|
|
1116
|
-
this.
|
|
1117
|
-
|
|
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
|
-
|
|
1124
|
-
this.
|
|
1125
|
-
|
|
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
|
-
|
|
1131
|
-
this.
|
|
1132
|
-
return this;
|
|
900
|
+
getConnectionString() {
|
|
901
|
+
return `${this.getHost()}:${this.getMappedPort(this.containerPort)}`;
|
|
1133
902
|
}
|
|
1134
|
-
|
|
1135
|
-
this.
|
|
1136
|
-
|
|
1137
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
if (
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
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
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
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
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
for (const
|
|
1170
|
-
|
|
1171
|
-
|
|
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
|
|
1162
|
+
return null;
|
|
1174
1163
|
}
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
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
|
|
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
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
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/
|
|
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")
|
|
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
|
|
1291
|
-
*
|
|
1222
|
+
* Create a CLI specification runner.
|
|
1223
|
+
* Runs CLI commands against fixture projects. Optionally starts infrastructure.
|
|
1292
1224
|
*/
|
|
1293
|
-
async function
|
|
1294
|
-
const
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
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
|
|
1306
|
-
|
|
1245
|
+
databases,
|
|
1246
|
+
fixturesRoot: root
|
|
1307
1247
|
});
|
|
1308
|
-
runner.cleanup = () =>
|
|
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
|
-
*
|
|
1338
|
-
*
|
|
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
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
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
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
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
|
-
|
|
1358
|
+
databases: databases.size > 0 ? databases : void 0,
|
|
1359
|
+
server: new HonoAdapter(app)
|
|
1368
1360
|
});
|
|
1369
|
-
runner.cleanup =
|
|
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/
|
|
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/
|
|
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;
|