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