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