@jterrazz/test 7.1.0 → 9.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +209 -230
- package/dist/checker.d.ts +1 -0
- package/dist/checker.js +741 -0
- package/dist/index.d.ts +1296 -529
- package/dist/index.js +3303 -1261
- package/dist/intercept.js +129 -290
- package/dist/match.js +153 -0
- package/dist/oxlint.cjs +2931 -0
- package/dist/oxlint.d.cts +150 -0
- package/dist/oxlint.d.ts +150 -0
- package/dist/oxlint.js +2925 -0
- package/package.json +47 -41
- package/dist/chunk.cjs +0 -28
- package/dist/index.cjs +0 -1814
- package/dist/index.cjs.map +0 -1
- package/dist/index.d.cts +0 -734
- package/dist/index.js.map +0 -1
- package/dist/intercept.cjs +0 -313
- package/dist/intercept.cjs.map +0 -1
- package/dist/intercept.d.cts +0 -105
- package/dist/intercept.d.ts +0 -105
- package/dist/intercept.js.map +0 -1
- package/dist/intercept2.cjs +0 -81
- package/dist/intercept2.cjs.map +0 -1
- package/dist/intercept2.js +0 -81
- package/dist/intercept2.js.map +0 -1
- package/dist/mock-of.cjs +0 -32
- package/dist/mock-of.cjs.map +0 -1
- package/dist/mock-of.d.cts +0 -27
- package/dist/mock-of.d.ts +0 -27
- package/dist/mock-of.js +0 -19
- package/dist/mock-of.js.map +0 -1
- package/dist/mock.cjs +0 -4
- package/dist/mock.d.cts +0 -2
- package/dist/mock.d.ts +0 -2
- package/dist/mock.js +0 -2
- package/dist/services.cjs +0 -5
- package/dist/services.d.cts +0 -2
- package/dist/services.d.ts +0 -2
- package/dist/services.js +0 -2
- package/dist/sqlite.adapter.cjs +0 -350
- package/dist/sqlite.adapter.cjs.map +0 -1
- package/dist/sqlite.adapter.d.cts +0 -209
- package/dist/sqlite.adapter.d.ts +0 -209
- package/dist/sqlite.adapter.js +0 -331
- package/dist/sqlite.adapter.js.map +0 -1
- package/dist/types.d.cts +0 -42
- package/dist/types.d.ts +0 -42
package/dist/index.cjs
DELETED
|
@@ -1,1814 +0,0 @@
|
|
|
1
|
-
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
require("./chunk.cjs");
|
|
3
|
-
const require_sqlite_adapter = require("./sqlite.adapter.cjs");
|
|
4
|
-
const require_mock_of = require("./mock-of.cjs");
|
|
5
|
-
let node_child_process = require("node:child_process");
|
|
6
|
-
let node_fs = require("node:fs");
|
|
7
|
-
let node_os = require("node:os");
|
|
8
|
-
let node_path = require("node:path");
|
|
9
|
-
let node_fs_promises = require("node:fs/promises");
|
|
10
|
-
let yaml = require("yaml");
|
|
11
|
-
//#region src/builder/cli/adapters/exec.adapter.ts
|
|
12
|
-
/**
|
|
13
|
-
* Build a child-process env from the parent env plus user overrides.
|
|
14
|
-
* `null` overrides delete keys (e.g. `INIT_CWD: null`).
|
|
15
|
-
*/
|
|
16
|
-
function buildEnv(extra) {
|
|
17
|
-
const env = {
|
|
18
|
-
...process.env,
|
|
19
|
-
INIT_CWD: void 0
|
|
20
|
-
};
|
|
21
|
-
if (extra) for (const [key, value] of Object.entries(extra)) if (value === null) delete env[key];
|
|
22
|
-
else env[key] = value;
|
|
23
|
-
return env;
|
|
24
|
-
}
|
|
25
|
-
/**
|
|
26
|
-
* Executes CLI commands via Node.js child_process.
|
|
27
|
-
* Uses `execSync` for one-shot commands and `spawn` for long-running processes.
|
|
28
|
-
* Used by the `cli()` specification runner.
|
|
29
|
-
*/
|
|
30
|
-
var ExecAdapter = class {
|
|
31
|
-
command;
|
|
32
|
-
constructor(command) {
|
|
33
|
-
this.command = command;
|
|
34
|
-
}
|
|
35
|
-
async exec(args, cwd, extraEnv) {
|
|
36
|
-
const env = buildEnv(extraEnv);
|
|
37
|
-
try {
|
|
38
|
-
return {
|
|
39
|
-
exitCode: 0,
|
|
40
|
-
stdout: (0, node_child_process.execSync)(`${this.command} ${args}`, {
|
|
41
|
-
cwd,
|
|
42
|
-
encoding: "utf8",
|
|
43
|
-
env,
|
|
44
|
-
stdio: [
|
|
45
|
-
"pipe",
|
|
46
|
-
"pipe",
|
|
47
|
-
"pipe"
|
|
48
|
-
]
|
|
49
|
-
}),
|
|
50
|
-
stderr: ""
|
|
51
|
-
};
|
|
52
|
-
} catch (error) {
|
|
53
|
-
return {
|
|
54
|
-
exitCode: error.status ?? 1,
|
|
55
|
-
stdout: error.stdout?.toString() ?? "",
|
|
56
|
-
stderr: error.stderr?.toString() ?? ""
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
async spawn(args, cwd, options, extraEnv) {
|
|
61
|
-
const env = buildEnv(extraEnv);
|
|
62
|
-
return new Promise((resolve) => {
|
|
63
|
-
let stdout = "";
|
|
64
|
-
let stderr = "";
|
|
65
|
-
let resolved = false;
|
|
66
|
-
const child = (0, node_child_process.spawn)(this.command, args.split(/\s+/).filter(Boolean), {
|
|
67
|
-
cwd,
|
|
68
|
-
env,
|
|
69
|
-
stdio: [
|
|
70
|
-
"pipe",
|
|
71
|
-
"pipe",
|
|
72
|
-
"pipe"
|
|
73
|
-
]
|
|
74
|
-
});
|
|
75
|
-
const finish = (exitCode) => {
|
|
76
|
-
if (resolved) return;
|
|
77
|
-
resolved = true;
|
|
78
|
-
child.kill("SIGTERM");
|
|
79
|
-
resolve({
|
|
80
|
-
exitCode,
|
|
81
|
-
stdout,
|
|
82
|
-
stderr
|
|
83
|
-
});
|
|
84
|
-
};
|
|
85
|
-
let patternMatched = false;
|
|
86
|
-
const checkPattern = () => {
|
|
87
|
-
if (!patternMatched && (stdout.includes(options.waitFor) || stderr.includes(options.waitFor))) {
|
|
88
|
-
patternMatched = true;
|
|
89
|
-
finish(0);
|
|
90
|
-
}
|
|
91
|
-
};
|
|
92
|
-
child.stdout?.on("data", (data) => {
|
|
93
|
-
stdout += data.toString();
|
|
94
|
-
checkPattern();
|
|
95
|
-
});
|
|
96
|
-
child.stderr?.on("data", (data) => {
|
|
97
|
-
stderr += data.toString();
|
|
98
|
-
checkPattern();
|
|
99
|
-
});
|
|
100
|
-
child.on("exit", (code) => {
|
|
101
|
-
if (!patternMatched) finish(code === 0 ? 1 : code ?? 1);
|
|
102
|
-
});
|
|
103
|
-
setTimeout(() => finish(124), options.timeout);
|
|
104
|
-
});
|
|
105
|
-
}
|
|
106
|
-
};
|
|
107
|
-
//#endregion
|
|
108
|
-
//#region src/builder/http/adapters/fetch.adapter.ts
|
|
109
|
-
/**
|
|
110
|
-
* Server adapter that sends real HTTP requests via the Fetch API.
|
|
111
|
-
* Used by the `e2e()` specification runner to hit a live server.
|
|
112
|
-
*/
|
|
113
|
-
var FetchAdapter = class {
|
|
114
|
-
baseUrl;
|
|
115
|
-
constructor(url) {
|
|
116
|
-
this.baseUrl = url.replace(/\/$/, "");
|
|
117
|
-
}
|
|
118
|
-
async request(method, path, body, headers) {
|
|
119
|
-
const init = {
|
|
120
|
-
method,
|
|
121
|
-
headers: {
|
|
122
|
-
"Content-Type": "application/json",
|
|
123
|
-
...headers
|
|
124
|
-
}
|
|
125
|
-
};
|
|
126
|
-
if (body !== void 0) init.body = JSON.stringify(body);
|
|
127
|
-
const response = await fetch(`${this.baseUrl}${path}`, init);
|
|
128
|
-
const responseBody = await response.json().catch(() => null);
|
|
129
|
-
const responseHeaders = {};
|
|
130
|
-
response.headers.forEach((value, key) => {
|
|
131
|
-
responseHeaders[key] = value;
|
|
132
|
-
});
|
|
133
|
-
return {
|
|
134
|
-
status: response.status,
|
|
135
|
-
body: responseBody,
|
|
136
|
-
headers: responseHeaders
|
|
137
|
-
};
|
|
138
|
-
}
|
|
139
|
-
};
|
|
140
|
-
//#endregion
|
|
141
|
-
//#region src/builder/http/adapters/hono.adapter.ts
|
|
142
|
-
/**
|
|
143
|
-
* Server adapter that dispatches requests in-process through a Hono app instance.
|
|
144
|
-
* Used by the `integration()` specification runner -- no network overhead.
|
|
145
|
-
*/
|
|
146
|
-
var HonoAdapter = class {
|
|
147
|
-
app;
|
|
148
|
-
constructor(app) {
|
|
149
|
-
this.app = app;
|
|
150
|
-
}
|
|
151
|
-
async request(method, path, body, headers) {
|
|
152
|
-
const init = {
|
|
153
|
-
method,
|
|
154
|
-
headers: {
|
|
155
|
-
"Content-Type": "application/json",
|
|
156
|
-
...headers
|
|
157
|
-
}
|
|
158
|
-
};
|
|
159
|
-
if (body !== void 0) init.body = JSON.stringify(body);
|
|
160
|
-
const response = await this.app.request(path, init);
|
|
161
|
-
const responseBody = await response.json().catch(() => null);
|
|
162
|
-
const responseHeaders = {};
|
|
163
|
-
response.headers.forEach((value, key) => {
|
|
164
|
-
responseHeaders[key] = value;
|
|
165
|
-
});
|
|
166
|
-
return {
|
|
167
|
-
status: response.status,
|
|
168
|
-
body: responseBody,
|
|
169
|
-
headers: responseHeaders
|
|
170
|
-
};
|
|
171
|
-
}
|
|
172
|
-
};
|
|
173
|
-
//#endregion
|
|
174
|
-
//#region src/builder/common/directory.ts
|
|
175
|
-
/**
|
|
176
|
-
* Default ignore patterns — paths that should never appear in a tracked snapshot.
|
|
177
|
-
* Each entry is matched against any path segment OR a path prefix.
|
|
178
|
-
*/
|
|
179
|
-
const DEFAULT_IGNORES = [
|
|
180
|
-
".git",
|
|
181
|
-
".DS_Store",
|
|
182
|
-
"node_modules",
|
|
183
|
-
".next",
|
|
184
|
-
"dist",
|
|
185
|
-
".turbo",
|
|
186
|
-
".cache"
|
|
187
|
-
];
|
|
188
|
-
/**
|
|
189
|
-
* Recursively walk a directory, returning sorted relative paths of files only.
|
|
190
|
-
* Ignored entries (default + caller-supplied) are skipped.
|
|
191
|
-
*/
|
|
192
|
-
async function walkDirectory(root, options = {}) {
|
|
193
|
-
const ignores = new Set([...DEFAULT_IGNORES, ...options.ignore ?? []]);
|
|
194
|
-
const out = [];
|
|
195
|
-
async function walk(current) {
|
|
196
|
-
let entries;
|
|
197
|
-
try {
|
|
198
|
-
entries = await (0, node_fs_promises.readdir)(current);
|
|
199
|
-
} catch {
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
202
|
-
for (const entry of entries) {
|
|
203
|
-
if (ignores.has(entry)) continue;
|
|
204
|
-
const abs = (0, node_path.resolve)(current, entry);
|
|
205
|
-
const stat = (0, node_fs.statSync)(abs);
|
|
206
|
-
if (stat.isDirectory()) await walk(abs);
|
|
207
|
-
else if (stat.isFile()) out.push((0, node_path.relative)(root, abs).split(node_path.sep).join("/"));
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
await walk(root);
|
|
211
|
-
out.sort();
|
|
212
|
-
return out;
|
|
213
|
-
}
|
|
214
|
-
/**
|
|
215
|
-
* Compare two directory trees file-by-file.
|
|
216
|
-
* Binary files are compared by byte equality but reported without inline diff.
|
|
217
|
-
*/
|
|
218
|
-
async function diffDirectories(expectedRoot, actualRoot, options = {}) {
|
|
219
|
-
const expectedFiles = await walkDirectory(expectedRoot, options);
|
|
220
|
-
const actualFiles = await walkDirectory(actualRoot, options);
|
|
221
|
-
const expectedSet = new Set(expectedFiles);
|
|
222
|
-
const actualSet = new Set(actualFiles);
|
|
223
|
-
const added = actualFiles.filter((f) => !expectedSet.has(f));
|
|
224
|
-
const removed = expectedFiles.filter((f) => !actualSet.has(f));
|
|
225
|
-
const changed = [];
|
|
226
|
-
for (const file of expectedFiles) {
|
|
227
|
-
if (!actualSet.has(file)) continue;
|
|
228
|
-
const expected = (0, node_fs.readFileSync)((0, node_path.resolve)(expectedRoot, file), "utf8");
|
|
229
|
-
const actual = (0, node_fs.readFileSync)((0, node_path.resolve)(actualRoot, file), "utf8");
|
|
230
|
-
if (expected !== actual) changed.push({
|
|
231
|
-
actual,
|
|
232
|
-
expected,
|
|
233
|
-
path: file
|
|
234
|
-
});
|
|
235
|
-
}
|
|
236
|
-
return {
|
|
237
|
-
added,
|
|
238
|
-
changed,
|
|
239
|
-
removed
|
|
240
|
-
};
|
|
241
|
-
}
|
|
242
|
-
//#endregion
|
|
243
|
-
//#region src/builder/common/reporter.ts
|
|
244
|
-
const GREEN = "\x1B[32m";
|
|
245
|
-
const RED = "\x1B[31m";
|
|
246
|
-
const DIM = "\x1B[2m";
|
|
247
|
-
const BOLD = "\x1B[1m";
|
|
248
|
-
const RESET = "\x1B[0m";
|
|
249
|
-
const BG_CYAN = "\x1B[46m";
|
|
250
|
-
const BLACK = "\x1B[30m";
|
|
251
|
-
const CHECK = "✓";
|
|
252
|
-
const CROSS = "×";
|
|
253
|
-
const ARROW = "→";
|
|
254
|
-
function formatStartupReport(mode, services, app) {
|
|
255
|
-
const lines = [];
|
|
256
|
-
lines.push("");
|
|
257
|
-
lines.push(`${BG_CYAN}${BLACK}${BOLD} INFRA ${RESET} Starting infrastructure...`);
|
|
258
|
-
lines.push("");
|
|
259
|
-
for (const service of services) if (service.error) {
|
|
260
|
-
lines.push(` ${RED}${CROSS}${RESET} ${service.type} (${service.name}) ${RED}${service.error}${RESET} ${DIM}${service.durationMs}ms${RESET}`);
|
|
261
|
-
if (service.logs) {
|
|
262
|
-
const logLines = service.logs.trim().split("\n").slice(-10);
|
|
263
|
-
for (const logLine of logLines) lines.push(` ${DIM}${logLine}${RESET}`);
|
|
264
|
-
}
|
|
265
|
-
} else {
|
|
266
|
-
const conn = service.connectionString ? `${DIM}${service.connectionString}${RESET}` : "";
|
|
267
|
-
lines.push(` ${GREEN}${CHECK}${RESET} ${service.type} (${service.name}) ${conn} ${DIM}${service.durationMs}ms${RESET}`);
|
|
268
|
-
}
|
|
269
|
-
if (app) {
|
|
270
|
-
lines.push("");
|
|
271
|
-
if (app.type === "in-process") lines.push(` ${DIM}${ARROW} app: in-process (Hono)${RESET}`);
|
|
272
|
-
else lines.push(` ${DIM}${ARROW} app: ${app.url}${RESET}`);
|
|
273
|
-
}
|
|
274
|
-
lines.push("");
|
|
275
|
-
return lines.join("\n");
|
|
276
|
-
}
|
|
277
|
-
function formatTableDiff(table, columns, expected, actual) {
|
|
278
|
-
const lines = [];
|
|
279
|
-
lines.push(`Table "${table}" mismatch`);
|
|
280
|
-
lines.push(`${DIM} query: ${columns.join(", ")}${RESET}`);
|
|
281
|
-
lines.push(`${DIM} expected: ${rowLabel(expected.length)}${RESET}`);
|
|
282
|
-
lines.push(`${DIM} received: ${rowLabel(actual.length)}${RESET}`);
|
|
283
|
-
lines.push("");
|
|
284
|
-
lines.push(`${GREEN}- Expected${RESET}`);
|
|
285
|
-
lines.push(`${RED}+ Received${RESET}`);
|
|
286
|
-
lines.push("");
|
|
287
|
-
const header = columns.join(" | ");
|
|
288
|
-
lines.push(`${DIM} ${header}${RESET}`);
|
|
289
|
-
const maxRows = Math.max(expected.length, actual.length);
|
|
290
|
-
for (let i = 0; i < maxRows; i++) {
|
|
291
|
-
const exp = expected[i];
|
|
292
|
-
const act = actual[i];
|
|
293
|
-
if (exp && !act) lines.push(`${GREEN}- ${formatRow(exp)}${RESET}`);
|
|
294
|
-
else if (!exp && act) lines.push(`${RED}+ ${formatRow(act)}${RESET}`);
|
|
295
|
-
else if (exp && act) if (JSON.stringify(exp) === JSON.stringify(act)) lines.push(` ${formatRow(act)}`);
|
|
296
|
-
else {
|
|
297
|
-
lines.push(`${GREEN}- ${formatRow(exp)}${RESET}`);
|
|
298
|
-
lines.push(`${RED}+ ${formatRow(act)}${RESET}`);
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
if (expected.length === 0 && actual.length === 0) lines.push(` (empty)`);
|
|
302
|
-
return lines.join("\n");
|
|
303
|
-
}
|
|
304
|
-
function formatResponseDiff(file, expected, actual) {
|
|
305
|
-
const lines = [];
|
|
306
|
-
lines.push(`Response mismatch (${file})`);
|
|
307
|
-
lines.push("");
|
|
308
|
-
lines.push(`${GREEN}- Expected${RESET}`);
|
|
309
|
-
lines.push(`${RED}+ Received${RESET}`);
|
|
310
|
-
lines.push("");
|
|
311
|
-
const expectedLines = JSON.stringify(expected, null, 2).split("\n");
|
|
312
|
-
const actualLines = JSON.stringify(actual, null, 2).split("\n");
|
|
313
|
-
const maxLines = Math.max(expectedLines.length, actualLines.length);
|
|
314
|
-
for (let i = 0; i < maxLines; i++) {
|
|
315
|
-
const exp = expectedLines[i];
|
|
316
|
-
const act = actualLines[i];
|
|
317
|
-
if (exp === act) lines.push(` ${exp}`);
|
|
318
|
-
else {
|
|
319
|
-
if (exp !== void 0) lines.push(`${GREEN}- ${exp}${RESET}`);
|
|
320
|
-
if (act !== void 0) lines.push(`${RED}+ ${act}${RESET}`);
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
return lines.join("\n");
|
|
324
|
-
}
|
|
325
|
-
function formatDirectoryDiff(fixtureName, diff, hint) {
|
|
326
|
-
const lines = [];
|
|
327
|
-
const total = diff.added.length + diff.removed.length + diff.changed.length;
|
|
328
|
-
lines.push(`Directory mismatch: ${BOLD}${fixtureName}${RESET}`);
|
|
329
|
-
lines.push(`${DIM} ${total} difference${total === 1 ? "" : "s"}: ${diff.added.length} added, ${diff.removed.length} removed, ${diff.changed.length} changed${RESET}`);
|
|
330
|
-
lines.push("");
|
|
331
|
-
lines.push(`${GREEN}- Expected (fixture)${RESET}`);
|
|
332
|
-
lines.push(`${RED}+ Received (generated)${RESET}`);
|
|
333
|
-
lines.push("");
|
|
334
|
-
for (const path of diff.added) lines.push(`${RED}+ added ${path}${RESET} ${DIM}(not in fixture)${RESET}`);
|
|
335
|
-
for (const path of diff.removed) lines.push(`${GREEN}- removed ${path}${RESET} ${DIM}(in fixture, not generated)${RESET}`);
|
|
336
|
-
for (const { path, expected, actual } of diff.changed) {
|
|
337
|
-
const expectedLines = expected.split("\n");
|
|
338
|
-
const actualLines = actual.split("\n");
|
|
339
|
-
const changedCount = countLineDifferences(expectedLines, actualLines);
|
|
340
|
-
lines.push(`${BOLD}~ changed ${path}${RESET} ${DIM}(${changedCount} line${changedCount === 1 ? "" : "s"} differ)${RESET}`);
|
|
341
|
-
let shown = 0;
|
|
342
|
-
const maxShown = 5;
|
|
343
|
-
const maxLines = Math.max(expectedLines.length, actualLines.length);
|
|
344
|
-
for (let i = 0; i < maxLines && shown < maxShown; i++) {
|
|
345
|
-
const exp = expectedLines[i];
|
|
346
|
-
const act = actualLines[i];
|
|
347
|
-
if (exp !== act) {
|
|
348
|
-
lines.push(`${DIM} line ${i + 1}:${RESET}`);
|
|
349
|
-
if (exp !== void 0) lines.push(` ${GREEN}- ${exp}${RESET}`);
|
|
350
|
-
if (act !== void 0) lines.push(` ${RED}+ ${act}${RESET}`);
|
|
351
|
-
shown++;
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
if (changedCount > maxShown) lines.push(` ${DIM}... ${changedCount - maxShown} more line(s)${RESET}`);
|
|
355
|
-
}
|
|
356
|
-
lines.push("");
|
|
357
|
-
lines.push(`${DIM}${hint}${RESET}`);
|
|
358
|
-
return lines.join("\n");
|
|
359
|
-
}
|
|
360
|
-
function countLineDifferences(expected, actual) {
|
|
361
|
-
let count = 0;
|
|
362
|
-
const max = Math.max(expected.length, actual.length);
|
|
363
|
-
for (let i = 0; i < max; i++) if (expected[i] !== actual[i]) count++;
|
|
364
|
-
return count;
|
|
365
|
-
}
|
|
366
|
-
function rowLabel(n) {
|
|
367
|
-
return n === 1 ? "1 row" : `${n} rows`;
|
|
368
|
-
}
|
|
369
|
-
function formatRow(row) {
|
|
370
|
-
return row.map((v) => String(v ?? "null")).join(" | ");
|
|
371
|
-
}
|
|
372
|
-
/** Strip ANSI escape codes from a string. */
|
|
373
|
-
function stripAnsi(str) {
|
|
374
|
-
return str.replace(/\x1b\[[0-9;]*m/g, "");
|
|
375
|
-
}
|
|
376
|
-
/** Strip ANSI codes and replace volatile tokens (ports, durations) with placeholders. */
|
|
377
|
-
function normalizeOutput(str) {
|
|
378
|
-
return stripAnsi(str).replace(/localhost:\d+/g, "localhost:PORT").replace(/\d+ms/g, "Xms").replace(/\d+\.\d+s/g, "X.Xs").trim();
|
|
379
|
-
}
|
|
380
|
-
//#endregion
|
|
381
|
-
//#region src/builder/common/directory-accessor.ts
|
|
382
|
-
/**
|
|
383
|
-
* Detect whether the user wants to update snapshots — `true` for any of:
|
|
384
|
-
* - vitest run with `-u` / `--update`
|
|
385
|
-
* - JTERRAZZ_TEST_UPDATE=1
|
|
386
|
-
* - UPDATE_SNAPSHOTS=1
|
|
387
|
-
*/
|
|
388
|
-
function shouldUpdateSnapshots() {
|
|
389
|
-
if (process.env.JTERRAZZ_TEST_UPDATE === "1") return true;
|
|
390
|
-
if (process.env.UPDATE_SNAPSHOTS === "1") return true;
|
|
391
|
-
if (process.argv.includes("-u") || process.argv.includes("--update")) return true;
|
|
392
|
-
return false;
|
|
393
|
-
}
|
|
394
|
-
/**
|
|
395
|
-
* Handle to a directory produced by a specification run.
|
|
396
|
-
* Supports snapshot-based assertions via {@link toMatchFixture} and file listing via {@link files}.
|
|
397
|
-
*/
|
|
398
|
-
var DirectoryAccessor = class {
|
|
399
|
-
absPath;
|
|
400
|
-
testDir;
|
|
401
|
-
constructor(absPath, testDir) {
|
|
402
|
-
this.absPath = absPath;
|
|
403
|
-
this.testDir = testDir;
|
|
404
|
-
}
|
|
405
|
-
/**
|
|
406
|
-
* Compare the directory tree against `expected/{name}/` (relative to the test file).
|
|
407
|
-
* On mismatch, throws with a structured diff. With update mode enabled, the
|
|
408
|
-
* fixture is overwritten with the current contents instead.
|
|
409
|
-
*/
|
|
410
|
-
async toMatchFixture(name, options = {}) {
|
|
411
|
-
const fixtureDir = (0, node_path.resolve)(this.testDir, "expected", name);
|
|
412
|
-
if (options.update ?? shouldUpdateSnapshots()) {
|
|
413
|
-
(0, node_fs.rmSync)(fixtureDir, {
|
|
414
|
-
force: true,
|
|
415
|
-
recursive: true
|
|
416
|
-
});
|
|
417
|
-
(0, node_fs.mkdirSync)(fixtureDir, { recursive: true });
|
|
418
|
-
(0, node_fs.cpSync)(this.absPath, fixtureDir, { recursive: true });
|
|
419
|
-
return;
|
|
420
|
-
}
|
|
421
|
-
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.`);
|
|
422
|
-
const diff = await diffDirectories(fixtureDir, this.absPath, { ignore: options.ignore });
|
|
423
|
-
if (diff.added.length === 0 && diff.removed.length === 0 && diff.changed.length === 0) return;
|
|
424
|
-
throw new Error(formatDirectoryDiff(name, diff, "Run with JTERRAZZ_TEST_UPDATE=1 to update the fixture."));
|
|
425
|
-
}
|
|
426
|
-
/**
|
|
427
|
-
* List all files in the directory (recursive, sorted, ignoring defaults).
|
|
428
|
-
* Useful for ad-hoc assertions when you don't want a full snapshot.
|
|
429
|
-
*/
|
|
430
|
-
async files(options = {}) {
|
|
431
|
-
return walkDirectory(this.absPath, options);
|
|
432
|
-
}
|
|
433
|
-
};
|
|
434
|
-
//#endregion
|
|
435
|
-
//#region src/builder/common/grep.ts
|
|
436
|
-
/**
|
|
437
|
-
* Extract text blocks from output that contain a pattern.
|
|
438
|
-
* Splits by blank lines (how linter/compiler output is structured),
|
|
439
|
-
* returns only blocks matching the pattern.
|
|
440
|
-
*
|
|
441
|
-
* @example
|
|
442
|
-
* expect(grep(result.stdout, "unused-var.ts")).toContain("no-unused-vars")
|
|
443
|
-
* expect(grep(result.stdout, "valid/sorted.ts")).not.toContain("sort-imports")
|
|
444
|
-
*/
|
|
445
|
-
function grep(output, pattern) {
|
|
446
|
-
return output.replace(/\x1b\[[0-9;]*m/g, "").split(/\n\s*\n/).filter((block) => block.includes(pattern)).join("\n\n");
|
|
447
|
-
}
|
|
448
|
-
//#endregion
|
|
449
|
-
//#region src/builder/common/response-accessor.ts
|
|
450
|
-
/** Accessor for an HTTP response body with file-based assertion support. */
|
|
451
|
-
var ResponseAccessor = class {
|
|
452
|
-
body;
|
|
453
|
-
testDir;
|
|
454
|
-
constructor(body, testDir) {
|
|
455
|
-
this.body = body;
|
|
456
|
-
this.testDir = testDir;
|
|
457
|
-
}
|
|
458
|
-
/**
|
|
459
|
-
* Assert that the response body matches the JSON in `responses/{file}`.
|
|
460
|
-
*
|
|
461
|
-
* @example
|
|
462
|
-
* result.response.toMatchFile("expected-items.json");
|
|
463
|
-
*/
|
|
464
|
-
toMatchFile(file) {
|
|
465
|
-
const expected = JSON.parse((0, node_fs.readFileSync)((0, node_path.resolve)(this.testDir, "responses", file), "utf8"));
|
|
466
|
-
if (JSON.stringify(this.body) !== JSON.stringify(expected)) throw new Error(formatResponseDiff(file, expected, this.body));
|
|
467
|
-
}
|
|
468
|
-
};
|
|
469
|
-
//#endregion
|
|
470
|
-
//#region src/builder/common/table-assertion.ts
|
|
471
|
-
/** Assertion helper for verifying database table contents after a specification run. */
|
|
472
|
-
var TableAssertion = class {
|
|
473
|
-
tableName;
|
|
474
|
-
db;
|
|
475
|
-
constructor(tableName, db) {
|
|
476
|
-
this.tableName = tableName;
|
|
477
|
-
this.db = db;
|
|
478
|
-
}
|
|
479
|
-
/**
|
|
480
|
-
* Assert that the table contains exactly the expected rows for the given columns.
|
|
481
|
-
*
|
|
482
|
-
* @example
|
|
483
|
-
* await result.table("users").toMatch({
|
|
484
|
-
* columns: ["name", "email"],
|
|
485
|
-
* rows: [["Alice", "alice@example.com"]],
|
|
486
|
-
* });
|
|
487
|
-
*/
|
|
488
|
-
async toMatch(expected) {
|
|
489
|
-
const actual = await this.db.query(this.tableName, expected.columns);
|
|
490
|
-
if (JSON.stringify(actual) !== JSON.stringify(expected.rows)) throw new Error(formatTableDiff(this.tableName, expected.columns, expected.rows, actual));
|
|
491
|
-
}
|
|
492
|
-
/** Assert that the table has zero rows. */
|
|
493
|
-
async toBeEmpty() {
|
|
494
|
-
const actual = await this.db.query(this.tableName, ["*"]);
|
|
495
|
-
if (actual.length !== 0) throw new Error(`Expected table "${this.tableName}" to be empty, but it has ${actual.length} rows`);
|
|
496
|
-
}
|
|
497
|
-
};
|
|
498
|
-
//#endregion
|
|
499
|
-
//#region src/builder/common/result.ts
|
|
500
|
-
/**
|
|
501
|
-
* The outcome of a single specification run.
|
|
502
|
-
* Provides accessors for CLI output, HTTP responses, files, directories, and database tables.
|
|
503
|
-
*/
|
|
504
|
-
var SpecificationResult = class {
|
|
505
|
-
commandResult;
|
|
506
|
-
config;
|
|
507
|
-
requestInfo;
|
|
508
|
-
responseData;
|
|
509
|
-
testDir;
|
|
510
|
-
workDir;
|
|
511
|
-
constructor(options) {
|
|
512
|
-
this.responseData = options.response;
|
|
513
|
-
this.commandResult = options.commandResult;
|
|
514
|
-
this.config = options.config;
|
|
515
|
-
this.testDir = options.testDir;
|
|
516
|
-
this.requestInfo = options.requestInfo;
|
|
517
|
-
this.workDir = options.workDir;
|
|
518
|
-
}
|
|
519
|
-
/** The process exit code. Only available after a CLI action. */
|
|
520
|
-
get exitCode() {
|
|
521
|
-
if (!this.commandResult) throw new Error(".exitCode requires a CLI action (.exec())");
|
|
522
|
-
return this.commandResult.exitCode;
|
|
523
|
-
}
|
|
524
|
-
/** The HTTP response status code. Only available after an HTTP action. */
|
|
525
|
-
get status() {
|
|
526
|
-
if (!this.responseData) throw new Error(".status requires an HTTP action (.get(), .post(), etc.)");
|
|
527
|
-
return this.responseData.status;
|
|
528
|
-
}
|
|
529
|
-
/** The captured standard output. Only available after a CLI action. */
|
|
530
|
-
get stdout() {
|
|
531
|
-
if (!this.commandResult) throw new Error(".stdout requires a CLI action (.exec())");
|
|
532
|
-
return this.commandResult.stdout;
|
|
533
|
-
}
|
|
534
|
-
/** The captured standard error. Only available after a CLI action. */
|
|
535
|
-
get stderr() {
|
|
536
|
-
if (!this.commandResult) throw new Error(".stderr requires a CLI action (.exec())");
|
|
537
|
-
return this.commandResult.stderr;
|
|
538
|
-
}
|
|
539
|
-
/**
|
|
540
|
-
* Extract text blocks from stdout that contain a pattern.
|
|
541
|
-
* Useful for parsing structured CLI output (linters, compilers).
|
|
542
|
-
*
|
|
543
|
-
* @example
|
|
544
|
-
* expect(result.grep('error.ts')).toContain('no-unused-vars');
|
|
545
|
-
*/
|
|
546
|
-
grep(pattern) {
|
|
547
|
-
return grep(this.stdout, pattern);
|
|
548
|
-
}
|
|
549
|
-
/** Access the HTTP response body for assertions. Only available after an HTTP action. */
|
|
550
|
-
get response() {
|
|
551
|
-
if (!this.responseData) throw new Error(".response requires an HTTP action (.get(), .post(), etc.)");
|
|
552
|
-
return new ResponseAccessor(this.responseData.body, this.testDir);
|
|
553
|
-
}
|
|
554
|
-
/** Access a directory (relative to the working directory) for snapshot assertions. */
|
|
555
|
-
directory(path = ".") {
|
|
556
|
-
return new DirectoryAccessor((0, node_path.resolve)(this.workDir ?? this.testDir, path), this.testDir);
|
|
557
|
-
}
|
|
558
|
-
/** Access a single file (relative to the working directory) for content assertions. */
|
|
559
|
-
file(path) {
|
|
560
|
-
const resolvedPath = (0, node_path.resolve)(this.workDir ?? this.testDir, path);
|
|
561
|
-
const exists = (0, node_fs.existsSync)(resolvedPath);
|
|
562
|
-
return {
|
|
563
|
-
get content() {
|
|
564
|
-
if (!exists) throw new Error(`File not found: ${path}`);
|
|
565
|
-
return (0, node_fs.readFileSync)(resolvedPath, "utf8");
|
|
566
|
-
},
|
|
567
|
-
exists
|
|
568
|
-
};
|
|
569
|
-
}
|
|
570
|
-
/** Access a database table for row-level assertions. */
|
|
571
|
-
table(tableName, options) {
|
|
572
|
-
const db = this.resolveDatabase(options?.service);
|
|
573
|
-
if (!db) throw new Error(options?.service ? `table("${tableName}") requires database "${options.service}" but it was not found` : `table("${tableName}") requires a database adapter`);
|
|
574
|
-
return new TableAssertion(tableName, db);
|
|
575
|
-
}
|
|
576
|
-
resolveDatabase(serviceName) {
|
|
577
|
-
if (serviceName && this.config.databases) return this.config.databases.get(serviceName);
|
|
578
|
-
return this.config.database;
|
|
579
|
-
}
|
|
580
|
-
};
|
|
581
|
-
//#endregion
|
|
582
|
-
//#region src/builder/specification-builder.ts
|
|
583
|
-
/**
|
|
584
|
-
* Fluent builder for declaring a single test specification.
|
|
585
|
-
*
|
|
586
|
-
* Chain setup methods ({@link seed}, {@link fixture}, {@link env}), an action
|
|
587
|
-
* ({@link get}, {@link post}, {@link exec}), then call {@link run} to execute
|
|
588
|
-
* and receive a {@link SpecificationResult} for assertions.
|
|
589
|
-
*/
|
|
590
|
-
var SpecificationBuilder = class {
|
|
591
|
-
commandArgs = null;
|
|
592
|
-
commandEnv = {};
|
|
593
|
-
config;
|
|
594
|
-
fixtures = [];
|
|
595
|
-
intercepts = [];
|
|
596
|
-
jobName = null;
|
|
597
|
-
label;
|
|
598
|
-
mocks = [];
|
|
599
|
-
projectName = null;
|
|
600
|
-
request = null;
|
|
601
|
-
requestHeaders = {};
|
|
602
|
-
seeds = [];
|
|
603
|
-
spawnConfig = null;
|
|
604
|
-
testDir;
|
|
605
|
-
constructor(config, testDir, label) {
|
|
606
|
-
this.config = config;
|
|
607
|
-
this.testDir = testDir;
|
|
608
|
-
this.label = label;
|
|
609
|
-
}
|
|
610
|
-
/**
|
|
611
|
-
* Queue a SQL seed file to run before the action.
|
|
612
|
-
*
|
|
613
|
-
* @example
|
|
614
|
-
* spec("creates user").seed("users.sql").exec("list-users").run();
|
|
615
|
-
*/
|
|
616
|
-
seed(file, options) {
|
|
617
|
-
this.seeds.push({
|
|
618
|
-
file,
|
|
619
|
-
service: options?.service
|
|
620
|
-
});
|
|
621
|
-
return this;
|
|
622
|
-
}
|
|
623
|
-
/** Copy a file or directory from `fixtures/` into the working directory before execution. */
|
|
624
|
-
fixture(file) {
|
|
625
|
-
this.fixtures.push({ file });
|
|
626
|
-
return this;
|
|
627
|
-
}
|
|
628
|
-
/** Copy an entire project fixture from `fixturesRoot` into the working directory. */
|
|
629
|
-
project(name) {
|
|
630
|
-
this.projectName = name;
|
|
631
|
-
return this;
|
|
632
|
-
}
|
|
633
|
-
/** Register an MSW mock definition file from `mock/` to intercept HTTP calls. */
|
|
634
|
-
mock(file) {
|
|
635
|
-
this.mocks.push({ file });
|
|
636
|
-
return this;
|
|
637
|
-
}
|
|
638
|
-
/**
|
|
639
|
-
* Set environment variables for the CLI process. Merged on top of process.env.
|
|
640
|
-
* Use `null` to unset a variable. Multiple calls merge.
|
|
641
|
-
*
|
|
642
|
-
* The token `$WORKDIR` (in any value) is replaced with the actual working
|
|
643
|
-
* directory at run-time — useful for tests that need a fully isolated `HOME`.
|
|
644
|
-
*
|
|
645
|
-
* @example
|
|
646
|
-
* spec("...").env({ HOME: "$WORKDIR", TZ: "UTC" }).exec("status").run();
|
|
647
|
-
*/
|
|
648
|
-
env(env) {
|
|
649
|
-
this.commandEnv = {
|
|
650
|
-
...this.commandEnv,
|
|
651
|
-
...env
|
|
652
|
-
};
|
|
653
|
-
return this;
|
|
654
|
-
}
|
|
655
|
-
/**
|
|
656
|
-
* Set HTTP headers for the request. Multiple calls merge.
|
|
657
|
-
*
|
|
658
|
-
* @example
|
|
659
|
-
* spec("french").headers({ 'Accept-Language': 'fr' }).get("/articles").run();
|
|
660
|
-
*/
|
|
661
|
-
headers(headers) {
|
|
662
|
-
this.requestHeaders = {
|
|
663
|
-
...this.requestHeaders,
|
|
664
|
-
...headers
|
|
665
|
-
};
|
|
666
|
-
return this;
|
|
667
|
-
}
|
|
668
|
-
/**
|
|
669
|
-
* Intercept an outgoing HTTP request and return a controlled response.
|
|
670
|
-
* Uses MSW under the hood. Intercepts are queued — multiple calls with the
|
|
671
|
-
* same trigger fire sequentially (first match consumed first).
|
|
672
|
-
*
|
|
673
|
-
* @param trigger - What to match (use openai.agent(), http.get(), etc.)
|
|
674
|
-
* @param response - What to return: an InterceptResponse object, or a filename
|
|
675
|
-
* from `intercepts/` (JSON loaded and auto-wrapped by the trigger).
|
|
676
|
-
*
|
|
677
|
-
* @example
|
|
678
|
-
* // With inline response
|
|
679
|
-
* .intercept(openai.agent({...}), openai.reply({ categories: ['TECH'] }))
|
|
680
|
-
*
|
|
681
|
-
* // With JSON fixture file (loaded from intercepts/ directory)
|
|
682
|
-
* .intercept(openai.agent({...}), 'ingest-tech.json')
|
|
683
|
-
* .intercept(http.get(url), 'world-news-tech.json')
|
|
684
|
-
*/
|
|
685
|
-
intercept(trigger, response) {
|
|
686
|
-
if (typeof response === "string") {
|
|
687
|
-
const slashIndex = response.indexOf("/");
|
|
688
|
-
if (slashIndex === -1) throw new Error(`.intercept(): file path must be 'adapter/filename.json' (e.g. 'openai/ingest-tech.json'), got '${response}'`);
|
|
689
|
-
const adapterName = response.slice(0, slashIndex);
|
|
690
|
-
if (adapterName !== trigger.adapter) throw new Error(`.intercept(): adapter mismatch - trigger uses '${trigger.adapter}' but file path starts with '${adapterName}/'`);
|
|
691
|
-
const filePath = (0, node_path.resolve)(this.testDir, "intercepts", response);
|
|
692
|
-
const data = JSON.parse((0, node_fs.readFileSync)(filePath, "utf8"));
|
|
693
|
-
const resolved = trigger.wrap(data);
|
|
694
|
-
this.intercepts.push({
|
|
695
|
-
trigger,
|
|
696
|
-
response: resolved
|
|
697
|
-
});
|
|
698
|
-
} else this.intercepts.push({
|
|
699
|
-
trigger,
|
|
700
|
-
response
|
|
701
|
-
});
|
|
702
|
-
return this;
|
|
703
|
-
}
|
|
704
|
-
/**
|
|
705
|
-
* Send a GET request to the server adapter.
|
|
706
|
-
*
|
|
707
|
-
* @example
|
|
708
|
-
* spec("list items").get("/api/items").run();
|
|
709
|
-
*/
|
|
710
|
-
get(path) {
|
|
711
|
-
this.request = {
|
|
712
|
-
method: "GET",
|
|
713
|
-
path
|
|
714
|
-
};
|
|
715
|
-
return this;
|
|
716
|
-
}
|
|
717
|
-
/**
|
|
718
|
-
* Send a POST request to the server adapter.
|
|
719
|
-
*
|
|
720
|
-
* @param bodyFile - Optional JSON file from `requests/` to use as the request body.
|
|
721
|
-
* @example
|
|
722
|
-
* spec("create item").post("/api/items", "new-item.json").run();
|
|
723
|
-
*/
|
|
724
|
-
post(path, bodyFile) {
|
|
725
|
-
this.request = {
|
|
726
|
-
bodyFile,
|
|
727
|
-
method: "POST",
|
|
728
|
-
path
|
|
729
|
-
};
|
|
730
|
-
return this;
|
|
731
|
-
}
|
|
732
|
-
/** Send a PUT request to the server adapter. */
|
|
733
|
-
put(path, bodyFile) {
|
|
734
|
-
this.request = {
|
|
735
|
-
bodyFile,
|
|
736
|
-
method: "PUT",
|
|
737
|
-
path
|
|
738
|
-
};
|
|
739
|
-
return this;
|
|
740
|
-
}
|
|
741
|
-
/** Send a DELETE request to the server adapter. */
|
|
742
|
-
delete(path) {
|
|
743
|
-
this.request = {
|
|
744
|
-
method: "DELETE",
|
|
745
|
-
path
|
|
746
|
-
};
|
|
747
|
-
return this;
|
|
748
|
-
}
|
|
749
|
-
/**
|
|
750
|
-
* Execute a CLI command (or a sequence of commands) in an isolated working directory.
|
|
751
|
-
* When an array is passed, commands run sequentially and stop on the first non-zero exit code.
|
|
752
|
-
*
|
|
753
|
-
* @example
|
|
754
|
-
* spec("init project").exec("init --name demo").run();
|
|
755
|
-
* spec("multi-step").exec(["init", "build"]).run();
|
|
756
|
-
*/
|
|
757
|
-
exec(args) {
|
|
758
|
-
this.commandArgs = args;
|
|
759
|
-
return this;
|
|
760
|
-
}
|
|
761
|
-
/** Spawn a long-running CLI process with custom spawn options (e.g. timeout, signal). */
|
|
762
|
-
spawn(args, options) {
|
|
763
|
-
this.spawnConfig = {
|
|
764
|
-
args,
|
|
765
|
-
options
|
|
766
|
-
};
|
|
767
|
-
return this;
|
|
768
|
-
}
|
|
769
|
-
/**
|
|
770
|
-
* Execute a named job registered via the app() factory.
|
|
771
|
-
*
|
|
772
|
-
* @param name - The job name to trigger (must match a registered JobHandle.name).
|
|
773
|
-
*
|
|
774
|
-
* @example
|
|
775
|
-
* spec('pipeline').intercept(openai.chat(), openai.response({...})).job('report-refresh').run();
|
|
776
|
-
*/
|
|
777
|
-
job(name) {
|
|
778
|
-
this.jobName = name;
|
|
779
|
-
return this;
|
|
780
|
-
}
|
|
781
|
-
/**
|
|
782
|
-
* Execute the specification: run seeds, copy fixtures, then perform the
|
|
783
|
-
* configured action (HTTP or CLI).
|
|
784
|
-
*
|
|
785
|
-
* @returns The result object used for assertions.
|
|
786
|
-
* @example
|
|
787
|
-
* const result = await spec("test").exec("status").run();
|
|
788
|
-
* expect(result.exitCode).toBe(0);
|
|
789
|
-
*/
|
|
790
|
-
async run() {
|
|
791
|
-
const hasHttpAction = this.request !== null;
|
|
792
|
-
const hasCliAction = this.commandArgs !== null || this.spawnConfig !== null;
|
|
793
|
-
const hasJobAction = this.jobName !== null;
|
|
794
|
-
const actionCount = [
|
|
795
|
-
hasHttpAction,
|
|
796
|
-
hasCliAction,
|
|
797
|
-
hasJobAction
|
|
798
|
-
].filter(Boolean).length;
|
|
799
|
-
if (actionCount === 0) throw new Error(`Specification "${this.label}": no action defined. Call .get(), .post(), .exec(), .job(), etc. before .run()`);
|
|
800
|
-
if (actionCount > 1) throw new Error(`Specification "${this.label}": cannot mix action types (.get/.post, .exec/.spawn, .job)`);
|
|
801
|
-
let workDir = null;
|
|
802
|
-
if (hasCliAction) workDir = this.prepareWorkDir();
|
|
803
|
-
if (this.config.databases) for (const db of this.config.databases.values()) await db.reset();
|
|
804
|
-
else if (this.config.database) await this.config.database.reset();
|
|
805
|
-
for (const entry of this.seeds) {
|
|
806
|
-
let db;
|
|
807
|
-
if (entry.service && this.config.databases) {
|
|
808
|
-
db = this.config.databases.get(entry.service);
|
|
809
|
-
if (!db) throw new Error(`seed() targets database "${entry.service}" but it was not found. Available: ${[...this.config.databases.keys()].join(", ")}`);
|
|
810
|
-
} else db = this.config.database;
|
|
811
|
-
if (!db) throw new Error("seed() requires a database adapter");
|
|
812
|
-
const sql = (0, node_fs.readFileSync)((0, node_path.resolve)(this.testDir, "seeds", entry.file), "utf8");
|
|
813
|
-
await db.seed(sql);
|
|
814
|
-
}
|
|
815
|
-
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 });
|
|
816
|
-
let cleanupIntercepts = null;
|
|
817
|
-
if (this.intercepts.length > 0) {
|
|
818
|
-
const { registerIntercepts } = await Promise.resolve().then(() => require("./intercept2.cjs"));
|
|
819
|
-
cleanupIntercepts = await registerIntercepts(this.intercepts);
|
|
820
|
-
}
|
|
821
|
-
try {
|
|
822
|
-
if (hasHttpAction) return await this.runHttpAction();
|
|
823
|
-
if (hasJobAction) return await this.runJobAction();
|
|
824
|
-
return await this.runCliAction(workDir);
|
|
825
|
-
} finally {
|
|
826
|
-
if (cleanupIntercepts) cleanupIntercepts();
|
|
827
|
-
}
|
|
828
|
-
}
|
|
829
|
-
resolveEnv(workDir) {
|
|
830
|
-
const keys = Object.keys(this.commandEnv);
|
|
831
|
-
if (keys.length === 0) return;
|
|
832
|
-
const resolved = {};
|
|
833
|
-
for (const key of keys) {
|
|
834
|
-
const value = this.commandEnv[key];
|
|
835
|
-
resolved[key] = typeof value === "string" ? value.replace(/\$WORKDIR/g, workDir) : value;
|
|
836
|
-
}
|
|
837
|
-
return resolved;
|
|
838
|
-
}
|
|
839
|
-
prepareWorkDir() {
|
|
840
|
-
const tempDir = (0, node_fs.mkdtempSync)((0, node_path.resolve)((0, node_os.tmpdir)(), "spec-cli-"));
|
|
841
|
-
if (this.projectName && this.config.fixturesRoot) {
|
|
842
|
-
const projectDir = (0, node_path.resolve)(this.config.fixturesRoot, this.projectName);
|
|
843
|
-
if (!(0, node_fs.existsSync)(projectDir)) throw new Error(`project("${this.projectName}"): fixture project not found at ${projectDir}`);
|
|
844
|
-
(0, node_fs.cpSync)(projectDir, tempDir, { recursive: true });
|
|
845
|
-
}
|
|
846
|
-
return tempDir;
|
|
847
|
-
}
|
|
848
|
-
async runHttpAction() {
|
|
849
|
-
if (!this.config.server) throw new Error("HTTP actions require a server adapter (use integration() or e2e())");
|
|
850
|
-
let body;
|
|
851
|
-
if (this.request.bodyFile) body = JSON.parse((0, node_fs.readFileSync)((0, node_path.resolve)(this.testDir, "requests", this.request.bodyFile), "utf8"));
|
|
852
|
-
const headers = Object.keys(this.requestHeaders).length > 0 ? this.requestHeaders : void 0;
|
|
853
|
-
const response = await this.config.server.request(this.request.method, this.request.path, body, headers);
|
|
854
|
-
return new SpecificationResult({
|
|
855
|
-
config: this.config,
|
|
856
|
-
requestInfo: {
|
|
857
|
-
body,
|
|
858
|
-
method: this.request.method,
|
|
859
|
-
path: this.request.path
|
|
860
|
-
},
|
|
861
|
-
response,
|
|
862
|
-
testDir: this.testDir
|
|
863
|
-
});
|
|
864
|
-
}
|
|
865
|
-
async runJobAction() {
|
|
866
|
-
if (!this.config.jobs?.length) throw new Error("Job actions require jobs registered via app(() => ({ server, jobs }))");
|
|
867
|
-
const job = this.config.jobs.find((j) => j.name === this.jobName);
|
|
868
|
-
if (!job) {
|
|
869
|
-
const available = this.config.jobs.map((j) => j.name).join(", ");
|
|
870
|
-
throw new Error(`job("${this.jobName}"): not found. Available: ${available}`);
|
|
871
|
-
}
|
|
872
|
-
await job.execute();
|
|
873
|
-
return new SpecificationResult({
|
|
874
|
-
config: this.config,
|
|
875
|
-
testDir: this.testDir
|
|
876
|
-
});
|
|
877
|
-
}
|
|
878
|
-
async runCliAction(workDir) {
|
|
879
|
-
if (!this.config.command) throw new Error("CLI actions require a command adapter (use cli())");
|
|
880
|
-
const env = this.resolveEnv(workDir);
|
|
881
|
-
let commandResult;
|
|
882
|
-
if (this.spawnConfig) commandResult = await this.config.command.spawn(this.spawnConfig.args, workDir, this.spawnConfig.options, env);
|
|
883
|
-
else if (Array.isArray(this.commandArgs)) {
|
|
884
|
-
commandResult = {
|
|
885
|
-
exitCode: 0,
|
|
886
|
-
stderr: "",
|
|
887
|
-
stdout: ""
|
|
888
|
-
};
|
|
889
|
-
for (const args of this.commandArgs) {
|
|
890
|
-
commandResult = await this.config.command.exec(args, workDir, env);
|
|
891
|
-
if (commandResult.exitCode !== 0) break;
|
|
892
|
-
}
|
|
893
|
-
} else commandResult = await this.config.command.exec(this.commandArgs, workDir, env);
|
|
894
|
-
return new SpecificationResult({
|
|
895
|
-
commandResult,
|
|
896
|
-
config: this.config,
|
|
897
|
-
testDir: this.testDir,
|
|
898
|
-
workDir
|
|
899
|
-
});
|
|
900
|
-
}
|
|
901
|
-
};
|
|
902
|
-
function getCallerDir() {
|
|
903
|
-
const stack = (/* @__PURE__ */ new Error("caller detection")).stack;
|
|
904
|
-
if (!stack) throw new Error("Cannot detect caller directory: no stack trace");
|
|
905
|
-
const lines = stack.split("\n");
|
|
906
|
-
for (const line of lines) {
|
|
907
|
-
const match = line.match(/at\s+(?:.*?\()?(?:file:\/\/)?([^:)]+):\d+:\d+/);
|
|
908
|
-
if (!match) continue;
|
|
909
|
-
const filePath = match[1];
|
|
910
|
-
if (filePath.includes("node_modules")) continue;
|
|
911
|
-
if (filePath.includes("/src/builder/") || filePath.includes("/src/spec/")) continue;
|
|
912
|
-
return (0, node_path.resolve)(filePath, "..");
|
|
913
|
-
}
|
|
914
|
-
throw new Error("Cannot detect caller directory from stack trace");
|
|
915
|
-
}
|
|
916
|
-
/**
|
|
917
|
-
* Create a {@link SpecificationRunner} bound to the given adapter configuration.
|
|
918
|
-
* The test file directory is auto-detected from the call stack.
|
|
919
|
-
*/
|
|
920
|
-
function createSpecificationRunner(config) {
|
|
921
|
-
return (label) => {
|
|
922
|
-
return new SpecificationBuilder(config, getCallerDir(), label);
|
|
923
|
-
};
|
|
924
|
-
}
|
|
925
|
-
//#endregion
|
|
926
|
-
//#region src/docker/docker-adapter.ts
|
|
927
|
-
/**
|
|
928
|
-
* Implements {@link DockerContainerPort} by shelling out to the Docker CLI.
|
|
929
|
-
* Each instance is bound to a single container ID.
|
|
930
|
-
*/
|
|
931
|
-
var DockerAdapter = class {
|
|
932
|
-
containerId;
|
|
933
|
-
constructor(containerId) {
|
|
934
|
-
this.containerId = containerId;
|
|
935
|
-
}
|
|
936
|
-
async exec(cmd) {
|
|
937
|
-
return (0, node_child_process.execSync)(`docker exec ${this.containerId} ${cmd.map((c) => `'${c}'`).join(" ")}`, {
|
|
938
|
-
encoding: "utf8",
|
|
939
|
-
timeout: 1e4
|
|
940
|
-
}).trim();
|
|
941
|
-
}
|
|
942
|
-
async file(path) {
|
|
943
|
-
try {
|
|
944
|
-
return {
|
|
945
|
-
exists: true,
|
|
946
|
-
content: await this.exec(["cat", path])
|
|
947
|
-
};
|
|
948
|
-
} catch {
|
|
949
|
-
return {
|
|
950
|
-
exists: false,
|
|
951
|
-
content: ""
|
|
952
|
-
};
|
|
953
|
-
}
|
|
954
|
-
}
|
|
955
|
-
async isRunning() {
|
|
956
|
-
try {
|
|
957
|
-
return (0, node_child_process.execSync)(`docker inspect --format='{{.State.Running}}' ${this.containerId}`, {
|
|
958
|
-
encoding: "utf8",
|
|
959
|
-
timeout: 5e3
|
|
960
|
-
}).trim() === "true";
|
|
961
|
-
} catch {
|
|
962
|
-
return false;
|
|
963
|
-
}
|
|
964
|
-
}
|
|
965
|
-
async logs(tail) {
|
|
966
|
-
return (0, node_child_process.execSync)(`docker logs ${tail ? `--tail ${tail}` : ""} ${this.containerId}`, {
|
|
967
|
-
encoding: "utf8",
|
|
968
|
-
timeout: 1e4
|
|
969
|
-
});
|
|
970
|
-
}
|
|
971
|
-
async inspect() {
|
|
972
|
-
const raw = (0, node_child_process.execSync)(`docker inspect ${this.containerId}`, {
|
|
973
|
-
encoding: "utf8",
|
|
974
|
-
timeout: 5e3
|
|
975
|
-
});
|
|
976
|
-
const data = JSON.parse(raw)[0];
|
|
977
|
-
return {
|
|
978
|
-
id: data.Id,
|
|
979
|
-
name: data.Name,
|
|
980
|
-
state: {
|
|
981
|
-
running: data.State.Running,
|
|
982
|
-
exitCode: data.State.ExitCode,
|
|
983
|
-
status: data.State.Status
|
|
984
|
-
},
|
|
985
|
-
config: {
|
|
986
|
-
image: data.Config.Image,
|
|
987
|
-
env: data.Config.Env || []
|
|
988
|
-
},
|
|
989
|
-
hostConfig: {
|
|
990
|
-
memory: data.HostConfig.Memory || 0,
|
|
991
|
-
cpuQuota: data.HostConfig.CpuQuota || 0,
|
|
992
|
-
networkMode: data.HostConfig.NetworkMode || "",
|
|
993
|
-
mounts: (data.Mounts || []).map((m) => ({
|
|
994
|
-
source: m.Source,
|
|
995
|
-
destination: m.Destination,
|
|
996
|
-
type: m.Type
|
|
997
|
-
}))
|
|
998
|
-
},
|
|
999
|
-
networkSettings: { networks: Object.fromEntries(Object.entries(data.NetworkSettings.Networks || {}).map(([name, net]) => [name, {
|
|
1000
|
-
gateway: net.Gateway,
|
|
1001
|
-
ipAddress: net.IPAddress
|
|
1002
|
-
}])) }
|
|
1003
|
-
};
|
|
1004
|
-
}
|
|
1005
|
-
async exists(path) {
|
|
1006
|
-
try {
|
|
1007
|
-
await this.exec([
|
|
1008
|
-
"test",
|
|
1009
|
-
"-e",
|
|
1010
|
-
path
|
|
1011
|
-
]);
|
|
1012
|
-
return true;
|
|
1013
|
-
} catch {
|
|
1014
|
-
return false;
|
|
1015
|
-
}
|
|
1016
|
-
}
|
|
1017
|
-
};
|
|
1018
|
-
/** Create a {@link DockerContainerPort} bound to an existing container by ID. */
|
|
1019
|
-
function dockerContainer(containerId) {
|
|
1020
|
-
return new DockerAdapter(containerId);
|
|
1021
|
-
}
|
|
1022
|
-
//#endregion
|
|
1023
|
-
//#region src/docker/docker-assertion.ts
|
|
1024
|
-
/**
|
|
1025
|
-
* Fluent assertion builder for Docker containers.
|
|
1026
|
-
* Chain async assertions like `toBeRunning()`, `toHaveFile()`, `toHaveMount()`.
|
|
1027
|
-
*/
|
|
1028
|
-
var DockerAssertion = class {
|
|
1029
|
-
container;
|
|
1030
|
-
constructor(container) {
|
|
1031
|
-
this.container = container;
|
|
1032
|
-
}
|
|
1033
|
-
/** Assert the container is running */
|
|
1034
|
-
async toBeRunning() {
|
|
1035
|
-
if (!await this.container.isRunning()) throw new Error("Expected container to be running");
|
|
1036
|
-
return this;
|
|
1037
|
-
}
|
|
1038
|
-
/** Assert the container is NOT running / doesn't exist */
|
|
1039
|
-
async toNotExist() {
|
|
1040
|
-
if (await this.container.isRunning()) throw new Error("Expected container to not exist or not be running");
|
|
1041
|
-
return this;
|
|
1042
|
-
}
|
|
1043
|
-
/** Assert a file exists inside the container */
|
|
1044
|
-
async toHaveFile(path, opts) {
|
|
1045
|
-
const file = await this.container.file(path);
|
|
1046
|
-
if (!file.exists) throw new Error(`Expected file ${path} to exist in container`);
|
|
1047
|
-
if (opts?.containing && !file.content.includes(opts.containing)) throw new Error(`Expected file ${path} to contain "${opts.containing}", got: ${file.content.slice(0, 200)}`);
|
|
1048
|
-
return this;
|
|
1049
|
-
}
|
|
1050
|
-
/** Assert a file does NOT exist */
|
|
1051
|
-
async toNotHaveFile(path) {
|
|
1052
|
-
if (await this.container.exists(path)) throw new Error(`Expected ${path} to not exist in container`);
|
|
1053
|
-
return this;
|
|
1054
|
-
}
|
|
1055
|
-
/** Assert a directory exists */
|
|
1056
|
-
async toHaveDirectory(path) {
|
|
1057
|
-
if (!await this.container.exists(path)) throw new Error(`Expected directory ${path} to exist in container`);
|
|
1058
|
-
return this;
|
|
1059
|
-
}
|
|
1060
|
-
/** Assert a mount exists */
|
|
1061
|
-
async toHaveMount(destination) {
|
|
1062
|
-
const info = await this.container.inspect();
|
|
1063
|
-
if (!info.hostConfig.mounts.find((m) => m.destination === destination)) {
|
|
1064
|
-
const available = info.hostConfig.mounts.map((m) => m.destination).join(", ");
|
|
1065
|
-
throw new Error(`Expected mount at ${destination}, found: [${available}]`);
|
|
1066
|
-
}
|
|
1067
|
-
return this;
|
|
1068
|
-
}
|
|
1069
|
-
/** Assert network mode */
|
|
1070
|
-
async toHaveNetwork(mode) {
|
|
1071
|
-
const info = await this.container.inspect();
|
|
1072
|
-
if (!info.hostConfig.networkMode.includes(mode)) throw new Error(`Expected network mode "${mode}", got "${info.hostConfig.networkMode}"`);
|
|
1073
|
-
return this;
|
|
1074
|
-
}
|
|
1075
|
-
/** Assert memory limit */
|
|
1076
|
-
async toHaveMemoryLimit(bytes) {
|
|
1077
|
-
const info = await this.container.inspect();
|
|
1078
|
-
if (info.hostConfig.memory !== bytes) throw new Error(`Expected memory limit ${bytes}, got ${info.hostConfig.memory}`);
|
|
1079
|
-
return this;
|
|
1080
|
-
}
|
|
1081
|
-
/** Assert CPU quota */
|
|
1082
|
-
async toHaveCpuQuota(quota) {
|
|
1083
|
-
const info = await this.container.inspect();
|
|
1084
|
-
if (info.hostConfig.cpuQuota !== quota) throw new Error(`Expected CPU quota ${quota}, got ${info.hostConfig.cpuQuota}`);
|
|
1085
|
-
return this;
|
|
1086
|
-
}
|
|
1087
|
-
/** Execute a command and return output for custom assertions */
|
|
1088
|
-
async exec(cmd) {
|
|
1089
|
-
return this.container.exec(cmd);
|
|
1090
|
-
}
|
|
1091
|
-
/** Read a file for custom assertions */
|
|
1092
|
-
async readFile(path) {
|
|
1093
|
-
const file = await this.container.file(path);
|
|
1094
|
-
if (!file.exists) throw new Error(`File ${path} does not exist`);
|
|
1095
|
-
return file.content;
|
|
1096
|
-
}
|
|
1097
|
-
/** Get logs for custom assertions */
|
|
1098
|
-
async getLogs(tail) {
|
|
1099
|
-
return this.container.logs(tail);
|
|
1100
|
-
}
|
|
1101
|
-
};
|
|
1102
|
-
//#endregion
|
|
1103
|
-
//#region src/infra/adapters/compose.adapter.ts
|
|
1104
|
-
/**
|
|
1105
|
-
* Start the full compose stack and stop it all on cleanup.
|
|
1106
|
-
* Supports per-worker project names for parallel execution.
|
|
1107
|
-
*/
|
|
1108
|
-
var ComposeStackAdapter = class {
|
|
1109
|
-
composeFile;
|
|
1110
|
-
projectName;
|
|
1111
|
-
started = false;
|
|
1112
|
-
constructor(composeFile, projectName) {
|
|
1113
|
-
this.composeFile = composeFile;
|
|
1114
|
-
this.projectName = projectName ?? null;
|
|
1115
|
-
}
|
|
1116
|
-
get projectFlag() {
|
|
1117
|
-
return this.projectName ? `-p ${this.projectName}` : "";
|
|
1118
|
-
}
|
|
1119
|
-
run(command) {
|
|
1120
|
-
try {
|
|
1121
|
-
return (0, node_child_process.execSync)(command, {
|
|
1122
|
-
cwd: (0, node_path.dirname)(this.composeFile),
|
|
1123
|
-
encoding: "utf8",
|
|
1124
|
-
timeout: 12e4
|
|
1125
|
-
}).trim();
|
|
1126
|
-
} catch (error) {
|
|
1127
|
-
const stderr = error.stderr?.toString().trim() ?? error.message;
|
|
1128
|
-
throw new Error(`docker compose failed: ${stderr}`, { cause: error });
|
|
1129
|
-
}
|
|
1130
|
-
}
|
|
1131
|
-
async start() {
|
|
1132
|
-
if (this.started) return;
|
|
1133
|
-
this.run(`docker compose ${this.projectFlag} -f ${this.composeFile} up -d --wait`);
|
|
1134
|
-
this.started = true;
|
|
1135
|
-
}
|
|
1136
|
-
async stop() {
|
|
1137
|
-
if (!this.started) return;
|
|
1138
|
-
this.run(`docker compose ${this.projectFlag} -f ${this.composeFile} down -v`);
|
|
1139
|
-
this.started = false;
|
|
1140
|
-
}
|
|
1141
|
-
getMappedPort(serviceName, containerPort) {
|
|
1142
|
-
const port = this.run(`docker compose ${this.projectFlag} -f ${this.composeFile} port ${serviceName} ${containerPort}`).split(":").pop();
|
|
1143
|
-
return Number(port);
|
|
1144
|
-
}
|
|
1145
|
-
getHost() {
|
|
1146
|
-
return "localhost";
|
|
1147
|
-
}
|
|
1148
|
-
};
|
|
1149
|
-
//#endregion
|
|
1150
|
-
//#region src/infra/adapters/testcontainers.adapter.ts
|
|
1151
|
-
/**
|
|
1152
|
-
* Container adapter using testcontainers.
|
|
1153
|
-
* Wraps a GenericContainer for programmatic container lifecycle.
|
|
1154
|
-
*/
|
|
1155
|
-
var TestcontainersAdapter = class {
|
|
1156
|
-
image;
|
|
1157
|
-
containerPort;
|
|
1158
|
-
env;
|
|
1159
|
-
reuse;
|
|
1160
|
-
container = null;
|
|
1161
|
-
constructor(options) {
|
|
1162
|
-
this.image = options.image;
|
|
1163
|
-
this.containerPort = options.port;
|
|
1164
|
-
this.env = options.env ?? {};
|
|
1165
|
-
this.reuse = options.reuse ?? false;
|
|
1166
|
-
}
|
|
1167
|
-
async start() {
|
|
1168
|
-
const { GenericContainer, Wait } = await import("testcontainers");
|
|
1169
|
-
let builder = new GenericContainer(this.image).withExposedPorts(this.containerPort);
|
|
1170
|
-
for (const [key, value] of Object.entries(this.env)) builder = builder.withEnvironment({ [key]: value });
|
|
1171
|
-
if (this.image.startsWith("postgres")) builder = builder.withWaitStrategy(Wait.forLogMessage(/database system is ready to accept connections/, 2));
|
|
1172
|
-
if (this.reuse) builder = builder.withReuse();
|
|
1173
|
-
this.container = await builder.start();
|
|
1174
|
-
}
|
|
1175
|
-
async stop() {
|
|
1176
|
-
if (this.container && !this.reuse) {
|
|
1177
|
-
await this.container.stop();
|
|
1178
|
-
this.container = null;
|
|
1179
|
-
}
|
|
1180
|
-
}
|
|
1181
|
-
getMappedPort(containerPort) {
|
|
1182
|
-
if (!this.container) throw new Error("Container not started");
|
|
1183
|
-
return this.container.getMappedPort(containerPort);
|
|
1184
|
-
}
|
|
1185
|
-
getHost() {
|
|
1186
|
-
if (!this.container) throw new Error("Container not started");
|
|
1187
|
-
return this.container.getHost();
|
|
1188
|
-
}
|
|
1189
|
-
getConnectionString() {
|
|
1190
|
-
return `${this.getHost()}:${this.getMappedPort(this.containerPort)}`;
|
|
1191
|
-
}
|
|
1192
|
-
async getLogs() {
|
|
1193
|
-
if (!this.container) return "";
|
|
1194
|
-
const stream = await this.container.logs();
|
|
1195
|
-
return new Promise((resolve) => {
|
|
1196
|
-
let output = "";
|
|
1197
|
-
stream.on("data", (chunk) => {
|
|
1198
|
-
output += chunk.toString();
|
|
1199
|
-
});
|
|
1200
|
-
stream.on("end", () => {
|
|
1201
|
-
resolve(output);
|
|
1202
|
-
});
|
|
1203
|
-
setTimeout(() => {
|
|
1204
|
-
resolve(output);
|
|
1205
|
-
}, 1e3);
|
|
1206
|
-
});
|
|
1207
|
-
}
|
|
1208
|
-
};
|
|
1209
|
-
//#endregion
|
|
1210
|
-
//#region src/infra/compose-parser.ts
|
|
1211
|
-
/**
|
|
1212
|
-
* Detect the service type from the image name.
|
|
1213
|
-
*/
|
|
1214
|
-
function detectServiceType(image) {
|
|
1215
|
-
if (!image) return "app";
|
|
1216
|
-
const lower = image.toLowerCase();
|
|
1217
|
-
if (lower.startsWith("postgres")) return "postgres";
|
|
1218
|
-
if (lower.startsWith("redis")) return "redis";
|
|
1219
|
-
return "unknown";
|
|
1220
|
-
}
|
|
1221
|
-
/**
|
|
1222
|
-
* Find the compose file in the project.
|
|
1223
|
-
* Looks for docker/compose.test.yaml or docker-compose.test.yaml.
|
|
1224
|
-
*/
|
|
1225
|
-
function findComposeFile(projectRoot) {
|
|
1226
|
-
const candidates = [
|
|
1227
|
-
(0, node_path.resolve)(projectRoot, "docker/compose.test.yaml"),
|
|
1228
|
-
(0, node_path.resolve)(projectRoot, "docker/compose.test.yml"),
|
|
1229
|
-
(0, node_path.resolve)(projectRoot, "docker-compose.test.yaml"),
|
|
1230
|
-
(0, node_path.resolve)(projectRoot, "docker-compose.test.yml")
|
|
1231
|
-
];
|
|
1232
|
-
for (const candidate of candidates) if ((0, node_fs.existsSync)(candidate)) return candidate;
|
|
1233
|
-
return null;
|
|
1234
|
-
}
|
|
1235
|
-
/**
|
|
1236
|
-
* Parse a docker-compose file and extract service definitions.
|
|
1237
|
-
*/
|
|
1238
|
-
function parseComposeFile(filePath) {
|
|
1239
|
-
const doc = (0, yaml.parse)((0, node_fs.readFileSync)(filePath, "utf8"));
|
|
1240
|
-
if (!doc?.services) return {
|
|
1241
|
-
services: [],
|
|
1242
|
-
appService: null,
|
|
1243
|
-
infraServices: []
|
|
1244
|
-
};
|
|
1245
|
-
const services = Object.entries(doc.services).map(([name, def]) => {
|
|
1246
|
-
const ports = [];
|
|
1247
|
-
if (def.ports) for (const port of def.ports) {
|
|
1248
|
-
const str = String(port);
|
|
1249
|
-
if (str.includes(":")) {
|
|
1250
|
-
const [host, container] = str.split(":");
|
|
1251
|
-
ports.push({
|
|
1252
|
-
container: Number(container),
|
|
1253
|
-
host: Number(host)
|
|
1254
|
-
});
|
|
1255
|
-
} else ports.push({ container: Number(str) });
|
|
1256
|
-
}
|
|
1257
|
-
const environment = {};
|
|
1258
|
-
if (def.environment) if (Array.isArray(def.environment)) for (const env of def.environment) {
|
|
1259
|
-
const [key, ...rest] = String(env).split("=");
|
|
1260
|
-
environment[key] = rest.join("=");
|
|
1261
|
-
}
|
|
1262
|
-
else Object.assign(environment, def.environment);
|
|
1263
|
-
const volumes = def.volumes ? def.volumes.map((v) => String(v)) : [];
|
|
1264
|
-
let dependsOn = [];
|
|
1265
|
-
if (def.depends_on) dependsOn = Array.isArray(def.depends_on) ? def.depends_on : Object.keys(def.depends_on);
|
|
1266
|
-
return {
|
|
1267
|
-
name,
|
|
1268
|
-
image: def.image,
|
|
1269
|
-
build: def.build,
|
|
1270
|
-
ports,
|
|
1271
|
-
environment,
|
|
1272
|
-
volumes,
|
|
1273
|
-
dependsOn
|
|
1274
|
-
};
|
|
1275
|
-
});
|
|
1276
|
-
return {
|
|
1277
|
-
services,
|
|
1278
|
-
appService: services.find((s) => s.build !== void 0) ?? null,
|
|
1279
|
-
infraServices: services.filter((s) => s.build === void 0)
|
|
1280
|
-
};
|
|
1281
|
-
}
|
|
1282
|
-
//#endregion
|
|
1283
|
-
//#region src/infra/orchestrator.ts
|
|
1284
|
-
/**
|
|
1285
|
-
* Orchestrator for test infrastructure.
|
|
1286
|
-
* Integration: starts services via testcontainers.
|
|
1287
|
-
* E2E: runs full docker compose up.
|
|
1288
|
-
*/
|
|
1289
|
-
var Orchestrator = class {
|
|
1290
|
-
services;
|
|
1291
|
-
mode;
|
|
1292
|
-
root;
|
|
1293
|
-
projectName;
|
|
1294
|
-
running = [];
|
|
1295
|
-
composeStack = null;
|
|
1296
|
-
composeHandles = [];
|
|
1297
|
-
started = false;
|
|
1298
|
-
constructor(options) {
|
|
1299
|
-
this.services = options.services;
|
|
1300
|
-
this.mode = options.mode;
|
|
1301
|
-
this.root = options.root ?? process.cwd();
|
|
1302
|
-
this.projectName = options.projectName;
|
|
1303
|
-
}
|
|
1304
|
-
/**
|
|
1305
|
-
* Start declared services via testcontainers (integration mode).
|
|
1306
|
-
* Phase 1: start all containers in parallel (the slow part).
|
|
1307
|
-
* Phase 2: wire connections, healthcheck, and init sequentially (fast).
|
|
1308
|
-
*/
|
|
1309
|
-
async start() {
|
|
1310
|
-
if (this.started) return;
|
|
1311
|
-
const composePath = findComposeFile(this.root);
|
|
1312
|
-
const composeDir = composePath ? (0, node_path.dirname)(composePath) : this.root;
|
|
1313
|
-
const composeConfig = composePath ? parseComposeFile(composePath) : null;
|
|
1314
|
-
const containerServices = [];
|
|
1315
|
-
const embeddedServices = [];
|
|
1316
|
-
for (const handle of this.services) {
|
|
1317
|
-
if (handle.defaultPort === 0) {
|
|
1318
|
-
embeddedServices.push(handle);
|
|
1319
|
-
continue;
|
|
1320
|
-
}
|
|
1321
|
-
let image = handle.defaultImage;
|
|
1322
|
-
let env = { ...handle.environment };
|
|
1323
|
-
if (handle.composeName && composeConfig) {
|
|
1324
|
-
const composeService = composeConfig.services.find((s) => s.name === handle.composeName);
|
|
1325
|
-
if (composeService) {
|
|
1326
|
-
image = composeService.image ?? image;
|
|
1327
|
-
env = {
|
|
1328
|
-
...env,
|
|
1329
|
-
...composeService.environment
|
|
1330
|
-
};
|
|
1331
|
-
Object.assign(handle.environment, composeService.environment);
|
|
1332
|
-
}
|
|
1333
|
-
}
|
|
1334
|
-
const container = new TestcontainersAdapter({
|
|
1335
|
-
image,
|
|
1336
|
-
port: handle.defaultPort,
|
|
1337
|
-
env
|
|
1338
|
-
});
|
|
1339
|
-
containerServices.push({
|
|
1340
|
-
container,
|
|
1341
|
-
handle
|
|
1342
|
-
});
|
|
1343
|
-
}
|
|
1344
|
-
await Promise.all([...containerServices.map(({ container }) => container.start()), ...embeddedServices.map(async (handle) => {
|
|
1345
|
-
await handle.initialize(composeDir);
|
|
1346
|
-
handle.started = true;
|
|
1347
|
-
this.running.push({
|
|
1348
|
-
handle,
|
|
1349
|
-
container: null
|
|
1350
|
-
});
|
|
1351
|
-
})]);
|
|
1352
|
-
const reports = [];
|
|
1353
|
-
for (const { container, handle } of containerServices) {
|
|
1354
|
-
const serviceStartTime = Date.now();
|
|
1355
|
-
try {
|
|
1356
|
-
const host = container.getHost();
|
|
1357
|
-
const port = container.getMappedPort(handle.defaultPort);
|
|
1358
|
-
handle.connectionString = handle.buildConnectionString(host, port);
|
|
1359
|
-
await handle.healthcheck();
|
|
1360
|
-
await handle.initialize(composeDir);
|
|
1361
|
-
handle.started = true;
|
|
1362
|
-
reports.push({
|
|
1363
|
-
name: handle.composeName ?? handle.type,
|
|
1364
|
-
type: handle.type,
|
|
1365
|
-
connectionString: handle.connectionString,
|
|
1366
|
-
durationMs: Date.now() - serviceStartTime
|
|
1367
|
-
});
|
|
1368
|
-
this.running.push({
|
|
1369
|
-
handle,
|
|
1370
|
-
container
|
|
1371
|
-
});
|
|
1372
|
-
} catch (error) {
|
|
1373
|
-
let logs = "";
|
|
1374
|
-
try {
|
|
1375
|
-
logs = await container.getLogs();
|
|
1376
|
-
} catch {}
|
|
1377
|
-
try {
|
|
1378
|
-
await container.stop();
|
|
1379
|
-
} catch {}
|
|
1380
|
-
reports.push({
|
|
1381
|
-
name: handle.composeName ?? handle.type,
|
|
1382
|
-
type: handle.type,
|
|
1383
|
-
durationMs: Date.now() - serviceStartTime,
|
|
1384
|
-
error: error.message,
|
|
1385
|
-
logs
|
|
1386
|
-
});
|
|
1387
|
-
const output = formatStartupReport("integration", reports, { type: "in-process" });
|
|
1388
|
-
console.error(output);
|
|
1389
|
-
throw error;
|
|
1390
|
-
}
|
|
1391
|
-
}
|
|
1392
|
-
this.started = true;
|
|
1393
|
-
const output = formatStartupReport("integration", reports, { type: "in-process" });
|
|
1394
|
-
console.log(output);
|
|
1395
|
-
}
|
|
1396
|
-
/**
|
|
1397
|
-
* Stop testcontainers (integration mode).
|
|
1398
|
-
*/
|
|
1399
|
-
async stop() {
|
|
1400
|
-
for (const { container } of this.running) if (container) await container.stop();
|
|
1401
|
-
this.running = [];
|
|
1402
|
-
this.started = false;
|
|
1403
|
-
}
|
|
1404
|
-
/**
|
|
1405
|
-
* Start full docker compose stack (e2e mode).
|
|
1406
|
-
* Auto-detects infra services and creates handles for them.
|
|
1407
|
-
*/
|
|
1408
|
-
async startCompose() {
|
|
1409
|
-
const composePath = findComposeFile(this.root);
|
|
1410
|
-
if (!composePath) throw new Error(`E2E: no compose file found in ${this.root}`);
|
|
1411
|
-
const startTime = Date.now();
|
|
1412
|
-
const composeDir = (0, node_path.dirname)(composePath);
|
|
1413
|
-
const composeConfig = parseComposeFile(composePath);
|
|
1414
|
-
this.composeStack = new ComposeStackAdapter(composePath, this.projectName);
|
|
1415
|
-
await this.composeStack.start();
|
|
1416
|
-
for (const service of composeConfig.infraServices) {
|
|
1417
|
-
const type = detectServiceType(service.image);
|
|
1418
|
-
if (type === "postgres") {
|
|
1419
|
-
const handle = require_sqlite_adapter.postgres({
|
|
1420
|
-
compose: service.name,
|
|
1421
|
-
env: service.environment
|
|
1422
|
-
});
|
|
1423
|
-
const port = this.composeStack.getMappedPort(service.name, 5432);
|
|
1424
|
-
handle.connectionString = handle.buildConnectionString("localhost", port);
|
|
1425
|
-
await handle.initialize(composeDir);
|
|
1426
|
-
handle.started = true;
|
|
1427
|
-
this.composeHandles.push(handle);
|
|
1428
|
-
} else if (type === "redis") {
|
|
1429
|
-
const handle = require_sqlite_adapter.redis({ compose: service.name });
|
|
1430
|
-
const port = this.composeStack.getMappedPort(service.name, 6379);
|
|
1431
|
-
handle.connectionString = handle.buildConnectionString("localhost", port);
|
|
1432
|
-
handle.started = true;
|
|
1433
|
-
this.composeHandles.push(handle);
|
|
1434
|
-
}
|
|
1435
|
-
}
|
|
1436
|
-
const durationMs = Date.now() - startTime;
|
|
1437
|
-
const output = formatStartupReport("e2e", this.composeHandles.map((h) => ({
|
|
1438
|
-
name: h.composeName ?? h.type,
|
|
1439
|
-
type: h.type,
|
|
1440
|
-
connectionString: h.connectionString,
|
|
1441
|
-
durationMs
|
|
1442
|
-
})), {
|
|
1443
|
-
type: "http",
|
|
1444
|
-
url: this.getAppUrl() ?? void 0
|
|
1445
|
-
});
|
|
1446
|
-
console.log(output);
|
|
1447
|
-
}
|
|
1448
|
-
/**
|
|
1449
|
-
* Stop docker compose stack (e2e mode).
|
|
1450
|
-
*/
|
|
1451
|
-
async stopCompose() {
|
|
1452
|
-
if (this.composeStack) {
|
|
1453
|
-
await this.composeStack.stop();
|
|
1454
|
-
this.composeStack = null;
|
|
1455
|
-
}
|
|
1456
|
-
this.composeHandles = [];
|
|
1457
|
-
}
|
|
1458
|
-
/**
|
|
1459
|
-
* Get a database service by compose name, or the first one if no name given.
|
|
1460
|
-
*/
|
|
1461
|
-
getDatabase(serviceName) {
|
|
1462
|
-
for (const handle of [...this.services, ...this.composeHandles]) {
|
|
1463
|
-
if (serviceName && handle.composeName !== serviceName) continue;
|
|
1464
|
-
const adapter = handle.createDatabaseAdapter();
|
|
1465
|
-
if (adapter) return adapter;
|
|
1466
|
-
}
|
|
1467
|
-
return null;
|
|
1468
|
-
}
|
|
1469
|
-
/**
|
|
1470
|
-
* Get all database services keyed by compose name.
|
|
1471
|
-
*/
|
|
1472
|
-
getDatabases() {
|
|
1473
|
-
const map = /* @__PURE__ */ new Map();
|
|
1474
|
-
for (const handle of [...this.services, ...this.composeHandles]) {
|
|
1475
|
-
const adapter = handle.createDatabaseAdapter();
|
|
1476
|
-
if (adapter && handle.composeName) map.set(handle.composeName, adapter);
|
|
1477
|
-
}
|
|
1478
|
-
return map;
|
|
1479
|
-
}
|
|
1480
|
-
/**
|
|
1481
|
-
* Get app URL from compose (e2e mode).
|
|
1482
|
-
*/
|
|
1483
|
-
getAppUrl() {
|
|
1484
|
-
const composePath = findComposeFile(this.root);
|
|
1485
|
-
if (!composePath || !this.composeStack) return null;
|
|
1486
|
-
const appService = parseComposeFile(composePath).appService;
|
|
1487
|
-
if (!appService || appService.ports.length === 0) return null;
|
|
1488
|
-
return `http://localhost:${this.composeStack.getMappedPort(appService.name, appService.ports[0].container)}`;
|
|
1489
|
-
}
|
|
1490
|
-
};
|
|
1491
|
-
//#endregion
|
|
1492
|
-
//#region src/spec/resolve.ts
|
|
1493
|
-
/**
|
|
1494
|
-
* Resolve root — if relative, resolves from the caller's directory.
|
|
1495
|
-
*/
|
|
1496
|
-
function resolveProjectRoot(root) {
|
|
1497
|
-
if (!root) return process.cwd();
|
|
1498
|
-
if ((0, node_path.isAbsolute)(root)) return root;
|
|
1499
|
-
const stack = (/* @__PURE__ */ new Error("resolve root")).stack;
|
|
1500
|
-
if (stack) {
|
|
1501
|
-
const lines = stack.split("\n");
|
|
1502
|
-
for (const line of lines) {
|
|
1503
|
-
const match = line.match(/at\s+(?:.*?\()?(?:file:\/\/)?([^:)]+):\d+:\d+/);
|
|
1504
|
-
if (!match) continue;
|
|
1505
|
-
const filePath = match[1];
|
|
1506
|
-
if (filePath.includes("node_modules") || filePath.includes("/src/spec/")) continue;
|
|
1507
|
-
return (0, node_path.resolve)(filePath, "..", root);
|
|
1508
|
-
}
|
|
1509
|
-
}
|
|
1510
|
-
return (0, node_path.resolve)(process.cwd(), root);
|
|
1511
|
-
}
|
|
1512
|
-
/**
|
|
1513
|
-
* Resolve a CLI command — checks node_modules/.bin, then treats as absolute/PATH.
|
|
1514
|
-
*/
|
|
1515
|
-
function resolveCommand(command, root) {
|
|
1516
|
-
if ((0, node_path.isAbsolute)(command)) return command;
|
|
1517
|
-
const binPath = (0, node_path.resolve)(root, "node_modules/.bin", command);
|
|
1518
|
-
if ((0, node_fs.existsSync)(binPath)) return binPath;
|
|
1519
|
-
const cwdBinPath = (0, node_path.resolve)(process.cwd(), "node_modules/.bin", command);
|
|
1520
|
-
if ((0, node_fs.existsSync)(cwdBinPath)) return cwdBinPath;
|
|
1521
|
-
return command;
|
|
1522
|
-
}
|
|
1523
|
-
//#endregion
|
|
1524
|
-
//#region src/spec/spec.ts
|
|
1525
|
-
/**
|
|
1526
|
-
* Create a specification runner for the given target.
|
|
1527
|
-
*
|
|
1528
|
-
* @param target - What to test against: {@link app}, {@link stack}, or {@link command}.
|
|
1529
|
-
* @param options - Shared options: root directory, infrastructure services.
|
|
1530
|
-
*
|
|
1531
|
-
* @example
|
|
1532
|
-
* // HTTP — in-process app with testcontainers
|
|
1533
|
-
* const s = await spec(app(() => createApp(db)), { services: [db] });
|
|
1534
|
-
*
|
|
1535
|
-
* // HTTP — full docker compose stack
|
|
1536
|
-
* const s = await spec(stack('../../'));
|
|
1537
|
-
*
|
|
1538
|
-
* // CLI — command binary
|
|
1539
|
-
* const s = await spec(command('my-cli'), { root: '../fixtures' });
|
|
1540
|
-
*/
|
|
1541
|
-
async function spec(target, options = {}) {
|
|
1542
|
-
switch (target.kind) {
|
|
1543
|
-
case "app": return startApp(target, options);
|
|
1544
|
-
case "stack": return startStack(target, options);
|
|
1545
|
-
case "command": return startCommand(target, options);
|
|
1546
|
-
}
|
|
1547
|
-
}
|
|
1548
|
-
function getWorkerId() {
|
|
1549
|
-
return process.env.VITEST_POOL_ID ?? "0";
|
|
1550
|
-
}
|
|
1551
|
-
async function acquireIsolation(services) {
|
|
1552
|
-
const workerId = getWorkerId();
|
|
1553
|
-
for (const service of services) await service.isolation().acquire(workerId);
|
|
1554
|
-
}
|
|
1555
|
-
async function releaseIsolation(services) {
|
|
1556
|
-
for (const service of services) await service.isolation().release();
|
|
1557
|
-
}
|
|
1558
|
-
async function startApp(target, options) {
|
|
1559
|
-
const services = options.services ?? [];
|
|
1560
|
-
const orchestrator = new Orchestrator({
|
|
1561
|
-
mode: "integration",
|
|
1562
|
-
root: resolveProjectRoot(options.root),
|
|
1563
|
-
services
|
|
1564
|
-
});
|
|
1565
|
-
await orchestrator.start();
|
|
1566
|
-
await acquireIsolation(services);
|
|
1567
|
-
const servicesMap = {};
|
|
1568
|
-
for (const svc of services) {
|
|
1569
|
-
const key = svc.composeName ?? svc.type;
|
|
1570
|
-
servicesMap[key] = svc;
|
|
1571
|
-
}
|
|
1572
|
-
const factoryResult = target.factory(servicesMap);
|
|
1573
|
-
const isComposite = Array.isArray(factoryResult.jobs);
|
|
1574
|
-
const honoApp = isComposite ? factoryResult.server : factoryResult;
|
|
1575
|
-
const jobs = isComposite ? factoryResult.jobs : void 0;
|
|
1576
|
-
const database = orchestrator.getDatabase() ?? void 0;
|
|
1577
|
-
const databases = orchestrator.getDatabases();
|
|
1578
|
-
const runner = createSpecificationRunner({
|
|
1579
|
-
database,
|
|
1580
|
-
databases: databases.size > 0 ? databases : void 0,
|
|
1581
|
-
jobs,
|
|
1582
|
-
server: new HonoAdapter(honoApp)
|
|
1583
|
-
});
|
|
1584
|
-
runner.cleanup = async () => {
|
|
1585
|
-
await releaseIsolation(services);
|
|
1586
|
-
await orchestrator.stop();
|
|
1587
|
-
};
|
|
1588
|
-
runner.docker = (id) => new DockerAssertion(dockerContainer(id));
|
|
1589
|
-
runner.orchestrator = orchestrator;
|
|
1590
|
-
return runner;
|
|
1591
|
-
}
|
|
1592
|
-
async function startStack(target, options) {
|
|
1593
|
-
const root = resolveProjectRoot(target.root ?? options.root);
|
|
1594
|
-
const projectName = `test-worker-${getWorkerId()}`;
|
|
1595
|
-
const orchestrator = new Orchestrator({
|
|
1596
|
-
mode: "e2e",
|
|
1597
|
-
root,
|
|
1598
|
-
services: options.services ?? [],
|
|
1599
|
-
projectName
|
|
1600
|
-
});
|
|
1601
|
-
await orchestrator.startCompose();
|
|
1602
|
-
const appUrl = orchestrator.getAppUrl();
|
|
1603
|
-
if (!appUrl) throw new Error("stack(): could not detect app URL from compose. Ensure an app service with ports is defined.");
|
|
1604
|
-
const database = orchestrator.getDatabase() ?? void 0;
|
|
1605
|
-
const databases = orchestrator.getDatabases();
|
|
1606
|
-
const runner = createSpecificationRunner({
|
|
1607
|
-
database,
|
|
1608
|
-
databases: databases.size > 0 ? databases : void 0,
|
|
1609
|
-
server: new FetchAdapter(appUrl)
|
|
1610
|
-
});
|
|
1611
|
-
runner.cleanup = () => orchestrator.stopCompose();
|
|
1612
|
-
runner.docker = (id) => new DockerAssertion(dockerContainer(id));
|
|
1613
|
-
runner.orchestrator = orchestrator;
|
|
1614
|
-
return runner;
|
|
1615
|
-
}
|
|
1616
|
-
async function startCommand(target, options) {
|
|
1617
|
-
const root = resolveProjectRoot(options.root);
|
|
1618
|
-
const bin = resolveCommand(target.bin, root);
|
|
1619
|
-
const services = options.services ?? [];
|
|
1620
|
-
let orchestrator = null;
|
|
1621
|
-
let database;
|
|
1622
|
-
let databases;
|
|
1623
|
-
if (services.length) {
|
|
1624
|
-
orchestrator = new Orchestrator({
|
|
1625
|
-
mode: "integration",
|
|
1626
|
-
root,
|
|
1627
|
-
services
|
|
1628
|
-
});
|
|
1629
|
-
await orchestrator.start();
|
|
1630
|
-
await acquireIsolation(services);
|
|
1631
|
-
database = orchestrator.getDatabase() ?? void 0;
|
|
1632
|
-
const dbMap = orchestrator.getDatabases();
|
|
1633
|
-
databases = dbMap.size > 0 ? dbMap : void 0;
|
|
1634
|
-
}
|
|
1635
|
-
const runner = createSpecificationRunner({
|
|
1636
|
-
command: new ExecAdapter(bin),
|
|
1637
|
-
database,
|
|
1638
|
-
databases,
|
|
1639
|
-
fixturesRoot: root
|
|
1640
|
-
});
|
|
1641
|
-
runner.cleanup = async () => {
|
|
1642
|
-
await releaseIsolation(services);
|
|
1643
|
-
if (orchestrator) await orchestrator.stop();
|
|
1644
|
-
};
|
|
1645
|
-
runner.docker = (id) => new DockerAssertion(dockerContainer(id));
|
|
1646
|
-
runner.orchestrator = orchestrator;
|
|
1647
|
-
return runner;
|
|
1648
|
-
}
|
|
1649
|
-
//#endregion
|
|
1650
|
-
//#region src/spec/targets.ts
|
|
1651
|
-
/**
|
|
1652
|
-
* Test against an in-process Hono app. The factory receives started services
|
|
1653
|
-
* so you can wire connection strings into your app/DI container.
|
|
1654
|
-
*
|
|
1655
|
-
* @param factory - Function that receives services and returns a Hono app instance.
|
|
1656
|
-
*
|
|
1657
|
-
* @example
|
|
1658
|
-
* const db = postgres({ compose: 'db' });
|
|
1659
|
-
* await spec(
|
|
1660
|
-
* app((services) => createApp({ databaseUrl: services.db.connectionString })),
|
|
1661
|
-
* { services: [db] },
|
|
1662
|
-
* );
|
|
1663
|
-
*/
|
|
1664
|
-
function app(factory) {
|
|
1665
|
-
return {
|
|
1666
|
-
kind: "app",
|
|
1667
|
-
factory
|
|
1668
|
-
};
|
|
1669
|
-
}
|
|
1670
|
-
/**
|
|
1671
|
-
* Test against a full docker compose stack. The stack is started with
|
|
1672
|
-
* `docker compose up` and real HTTP requests are sent to the app service.
|
|
1673
|
-
*
|
|
1674
|
-
* @param root - Project root containing `docker/compose.test.yaml`.
|
|
1675
|
-
*
|
|
1676
|
-
* @example
|
|
1677
|
-
* await spec(stack('../../'));
|
|
1678
|
-
*/
|
|
1679
|
-
function stack(root) {
|
|
1680
|
-
return {
|
|
1681
|
-
kind: "stack",
|
|
1682
|
-
root
|
|
1683
|
-
};
|
|
1684
|
-
}
|
|
1685
|
-
/**
|
|
1686
|
-
* Test a CLI binary. Each spec runs in a fresh temp directory.
|
|
1687
|
-
*
|
|
1688
|
-
* @param bin - Path to the CLI binary or command name (resolved from node_modules/.bin or PATH).
|
|
1689
|
-
*
|
|
1690
|
-
* @example
|
|
1691
|
-
* await spec(command('my-cli'), { root: '../fixtures' });
|
|
1692
|
-
*/
|
|
1693
|
-
function command(bin) {
|
|
1694
|
-
return {
|
|
1695
|
-
kind: "command",
|
|
1696
|
-
bin
|
|
1697
|
-
};
|
|
1698
|
-
}
|
|
1699
|
-
//#endregion
|
|
1700
|
-
//#region src/spec/legacy-cli.ts
|
|
1701
|
-
/**
|
|
1702
|
-
* Create a CLI specification runner.
|
|
1703
|
-
* Runs CLI commands against fixture projects. Optionally starts infrastructure.
|
|
1704
|
-
*/
|
|
1705
|
-
async function cli(options) {
|
|
1706
|
-
const root = resolveProjectRoot(options.root);
|
|
1707
|
-
const command = resolveCommand(options.command, root);
|
|
1708
|
-
let orchestrator = null;
|
|
1709
|
-
let database;
|
|
1710
|
-
let databases;
|
|
1711
|
-
if (options.services?.length) {
|
|
1712
|
-
orchestrator = new Orchestrator({
|
|
1713
|
-
mode: "integration",
|
|
1714
|
-
root,
|
|
1715
|
-
services: options.services
|
|
1716
|
-
});
|
|
1717
|
-
await orchestrator.start();
|
|
1718
|
-
database = orchestrator.getDatabase() ?? void 0;
|
|
1719
|
-
const dbMap = orchestrator.getDatabases();
|
|
1720
|
-
databases = dbMap.size > 0 ? dbMap : void 0;
|
|
1721
|
-
}
|
|
1722
|
-
const runner = createSpecificationRunner({
|
|
1723
|
-
command: new ExecAdapter(command),
|
|
1724
|
-
database,
|
|
1725
|
-
databases,
|
|
1726
|
-
fixturesRoot: root
|
|
1727
|
-
});
|
|
1728
|
-
runner.cleanup = async () => {
|
|
1729
|
-
if (orchestrator) await orchestrator.stop();
|
|
1730
|
-
};
|
|
1731
|
-
runner.orchestrator = orchestrator;
|
|
1732
|
-
return runner;
|
|
1733
|
-
}
|
|
1734
|
-
//#endregion
|
|
1735
|
-
//#region src/spec/legacy-e2e.ts
|
|
1736
|
-
/**
|
|
1737
|
-
* Create an E2E specification runner.
|
|
1738
|
-
* Starts full docker compose stack. App URL and database auto-detected.
|
|
1739
|
-
*/
|
|
1740
|
-
async function e2e(options = {}) {
|
|
1741
|
-
const orchestrator = new Orchestrator({
|
|
1742
|
-
mode: "e2e",
|
|
1743
|
-
root: resolveProjectRoot(options.root),
|
|
1744
|
-
services: []
|
|
1745
|
-
});
|
|
1746
|
-
await orchestrator.startCompose();
|
|
1747
|
-
const appUrl = orchestrator.getAppUrl();
|
|
1748
|
-
if (!appUrl) throw new Error("E2E: could not detect app URL from compose. Ensure an app service with ports is defined.");
|
|
1749
|
-
const database = orchestrator.getDatabase() ?? void 0;
|
|
1750
|
-
const databases = orchestrator.getDatabases();
|
|
1751
|
-
const runner = createSpecificationRunner({
|
|
1752
|
-
database,
|
|
1753
|
-
databases: databases.size > 0 ? databases : void 0,
|
|
1754
|
-
server: new FetchAdapter(appUrl)
|
|
1755
|
-
});
|
|
1756
|
-
runner.cleanup = () => orchestrator.stopCompose();
|
|
1757
|
-
runner.orchestrator = orchestrator;
|
|
1758
|
-
return runner;
|
|
1759
|
-
}
|
|
1760
|
-
//#endregion
|
|
1761
|
-
//#region src/spec/legacy-integration.ts
|
|
1762
|
-
/**
|
|
1763
|
-
* Create an integration specification runner.
|
|
1764
|
-
* Starts infra containers via testcontainers, app runs in-process.
|
|
1765
|
-
*/
|
|
1766
|
-
async function integration(options) {
|
|
1767
|
-
const orchestrator = new Orchestrator({
|
|
1768
|
-
mode: "integration",
|
|
1769
|
-
root: resolveProjectRoot(options.root),
|
|
1770
|
-
services: options.services
|
|
1771
|
-
});
|
|
1772
|
-
await orchestrator.start();
|
|
1773
|
-
const app = options.app();
|
|
1774
|
-
const database = orchestrator.getDatabase() ?? void 0;
|
|
1775
|
-
const databases = orchestrator.getDatabases();
|
|
1776
|
-
const runner = createSpecificationRunner({
|
|
1777
|
-
database,
|
|
1778
|
-
databases: databases.size > 0 ? databases : void 0,
|
|
1779
|
-
server: new HonoAdapter(app)
|
|
1780
|
-
});
|
|
1781
|
-
runner.cleanup = () => orchestrator.stop();
|
|
1782
|
-
runner.orchestrator = orchestrator;
|
|
1783
|
-
return runner;
|
|
1784
|
-
}
|
|
1785
|
-
//#endregion
|
|
1786
|
-
exports.DirectoryAccessor = DirectoryAccessor;
|
|
1787
|
-
exports.DockerAssertion = DockerAssertion;
|
|
1788
|
-
exports.ExecAdapter = ExecAdapter;
|
|
1789
|
-
exports.FetchAdapter = FetchAdapter;
|
|
1790
|
-
exports.HonoAdapter = HonoAdapter;
|
|
1791
|
-
exports.Orchestrator = Orchestrator;
|
|
1792
|
-
exports.ResponseAccessor = ResponseAccessor;
|
|
1793
|
-
exports.SpecificationBuilder = SpecificationBuilder;
|
|
1794
|
-
exports.SpecificationResult = SpecificationResult;
|
|
1795
|
-
exports.TableAssertion = TableAssertion;
|
|
1796
|
-
exports.app = app;
|
|
1797
|
-
exports.cli = cli;
|
|
1798
|
-
exports.command = command;
|
|
1799
|
-
exports.createSpecificationRunner = createSpecificationRunner;
|
|
1800
|
-
exports.dockerContainer = dockerContainer;
|
|
1801
|
-
exports.e2e = e2e;
|
|
1802
|
-
exports.grep = grep;
|
|
1803
|
-
exports.integration = integration;
|
|
1804
|
-
exports.mockOf = require_mock_of.mockOf;
|
|
1805
|
-
exports.mockOfDate = require_mock_of.mockOfDate;
|
|
1806
|
-
exports.normalizeOutput = normalizeOutput;
|
|
1807
|
-
exports.postgres = require_sqlite_adapter.postgres;
|
|
1808
|
-
exports.redis = require_sqlite_adapter.redis;
|
|
1809
|
-
exports.spec = spec;
|
|
1810
|
-
exports.sqlite = require_sqlite_adapter.sqlite;
|
|
1811
|
-
exports.stack = stack;
|
|
1812
|
-
exports.stripAnsi = stripAnsi;
|
|
1813
|
-
|
|
1814
|
-
//# sourceMappingURL=index.cjs.map
|