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