@jterrazz/test 7.1.0 → 9.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +209 -230
- package/dist/checker.d.ts +1 -0
- package/dist/checker.js +741 -0
- package/dist/index.d.ts +1296 -529
- package/dist/index.js +3303 -1261
- package/dist/intercept.js +129 -290
- package/dist/match.js +153 -0
- package/dist/oxlint.cjs +2931 -0
- package/dist/oxlint.d.cts +150 -0
- package/dist/oxlint.d.ts +150 -0
- package/dist/oxlint.js +2925 -0
- package/package.json +47 -41
- package/dist/chunk.cjs +0 -28
- package/dist/index.cjs +0 -1814
- package/dist/index.cjs.map +0 -1
- package/dist/index.d.cts +0 -734
- package/dist/index.js.map +0 -1
- package/dist/intercept.cjs +0 -313
- package/dist/intercept.cjs.map +0 -1
- package/dist/intercept.d.cts +0 -105
- package/dist/intercept.d.ts +0 -105
- package/dist/intercept.js.map +0 -1
- package/dist/intercept2.cjs +0 -81
- package/dist/intercept2.cjs.map +0 -1
- package/dist/intercept2.js +0 -81
- package/dist/intercept2.js.map +0 -1
- package/dist/mock-of.cjs +0 -32
- package/dist/mock-of.cjs.map +0 -1
- package/dist/mock-of.d.cts +0 -27
- package/dist/mock-of.d.ts +0 -27
- package/dist/mock-of.js +0 -19
- package/dist/mock-of.js.map +0 -1
- package/dist/mock.cjs +0 -4
- package/dist/mock.d.cts +0 -2
- package/dist/mock.d.ts +0 -2
- package/dist/mock.js +0 -2
- package/dist/services.cjs +0 -5
- package/dist/services.d.cts +0 -2
- package/dist/services.d.ts +0 -2
- package/dist/services.js +0 -2
- package/dist/sqlite.adapter.cjs +0 -350
- package/dist/sqlite.adapter.cjs.map +0 -1
- package/dist/sqlite.adapter.d.cts +0 -209
- package/dist/sqlite.adapter.d.ts +0 -209
- package/dist/sqlite.adapter.js +0 -331
- package/dist/sqlite.adapter.js.map +0 -1
- package/dist/types.d.cts +0 -42
- package/dist/types.d.ts +0 -42
package/dist/index.js
CHANGED
|
@@ -1,142 +1,393 @@
|
|
|
1
|
-
import { n as
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
5
|
-
import { tmpdir } from "node:os";
|
|
6
|
-
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
7
|
-
import { readdir } from "node:fs/promises";
|
|
1
|
+
import { i as match, n as Matcher, r as TOKEN_KINDS, t as CaptureScope } from "./match.js";
|
|
2
|
+
import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
8
4
|
import { parse } from "yaml";
|
|
9
|
-
|
|
5
|
+
import { execSync, spawn, spawnSync } from "node:child_process";
|
|
6
|
+
import { Client } from "pg";
|
|
7
|
+
import { readdir } from "node:fs/promises";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import Database from "better-sqlite3";
|
|
11
|
+
import { mockDeep } from "vitest-mock-extended";
|
|
12
|
+
import MockDatePackage from "mockdate";
|
|
13
|
+
//#region src/core/specification/shared/registry.ts
|
|
14
|
+
let containerIntegrations = null;
|
|
15
|
+
const composeServiceFactories = /* @__PURE__ */ new Map();
|
|
16
|
+
/** Wire the container runtimes. Called by the package entry point. */
|
|
17
|
+
function registerContainerIntegrations(integrations) {
|
|
18
|
+
containerIntegrations = integrations;
|
|
19
|
+
}
|
|
20
|
+
/** Wire a service auto-detection factory (e.g. `'postgres'`, `'redis'`). */
|
|
21
|
+
function registerComposeServiceFactory(type, factory) {
|
|
22
|
+
composeServiceFactories.set(type, factory);
|
|
23
|
+
}
|
|
24
|
+
function getContainerIntegrations() {
|
|
25
|
+
if (!containerIntegrations) throw new Error("@jterrazz/test: container integrations are not registered — import from '@jterrazz/test' (the package entry point wires them).");
|
|
26
|
+
return containerIntegrations;
|
|
27
|
+
}
|
|
28
|
+
function getComposeServiceFactory(type) {
|
|
29
|
+
return composeServiceFactories.get(type);
|
|
30
|
+
}
|
|
31
|
+
//#endregion
|
|
32
|
+
//#region src/integrations/compose/compose-parser.ts
|
|
10
33
|
/**
|
|
11
|
-
*
|
|
12
|
-
* `null` overrides delete keys (e.g. `INIT_CWD: null`).
|
|
34
|
+
* Parse a docker-compose file and extract service definitions.
|
|
13
35
|
*/
|
|
14
|
-
function
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
36
|
+
function parseComposeFile(filePath) {
|
|
37
|
+
const doc = parse(readFileSync(filePath, "utf8"));
|
|
38
|
+
if (!doc?.services) return {
|
|
39
|
+
services: [],
|
|
40
|
+
appService: null,
|
|
41
|
+
infraServices: []
|
|
42
|
+
};
|
|
43
|
+
const services = Object.entries(doc.services).map(([name, def]) => {
|
|
44
|
+
const ports = [];
|
|
45
|
+
if (def.ports) for (const port of def.ports) {
|
|
46
|
+
const str = String(port);
|
|
47
|
+
if (str.includes(":")) {
|
|
48
|
+
const [host, container] = str.split(":");
|
|
49
|
+
ports.push({
|
|
50
|
+
container: Number(container),
|
|
51
|
+
host: Number(host)
|
|
52
|
+
});
|
|
53
|
+
} else ports.push({ container: Number(str) });
|
|
54
|
+
}
|
|
55
|
+
const environment = {};
|
|
56
|
+
if (def.environment) if (Array.isArray(def.environment)) for (const env of def.environment) {
|
|
57
|
+
const [key, ...rest] = String(env).split("=");
|
|
58
|
+
environment[key] = rest.join("=");
|
|
59
|
+
}
|
|
60
|
+
else Object.assign(environment, def.environment);
|
|
61
|
+
const volumes = def.volumes ? def.volumes.map((v) => String(v)) : [];
|
|
62
|
+
let dependsOn = [];
|
|
63
|
+
if (def.depends_on) dependsOn = Array.isArray(def.depends_on) ? def.depends_on : Object.keys(def.depends_on);
|
|
64
|
+
return {
|
|
65
|
+
name,
|
|
66
|
+
image: def.image,
|
|
67
|
+
build: def.build,
|
|
68
|
+
ports,
|
|
69
|
+
environment,
|
|
70
|
+
volumes,
|
|
71
|
+
dependsOn
|
|
72
|
+
};
|
|
73
|
+
});
|
|
74
|
+
return {
|
|
75
|
+
services,
|
|
76
|
+
appService: services.find((s) => s.build !== void 0) ?? null,
|
|
77
|
+
infraServices: services.filter((s) => s.build === void 0)
|
|
18
78
|
};
|
|
19
|
-
if (extra) for (const [key, value] of Object.entries(extra)) if (value === null) delete env[key];
|
|
20
|
-
else env[key] = value;
|
|
21
|
-
return env;
|
|
22
79
|
}
|
|
80
|
+
//#endregion
|
|
81
|
+
//#region src/integrations/compose/compose.ts
|
|
23
82
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* Used by the `cli()` specification runner.
|
|
83
|
+
* Start the full compose stack and stop it all on cleanup.
|
|
84
|
+
* Supports per-worker project names for parallel execution.
|
|
27
85
|
*/
|
|
28
|
-
var
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
86
|
+
var ComposeStackAdapter = class {
|
|
87
|
+
composeFile;
|
|
88
|
+
projectName;
|
|
89
|
+
started = false;
|
|
90
|
+
constructor(composeFile, projectName) {
|
|
91
|
+
this.composeFile = composeFile;
|
|
92
|
+
this.projectName = projectName ?? null;
|
|
32
93
|
}
|
|
33
|
-
|
|
34
|
-
|
|
94
|
+
get projectFlag() {
|
|
95
|
+
return this.projectName ? `-p ${this.projectName}` : "";
|
|
96
|
+
}
|
|
97
|
+
run(command) {
|
|
35
98
|
try {
|
|
36
|
-
return {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
env,
|
|
42
|
-
stdio: [
|
|
43
|
-
"pipe",
|
|
44
|
-
"pipe",
|
|
45
|
-
"pipe"
|
|
46
|
-
]
|
|
47
|
-
}),
|
|
48
|
-
stderr: ""
|
|
49
|
-
};
|
|
99
|
+
return execSync(command, {
|
|
100
|
+
cwd: dirname(this.composeFile),
|
|
101
|
+
encoding: "utf8",
|
|
102
|
+
timeout: 12e4
|
|
103
|
+
}).trim();
|
|
50
104
|
} catch (error) {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
stdout: error.stdout?.toString() ?? "",
|
|
54
|
-
stderr: error.stderr?.toString() ?? ""
|
|
55
|
-
};
|
|
105
|
+
const stderr = error.stderr?.toString().trim() ?? error.message;
|
|
106
|
+
throw new Error(`docker compose failed: ${stderr}`, { cause: error });
|
|
56
107
|
}
|
|
57
108
|
}
|
|
58
|
-
async
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
});
|
|
109
|
+
async start() {
|
|
110
|
+
if (this.started) return;
|
|
111
|
+
this.run(`docker compose ${this.projectFlag} -f ${this.composeFile} up -d --wait --build`);
|
|
112
|
+
this.started = true;
|
|
113
|
+
}
|
|
114
|
+
async stop() {
|
|
115
|
+
if (!this.started) return;
|
|
116
|
+
this.run(`docker compose ${this.projectFlag} -f ${this.composeFile} down -v`);
|
|
117
|
+
this.started = false;
|
|
118
|
+
}
|
|
119
|
+
getMappedPort(serviceName, containerPort) {
|
|
120
|
+
const port = this.run(`docker compose ${this.projectFlag} -f ${this.composeFile} port ${serviceName} ${containerPort}`).split(":").pop();
|
|
121
|
+
return Number(port);
|
|
122
|
+
}
|
|
123
|
+
getHost() {
|
|
124
|
+
return "localhost";
|
|
103
125
|
}
|
|
104
126
|
};
|
|
105
127
|
//#endregion
|
|
106
|
-
//#region src/
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
128
|
+
//#region src/integrations/postgres/postgres.ts
|
|
129
|
+
var PostgresHandle = class {
|
|
130
|
+
type = "postgres";
|
|
131
|
+
composeName;
|
|
132
|
+
defaultPort = 5432;
|
|
133
|
+
defaultImage;
|
|
134
|
+
environment;
|
|
135
|
+
connectionString = "";
|
|
136
|
+
started = false;
|
|
137
|
+
client = null;
|
|
138
|
+
originalConnectionString = "";
|
|
139
|
+
schema = "public";
|
|
140
|
+
constructor(options = {}) {
|
|
141
|
+
this.composeName = options.composeService ?? null;
|
|
142
|
+
this.defaultImage = options.image ?? "postgres:17";
|
|
143
|
+
this.environment = {
|
|
144
|
+
POSTGRES_DB: "test",
|
|
145
|
+
POSTGRES_PASSWORD: "test",
|
|
146
|
+
POSTGRES_USER: "test",
|
|
147
|
+
...options.env
|
|
148
|
+
};
|
|
115
149
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
150
|
+
buildConnectionString(host, port) {
|
|
151
|
+
return `postgresql://${this.environment.POSTGRES_USER ?? "test"}:${this.environment.POSTGRES_PASSWORD ?? "test"}@${host}:${port}/${this.environment.POSTGRES_DB ?? "test"}`;
|
|
152
|
+
}
|
|
153
|
+
createDatabaseAdapter() {
|
|
154
|
+
return this;
|
|
155
|
+
}
|
|
156
|
+
async healthcheck() {
|
|
157
|
+
if (!this.connectionString) throw new Error("postgres: cannot healthcheck — no connection string");
|
|
158
|
+
try {
|
|
159
|
+
const client = new Client({ connectionString: this.connectionString });
|
|
160
|
+
await client.connect();
|
|
161
|
+
await client.query("SELECT 1");
|
|
162
|
+
await client.end();
|
|
163
|
+
} catch (error) {
|
|
164
|
+
throw new Error(`postgres healthcheck failed: ${error.message || error.code || String(error)}`, { cause: error });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
async initialize(composeDir) {
|
|
168
|
+
if (!this.composeName) return;
|
|
169
|
+
const initPaths = [resolve(composeDir, `${this.composeName}/init.sql`), resolve(composeDir, "postgres/init.sql")];
|
|
170
|
+
for (const initPath of initPaths) if (existsSync(initPath)) {
|
|
171
|
+
const sql = readFileSync(initPath, "utf8");
|
|
172
|
+
try {
|
|
173
|
+
await this.seed(sql);
|
|
174
|
+
} catch (error) {
|
|
175
|
+
throw new Error(`postgres init script failed (${initPath}):\n${error.message}`, { cause: error });
|
|
176
|
+
}
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
async getClient() {
|
|
181
|
+
if (this.client) return this.client;
|
|
182
|
+
const client = new Client({ connectionString: this.connectionString });
|
|
183
|
+
client.on("error", () => {
|
|
184
|
+
this.client = null;
|
|
185
|
+
});
|
|
186
|
+
await client.connect();
|
|
187
|
+
this.client = client;
|
|
188
|
+
return client;
|
|
189
|
+
}
|
|
190
|
+
async seed(sql) {
|
|
191
|
+
await (await this.getClient()).query(sql);
|
|
192
|
+
}
|
|
193
|
+
async reset() {
|
|
194
|
+
const client = await this.getClient();
|
|
195
|
+
const result = await client.query(`
|
|
196
|
+
SELECT tablename FROM pg_tables
|
|
197
|
+
WHERE schemaname = '${this.schema}'
|
|
198
|
+
AND tablename NOT LIKE '_prisma%'
|
|
199
|
+
`);
|
|
200
|
+
for (const row of result.rows) await client.query(`TRUNCATE "${this.schema}"."${row.tablename}" CASCADE`);
|
|
201
|
+
}
|
|
202
|
+
async query(table, columns) {
|
|
203
|
+
const client = await this.getClient();
|
|
204
|
+
const columnList = columns.join(", ");
|
|
205
|
+
return (await client.query(`SELECT ${columnList} FROM "${this.schema}"."${table}" ORDER BY 1`)).rows.map((row) => columns.map((col) => row[col]));
|
|
206
|
+
}
|
|
207
|
+
isolation() {
|
|
208
|
+
return {
|
|
209
|
+
acquire: async (workerId) => {
|
|
210
|
+
const workerSchema = `worker_${workerId}`;
|
|
211
|
+
this.originalConnectionString = this.connectionString;
|
|
212
|
+
const client = await this.getClient();
|
|
213
|
+
await client.query(`DROP SCHEMA IF EXISTS "${workerSchema}" CASCADE`);
|
|
214
|
+
await client.query(`CREATE SCHEMA "${workerSchema}"`);
|
|
215
|
+
const tables = await client.query(`
|
|
216
|
+
SELECT tablename FROM pg_tables
|
|
217
|
+
WHERE schemaname = 'public'
|
|
218
|
+
AND tablename NOT LIKE '_prisma%'
|
|
219
|
+
`);
|
|
220
|
+
for (const row of tables.rows) await client.query(`CREATE TABLE "${workerSchema}"."${row.tablename}" (LIKE "public"."${row.tablename}" INCLUDING ALL)`);
|
|
221
|
+
this.schema = workerSchema;
|
|
222
|
+
await client.query(`SET search_path TO "${workerSchema}", public`);
|
|
223
|
+
const url = new URL(this.connectionString);
|
|
224
|
+
url.searchParams.set("options", `-c search_path=${workerSchema},public`);
|
|
225
|
+
this.connectionString = url.toString();
|
|
226
|
+
},
|
|
227
|
+
reset: async () => {
|
|
228
|
+
await this.reset();
|
|
229
|
+
},
|
|
230
|
+
release: async () => {
|
|
231
|
+
const client = await this.getClient();
|
|
232
|
+
const workerSchema = this.schema;
|
|
233
|
+
this.schema = "public";
|
|
234
|
+
this.connectionString = this.originalConnectionString;
|
|
235
|
+
await client.query(`SET search_path TO public`);
|
|
236
|
+
await client.query(`DROP SCHEMA IF EXISTS "${workerSchema}" CASCADE`);
|
|
122
237
|
}
|
|
123
238
|
};
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
/**
|
|
242
|
+
* Create a PostgreSQL service handle.
|
|
243
|
+
*
|
|
244
|
+
* @example
|
|
245
|
+
* // Key derives the compose service (exact name or kebab-case):
|
|
246
|
+
* // { db: postgres() } → compose service "db"
|
|
247
|
+
* // { analyticsDb: postgres() } → compose service "analytics-db"
|
|
248
|
+
* // Escape hatch for names the key cannot derive:
|
|
249
|
+
* const events = postgres({ composeService: "legacy_event_store" });
|
|
250
|
+
* // After start: events.connectionString is populated
|
|
251
|
+
*/
|
|
252
|
+
function postgres(options = {}) {
|
|
253
|
+
return new PostgresHandle(options);
|
|
254
|
+
}
|
|
255
|
+
//#endregion
|
|
256
|
+
//#region src/integrations/redis/redis.ts
|
|
257
|
+
var RedisHandle = class {
|
|
258
|
+
type = "redis";
|
|
259
|
+
composeName;
|
|
260
|
+
defaultPort = 6379;
|
|
261
|
+
defaultImage;
|
|
262
|
+
environment = {};
|
|
263
|
+
connectionString = "";
|
|
264
|
+
started = false;
|
|
265
|
+
dbIndex = 0;
|
|
266
|
+
constructor(options = {}) {
|
|
267
|
+
this.composeName = options.composeService ?? null;
|
|
268
|
+
this.defaultImage = options.image ?? "redis:7";
|
|
269
|
+
}
|
|
270
|
+
buildConnectionString(host, port) {
|
|
271
|
+
return `redis://${host}:${port}`;
|
|
272
|
+
}
|
|
273
|
+
createDatabaseAdapter() {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
async healthcheck() {
|
|
277
|
+
if (!this.connectionString) throw new Error("redis: cannot healthcheck — no connection string");
|
|
278
|
+
try {
|
|
279
|
+
const { createClient } = await import("redis");
|
|
280
|
+
const client = createClient({ url: this.connectionString });
|
|
281
|
+
await client.connect();
|
|
282
|
+
await client.ping();
|
|
283
|
+
await client.disconnect();
|
|
284
|
+
} catch (error) {
|
|
285
|
+
throw new Error(`redis healthcheck failed: ${error.message || error.code || String(error)}`, { cause: error });
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
async initialize() {}
|
|
289
|
+
async reset() {
|
|
290
|
+
const { createClient } = await import("redis");
|
|
291
|
+
const client = createClient({
|
|
292
|
+
url: this.connectionString,
|
|
293
|
+
database: this.dbIndex
|
|
130
294
|
});
|
|
295
|
+
await client.connect();
|
|
296
|
+
try {
|
|
297
|
+
await client.flushDb();
|
|
298
|
+
} finally {
|
|
299
|
+
await client.disconnect();
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
isolation() {
|
|
131
303
|
return {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
304
|
+
acquire: async (workerId) => {
|
|
305
|
+
const numericId = Number.parseInt(workerId, 10) || 0;
|
|
306
|
+
this.dbIndex = numericId % 15 + 1;
|
|
307
|
+
},
|
|
308
|
+
reset: async () => {
|
|
309
|
+
await this.reset();
|
|
310
|
+
},
|
|
311
|
+
release: async () => {
|
|
312
|
+
await this.reset();
|
|
313
|
+
this.dbIndex = 0;
|
|
314
|
+
}
|
|
135
315
|
};
|
|
136
316
|
}
|
|
137
317
|
};
|
|
318
|
+
/**
|
|
319
|
+
* Create a Redis service handle.
|
|
320
|
+
*
|
|
321
|
+
* @example
|
|
322
|
+
* // The record key derives the compose service: { cache: redis() } → "cache".
|
|
323
|
+
* const cache = redis();
|
|
324
|
+
* // After start: cache.connectionString is populated
|
|
325
|
+
*/
|
|
326
|
+
function redis(options = {}) {
|
|
327
|
+
return new RedisHandle(options);
|
|
328
|
+
}
|
|
329
|
+
//#endregion
|
|
330
|
+
//#region src/integrations/testcontainers/testcontainers.ts
|
|
331
|
+
/**
|
|
332
|
+
* Container adapter using testcontainers.
|
|
333
|
+
* Wraps a GenericContainer for programmatic container lifecycle.
|
|
334
|
+
*/
|
|
335
|
+
var TestcontainersAdapter = class {
|
|
336
|
+
image;
|
|
337
|
+
containerPort;
|
|
338
|
+
env;
|
|
339
|
+
reuse;
|
|
340
|
+
container = null;
|
|
341
|
+
constructor(options) {
|
|
342
|
+
this.image = options.image;
|
|
343
|
+
this.containerPort = options.port;
|
|
344
|
+
this.env = options.env ?? {};
|
|
345
|
+
this.reuse = options.reuse ?? false;
|
|
346
|
+
}
|
|
347
|
+
async start() {
|
|
348
|
+
const { GenericContainer, Wait } = await import("testcontainers");
|
|
349
|
+
let builder = new GenericContainer(this.image).withExposedPorts(this.containerPort);
|
|
350
|
+
for (const [key, value] of Object.entries(this.env)) builder = builder.withEnvironment({ [key]: value });
|
|
351
|
+
if (this.image.startsWith("postgres")) builder = builder.withWaitStrategy(Wait.forLogMessage(/database system is ready to accept connections/, 2));
|
|
352
|
+
if (this.reuse) builder = builder.withReuse();
|
|
353
|
+
this.container = await builder.start();
|
|
354
|
+
}
|
|
355
|
+
async stop() {
|
|
356
|
+
if (this.container && !this.reuse) {
|
|
357
|
+
await this.container.stop();
|
|
358
|
+
this.container = null;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
getMappedPort(containerPort) {
|
|
362
|
+
if (!this.container) throw new Error("Container not started");
|
|
363
|
+
return this.container.getMappedPort(containerPort);
|
|
364
|
+
}
|
|
365
|
+
getHost() {
|
|
366
|
+
if (!this.container) throw new Error("Container not started");
|
|
367
|
+
return this.container.getHost();
|
|
368
|
+
}
|
|
369
|
+
getConnectionString() {
|
|
370
|
+
return `${this.getHost()}:${this.getMappedPort(this.containerPort)}`;
|
|
371
|
+
}
|
|
372
|
+
async getLogs() {
|
|
373
|
+
if (!this.container) return "";
|
|
374
|
+
const stream = await this.container.logs();
|
|
375
|
+
return new Promise((resolve) => {
|
|
376
|
+
let output = "";
|
|
377
|
+
stream.on("data", (chunk) => {
|
|
378
|
+
output += chunk.toString();
|
|
379
|
+
});
|
|
380
|
+
stream.on("end", () => {
|
|
381
|
+
resolve(output);
|
|
382
|
+
});
|
|
383
|
+
setTimeout(() => {
|
|
384
|
+
resolve(output);
|
|
385
|
+
}, 1e3);
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
};
|
|
138
389
|
//#endregion
|
|
139
|
-
//#region src/
|
|
390
|
+
//#region src/integrations/hono/hono.adapter.ts
|
|
140
391
|
/**
|
|
141
392
|
* Server adapter that dispatches requests in-process through a Hono app instance.
|
|
142
393
|
* Used by the `integration()` specification runner -- no network overhead.
|
|
@@ -154,7 +405,7 @@ var HonoAdapter = class {
|
|
|
154
405
|
...headers
|
|
155
406
|
}
|
|
156
407
|
};
|
|
157
|
-
if (body !== void 0) init.body = JSON.stringify(body);
|
|
408
|
+
if (body !== void 0) init.body = typeof body === "string" ? body : JSON.stringify(body);
|
|
158
409
|
const response = await this.app.request(path, init);
|
|
159
410
|
const responseBody = await response.json().catch(() => null);
|
|
160
411
|
const responseHeaders = {};
|
|
@@ -169,76 +420,387 @@ var HonoAdapter = class {
|
|
|
169
420
|
}
|
|
170
421
|
};
|
|
171
422
|
//#endregion
|
|
172
|
-
//#region src/
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
"
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
423
|
+
//#region src/core/http-files/http-file.ts
|
|
424
|
+
function splitSections(content) {
|
|
425
|
+
const lines = content.split(/\r?\n/);
|
|
426
|
+
let index = 0;
|
|
427
|
+
while (index < lines.length && lines[index].trim() === "") index++;
|
|
428
|
+
const startLine = lines[index] ?? "";
|
|
429
|
+
index++;
|
|
430
|
+
const headerLines = [];
|
|
431
|
+
while (index < lines.length && lines[index].trim() !== "") {
|
|
432
|
+
headerLines.push(lines[index]);
|
|
433
|
+
index++;
|
|
434
|
+
}
|
|
435
|
+
return {
|
|
436
|
+
body: lines.slice(index + 1).join("\n").trim(),
|
|
437
|
+
headerLines,
|
|
438
|
+
startLine
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
function parseHeaders(headerLines, fileName) {
|
|
442
|
+
const headers = {};
|
|
443
|
+
for (const line of headerLines) {
|
|
444
|
+
const separator = line.indexOf(":");
|
|
445
|
+
if (separator === -1) throw new Error(`${fileName}: invalid header line "${line}" (expected "Name: value")`);
|
|
446
|
+
headers[line.slice(0, separator).trim()] = line.slice(separator + 1).trim();
|
|
447
|
+
}
|
|
448
|
+
return headers;
|
|
449
|
+
}
|
|
450
|
+
/** Parse a `requests/*.http` file. First line must be `METHOD /path`. */
|
|
451
|
+
function parseRequestFile(content, fileName) {
|
|
452
|
+
const { body, headerLines, startLine } = splitSections(content);
|
|
453
|
+
const start = /^(?<method>[A-Z]+)\s+(?<path>\S+)$/.exec(startLine.trim());
|
|
454
|
+
if (!start?.groups) throw new Error(`${fileName}: first line must be "METHOD /path" (e.g. "POST /users"), got "${startLine}"`);
|
|
455
|
+
return {
|
|
456
|
+
body: body.length > 0 ? body : void 0,
|
|
457
|
+
headers: parseHeaders(headerLines, fileName),
|
|
458
|
+
method: start.groups.method,
|
|
459
|
+
path: start.groups.path
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
/** Parse a response `.http` file. First line must be `HTTP/1.1 <status>`. */
|
|
463
|
+
function parseResponseFile(content, fileName) {
|
|
464
|
+
const { body, headerLines, startLine } = splitSections(content);
|
|
465
|
+
const start = /^HTTP\/1\.1\s+(?<status>\S+)(?:\s+.*)?$/.exec(startLine.trim());
|
|
466
|
+
if (!start?.groups) throw new Error(`${fileName}: first line must be "HTTP/1.1 <status>" (e.g. "HTTP/1.1 200 OK"), got "${startLine}"`);
|
|
467
|
+
let parsedBody;
|
|
468
|
+
if (body.length > 0) try {
|
|
469
|
+
parsedBody = JSON.parse(body);
|
|
470
|
+
} catch {
|
|
471
|
+
parsedBody = body;
|
|
472
|
+
}
|
|
473
|
+
return {
|
|
474
|
+
body: parsedBody,
|
|
475
|
+
hasBody: body.length > 0,
|
|
476
|
+
headers: parseHeaders(headerLines, fileName),
|
|
477
|
+
status: start.groups.status
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
const STATUS_TEXT = {
|
|
481
|
+
200: "OK",
|
|
482
|
+
201: "Created",
|
|
483
|
+
202: "Accepted",
|
|
484
|
+
204: "No Content",
|
|
485
|
+
301: "Moved Permanently",
|
|
486
|
+
302: "Found",
|
|
487
|
+
304: "Not Modified",
|
|
488
|
+
400: "Bad Request",
|
|
489
|
+
401: "Unauthorized",
|
|
490
|
+
403: "Forbidden",
|
|
491
|
+
404: "Not Found",
|
|
492
|
+
409: "Conflict",
|
|
493
|
+
422: "Unprocessable Entity",
|
|
494
|
+
429: "Too Many Requests",
|
|
495
|
+
500: "Internal Server Error",
|
|
496
|
+
502: "Bad Gateway",
|
|
497
|
+
503: "Service Unavailable"
|
|
498
|
+
};
|
|
499
|
+
/** Serialize a response fixture (used by update mode). */
|
|
500
|
+
function serializeResponseFile(response) {
|
|
501
|
+
const statusNumber = Number(response.status);
|
|
502
|
+
const statusText = STATUS_TEXT[statusNumber];
|
|
503
|
+
const lines = [`HTTP/1.1 ${response.status}${statusText ? ` ${statusText}` : ""}`];
|
|
504
|
+
for (const [name, value] of Object.entries(response.headers)) lines.push(`${name}: ${value}`);
|
|
505
|
+
if (response.hasBody) {
|
|
506
|
+
lines.push("");
|
|
507
|
+
lines.push(typeof response.body === "string" && !isJsonLike(response.body) ? response.body : JSON.stringify(response.body, null, 4));
|
|
508
|
+
}
|
|
509
|
+
return `${lines.join("\n")}\n`;
|
|
510
|
+
}
|
|
511
|
+
function isJsonLike(text) {
|
|
512
|
+
try {
|
|
513
|
+
JSON.parse(text);
|
|
514
|
+
return true;
|
|
515
|
+
} catch {
|
|
516
|
+
return false;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
//#endregion
|
|
520
|
+
//#region src/core/matching/structural.ts
|
|
186
521
|
/**
|
|
187
|
-
*
|
|
188
|
-
*
|
|
522
|
+
* Structural comparison engine shared by every fixture matcher.
|
|
523
|
+
*
|
|
524
|
+
* Handles three kinds of "expected" values:
|
|
525
|
+
* - plain JSON values → strict deep equality
|
|
526
|
+
* - {@link Matcher} instances (code-side `match.*`)
|
|
527
|
+
* - strings containing `{{placeholder}}` forms (file-side fixtures)
|
|
528
|
+
*
|
|
529
|
+
* One unified `{{token}}` grammar (CONVENTIONS D4) — the same vocabulary
|
|
530
|
+
* works in `expected/*.http` (body and headers), `expected/*.json`, and
|
|
531
|
+
* text snapshots (`expected/*.txt`).
|
|
532
|
+
*
|
|
533
|
+
* Ref captures (`match.ref(name)` / `{{type#name}}`) are recorded in the
|
|
534
|
+
* {@link CaptureScope} supplied by the caller.
|
|
189
535
|
*/
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
536
|
+
const UUID_SOURCE = "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}";
|
|
537
|
+
const ULID_SOURCE = "[0-9A-HJKMNP-TV-Z]{26}";
|
|
538
|
+
const ISO8601_SOURCE = String.raw`\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})`;
|
|
539
|
+
const DATE_SOURCE = String.raw`\d{4}-\d{2}-\d{2}`;
|
|
540
|
+
const TIME_SOURCE = String.raw`\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?`;
|
|
541
|
+
const DURATION_SOURCE = String.raw`\d+(?:\.\d+)?(?:ms|s|m|h)`;
|
|
542
|
+
const NUMBER_SOURCE = String.raw`-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?`;
|
|
543
|
+
const INT_SOURCE = String.raw`-?\d+`;
|
|
544
|
+
const FLOAT_SOURCE = String.raw`-?\d+\.\d+`;
|
|
545
|
+
const SEMVER_SOURCE = String.raw`\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?`;
|
|
546
|
+
const SHA_SOURCE = "[0-9a-f]{7,64}";
|
|
547
|
+
const HEX_SOURCE = "[0-9a-fA-F]+";
|
|
548
|
+
const BASE64_SOURCE = "[A-Za-z0-9+/]+={0,2}";
|
|
549
|
+
const PORT_SOURCE = String.raw`(?:6553[0-5]|655[0-2]\d|65[0-4]\d{2}|6[0-4]\d{3}|[1-5]\d{4}|\d{1,4})`;
|
|
550
|
+
const IP_OCTET = String.raw`(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)`;
|
|
551
|
+
const IP_SOURCE = String.raw`(?:${IP_OCTET}\.){3}${IP_OCTET}`;
|
|
552
|
+
const URL_SOURCE = String.raw`https?:\/\/[^\s"'<>]+`;
|
|
553
|
+
const EMBEDDED_SOURCES = {
|
|
554
|
+
base64: BASE64_SOURCE,
|
|
555
|
+
date: DATE_SOURCE,
|
|
556
|
+
duration: DURATION_SOURCE,
|
|
557
|
+
email: String.raw`[^\s@"'<>]+@[^\s@"'<>]+\.[^\s@"'<>]+`,
|
|
558
|
+
float: FLOAT_SOURCE,
|
|
559
|
+
hex: HEX_SOURCE,
|
|
560
|
+
int: INT_SOURCE,
|
|
561
|
+
ip: IP_SOURCE,
|
|
562
|
+
iso8601: ISO8601_SOURCE,
|
|
563
|
+
number: NUMBER_SOURCE,
|
|
564
|
+
path: String.raw`\.{0,2}\/[^\s"'<>]*`,
|
|
565
|
+
port: PORT_SOURCE,
|
|
566
|
+
semver: SEMVER_SOURCE,
|
|
567
|
+
sha: SHA_SOURCE,
|
|
568
|
+
time: TIME_SOURCE,
|
|
569
|
+
ulid: ULID_SOURCE,
|
|
570
|
+
url: URL_SOURCE,
|
|
571
|
+
uuid: UUID_SOURCE
|
|
572
|
+
};
|
|
573
|
+
const wholeRe = (source) => new RegExp(`^(?:${source})$`);
|
|
574
|
+
const WHOLE_RES = Object.fromEntries(Object.entries(EMBEDDED_SOURCES).map(([kind, source]) => [kind, wholeRe(source)]));
|
|
575
|
+
const PLACEHOLDER_RE = new RegExp(String.raw`\{\{(?<kind>${[...TOKEN_KINDS].sort((a, b) => b.length - a.length).join("|")})(?:#(?<ref>[\w.-]+))?\}\}`, "g");
|
|
576
|
+
/** Whether a fixture string contains at least one `{{placeholder}}`. */
|
|
577
|
+
function hasPlaceholders(value) {
|
|
578
|
+
PLACEHOLDER_RE.lastIndex = 0;
|
|
579
|
+
return PLACEHOLDER_RE.test(value);
|
|
580
|
+
}
|
|
581
|
+
/** Key-order-independent stringification for captured-object comparison. */
|
|
582
|
+
function stableStringify(value) {
|
|
583
|
+
if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(",")}]`;
|
|
584
|
+
if (value !== null && typeof value === "object") return `{${Object.entries(value).sort(([a], [b]) => a < b ? -1 : 1).map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`).join(",")}}`;
|
|
585
|
+
return JSON.stringify(value) ?? "undefined";
|
|
586
|
+
}
|
|
587
|
+
function capturedEquals(a, b) {
|
|
588
|
+
if (Object.is(a, b)) return true;
|
|
589
|
+
if (typeof a === "number" && typeof b === "string" || typeof a === "string" && typeof b === "number") return String(a) === String(b);
|
|
590
|
+
if (a !== null && b !== null && typeof a === "object" && typeof b === "object") return stableStringify(a) === stableStringify(b);
|
|
591
|
+
return false;
|
|
592
|
+
}
|
|
593
|
+
function recordRef(name, actual, scope) {
|
|
594
|
+
if (scope.has(name)) return capturedEquals(scope.get(name), actual);
|
|
595
|
+
scope.set(name, actual);
|
|
596
|
+
return true;
|
|
597
|
+
}
|
|
598
|
+
function isPortValue(value) {
|
|
599
|
+
return Number.isInteger(value) && value >= 0 && value <= 65535;
|
|
600
|
+
}
|
|
601
|
+
function kindMatches(kind, actual, scope) {
|
|
602
|
+
switch (kind) {
|
|
603
|
+
case "any": return true;
|
|
604
|
+
case "float":
|
|
605
|
+
if (typeof actual === "number") return Number.isFinite(actual);
|
|
606
|
+
return typeof actual === "string" && WHOLE_RES.float.test(actual);
|
|
607
|
+
case "int":
|
|
608
|
+
if (typeof actual === "number") return Number.isInteger(actual);
|
|
609
|
+
return typeof actual === "string" && WHOLE_RES.int.test(actual);
|
|
610
|
+
case "number":
|
|
611
|
+
if (typeof actual === "number") return Number.isFinite(actual);
|
|
612
|
+
return typeof actual === "string" && WHOLE_RES.number.test(actual);
|
|
613
|
+
case "port":
|
|
614
|
+
if (typeof actual === "number") return isPortValue(actual);
|
|
615
|
+
return typeof actual === "string" && WHOLE_RES.port.test(actual) && isPortValue(Number(actual));
|
|
616
|
+
case "string": return typeof actual === "string";
|
|
617
|
+
case "workdir": return typeof actual === "string" && scope.workdir !== void 0 ? actual === scope.workdir : false;
|
|
618
|
+
default: {
|
|
619
|
+
const re = WHOLE_RES[kind];
|
|
620
|
+
return re !== void 0 && typeof actual === "string" && re.test(actual);
|
|
206
621
|
}
|
|
207
622
|
}
|
|
208
|
-
await walk(root);
|
|
209
|
-
out.sort();
|
|
210
|
-
return out;
|
|
211
623
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
624
|
+
function matcherMatches(matcher, actual, scope) {
|
|
625
|
+
if (matcher.kind === "regex") return typeof actual === "string" && matcher.regex.test(actual);
|
|
626
|
+
if (matcher.kind === "ref") {
|
|
627
|
+
if (matcher.notRef && scope.has(matcher.notRef) && capturedEquals(scope.get(matcher.notRef), actual)) return false;
|
|
628
|
+
return recordRef(matcher.refName, actual, scope);
|
|
629
|
+
}
|
|
630
|
+
return kindMatches(matcher.kind, actual, scope);
|
|
631
|
+
}
|
|
632
|
+
function placeholderSource(kind, scope) {
|
|
633
|
+
if (kind === "workdir") return scope.workdir === void 0 ? "(?!)" : escapeRegExp(scope.workdir);
|
|
634
|
+
const source = EMBEDDED_SOURCES[kind];
|
|
635
|
+
if (source) return source;
|
|
636
|
+
return kind === "any" ? String.raw`[\s\S]*?` : String.raw`[^\n]*?`;
|
|
637
|
+
}
|
|
638
|
+
function escapeRegExp(text) {
|
|
639
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
|
|
640
|
+
}
|
|
641
|
+
function parsePlaceholderString(expected, scope) {
|
|
642
|
+
PLACEHOLDER_RE.lastIndex = 0;
|
|
643
|
+
const refs = [];
|
|
644
|
+
let pattern = "^";
|
|
645
|
+
let lastIndex = 0;
|
|
646
|
+
let single = null;
|
|
647
|
+
let count = 0;
|
|
648
|
+
for (const found of expected.matchAll(PLACEHOLDER_RE)) {
|
|
649
|
+
const kind = found.groups.kind;
|
|
650
|
+
const ref = found.groups.ref;
|
|
651
|
+
pattern += escapeRegExp(expected.slice(lastIndex, found.index));
|
|
652
|
+
pattern += `(${placeholderSource(kind, scope)})`;
|
|
653
|
+
refs.push({
|
|
654
|
+
index: count,
|
|
655
|
+
kind,
|
|
656
|
+
ref
|
|
232
657
|
});
|
|
658
|
+
count++;
|
|
659
|
+
lastIndex = found.index + found[0].length;
|
|
660
|
+
if (found.index === 0 && found[0].length === expected.length) single = {
|
|
661
|
+
kind,
|
|
662
|
+
ref
|
|
663
|
+
};
|
|
233
664
|
}
|
|
665
|
+
pattern += `${escapeRegExp(expected.slice(lastIndex))}$`;
|
|
234
666
|
return {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
667
|
+
pattern: new RegExp(pattern),
|
|
668
|
+
refs,
|
|
669
|
+
single
|
|
238
670
|
};
|
|
239
671
|
}
|
|
672
|
+
function placeholderStringMatches(expected, actual, scope) {
|
|
673
|
+
const parsed = parsePlaceholderString(expected, scope);
|
|
674
|
+
if (parsed.single) {
|
|
675
|
+
if (!kindMatches(parsed.single.kind, actual, scope)) return false;
|
|
676
|
+
if (parsed.single.ref) return recordRef(parsed.single.ref, actual, scope);
|
|
677
|
+
return true;
|
|
678
|
+
}
|
|
679
|
+
if (typeof actual !== "string" && typeof actual !== "number") return false;
|
|
680
|
+
const text = String(actual);
|
|
681
|
+
const found = parsed.pattern.exec(text);
|
|
682
|
+
if (!found) return false;
|
|
683
|
+
for (const entry of parsed.refs) {
|
|
684
|
+
if (!entry.ref) continue;
|
|
685
|
+
if (!recordRef(entry.ref, found[entry.index + 1], scope)) return false;
|
|
686
|
+
}
|
|
687
|
+
return true;
|
|
688
|
+
}
|
|
689
|
+
function isPlainObject(value) {
|
|
690
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* Deep structural equality with matcher / placeholder support. Ref captures
|
|
694
|
+
* are recorded in `scope` in traversal order (arrays left-to-right, object
|
|
695
|
+
* keys in expected-key order).
|
|
696
|
+
*/
|
|
697
|
+
function structuralEquals(expected, actual, scope) {
|
|
698
|
+
if (expected instanceof Matcher) return matcherMatches(expected, actual, scope);
|
|
699
|
+
if (typeof expected === "string" && hasPlaceholders(expected)) return placeholderStringMatches(expected, actual, scope);
|
|
700
|
+
if (Array.isArray(expected)) {
|
|
701
|
+
if (!Array.isArray(actual) || actual.length !== expected.length) return false;
|
|
702
|
+
return expected.every((item, i) => structuralEquals(item, actual[i], scope));
|
|
703
|
+
}
|
|
704
|
+
if (isPlainObject(expected)) {
|
|
705
|
+
if (!isPlainObject(actual)) return false;
|
|
706
|
+
const expectedKeys = Object.keys(expected);
|
|
707
|
+
const actualKeys = Object.keys(actual);
|
|
708
|
+
if (expectedKeys.length !== actualKeys.length) return false;
|
|
709
|
+
return expectedKeys.every((key) => key in actual && structuralEquals(expected[key], actual[key], scope));
|
|
710
|
+
}
|
|
711
|
+
return Object.is(expected, actual);
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Deep structural SUBSET match (toMatchObject-style) with matcher /
|
|
715
|
+
* placeholder support. Plain objects match when every expected key is present
|
|
716
|
+
* and recursively subset-matches — the actual value may carry extra keys.
|
|
717
|
+
* Arrays require equal length with each element subset-matched. Leaves fall
|
|
718
|
+
* back to {@link structuralEquals} semantics (matchers, placeholders, strict
|
|
719
|
+
* equality). Used by request-body filters on intercept triggers.
|
|
720
|
+
*/
|
|
721
|
+
function structuralSubset(expected, actual, scope) {
|
|
722
|
+
if (expected instanceof Matcher) return matcherMatches(expected, actual, scope);
|
|
723
|
+
if (typeof expected === "string" && hasPlaceholders(expected)) return placeholderStringMatches(expected, actual, scope);
|
|
724
|
+
if (Array.isArray(expected)) {
|
|
725
|
+
if (!Array.isArray(actual) || actual.length !== expected.length) return false;
|
|
726
|
+
return expected.every((item, i) => structuralSubset(item, actual[i], scope));
|
|
727
|
+
}
|
|
728
|
+
if (isPlainObject(expected)) {
|
|
729
|
+
if (!isPlainObject(actual)) return false;
|
|
730
|
+
return Object.keys(expected).every((key) => key in actual && structuralSubset(expected[key], actual[key], scope));
|
|
731
|
+
}
|
|
732
|
+
return Object.is(expected, actual);
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* Multi-line text comparison with `{{token}}` support — used by text
|
|
736
|
+
* snapshots (`expected/*.txt`). Without placeholders this is strict equality.
|
|
737
|
+
*/
|
|
738
|
+
function textEquals(expected, actual, scope) {
|
|
739
|
+
if (!hasPlaceholders(expected)) return expected === actual;
|
|
740
|
+
return placeholderStringMatches(expected, actual, scope);
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* Render an expected value for failure diffs and serialization: Matcher
|
|
744
|
+
* instances become their placeholder text, everything else is untouched.
|
|
745
|
+
*/
|
|
746
|
+
function renderExpected(value) {
|
|
747
|
+
if (value instanceof Matcher) return value.toString();
|
|
748
|
+
if (Array.isArray(value)) return value.map((item) => renderExpected(item));
|
|
749
|
+
if (isPlainObject(value)) return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, renderExpected(item)]));
|
|
750
|
+
return value;
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Substitute the framework-known cwd (`workdir`) for its `{{workdir}}` token in
|
|
754
|
+
* every string leaf of a structured value. The structural mirror of the text
|
|
755
|
+
* path's `actual.replaceAll(workdir, '{{workdir}}')` — so a golden written under
|
|
756
|
+
* `TEST_UPDATE=1` stores the token, not a run-specific temp path (CONVENTIONS
|
|
757
|
+
* D5). A no-op when `workdir` is undefined (no cwd, e.g. api/jobs mode).
|
|
758
|
+
*/
|
|
759
|
+
function substituteWorkdirDeep(value, workdir) {
|
|
760
|
+
if (typeof value === "string") return value.replaceAll(workdir, "{{workdir}}");
|
|
761
|
+
if (Array.isArray(value)) return value.map((item) => substituteWorkdirDeep(item, workdir));
|
|
762
|
+
if (isPlainObject(value)) return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, substituteWorkdirDeep(item, workdir)]));
|
|
763
|
+
return value;
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* Update-mode merge: rewrite a fixture from the actual output while
|
|
767
|
+
* preserving every segment of the previous fixture that was covered by a
|
|
768
|
+
* placeholder (and still matches the actual value).
|
|
769
|
+
*
|
|
770
|
+
* Token preservation is symmetric with the text path (CONVENTIONS D5): a
|
|
771
|
+
* previous `{{placeholder}}` (including `{{workdir}}`) that still matches the
|
|
772
|
+
* RAW actual value is kept; every other leaf is taken from the actual output
|
|
773
|
+
* with the framework-known cwd substituted back to `{{workdir}}`.
|
|
774
|
+
*/
|
|
775
|
+
function mergePreservingPlaceholders(previous, actual, workdir) {
|
|
776
|
+
const scope = new CaptureScope(workdir);
|
|
777
|
+
const subst = (value) => workdir === void 0 ? value : substituteWorkdirDeep(value, workdir);
|
|
778
|
+
function merge(prev, act) {
|
|
779
|
+
if (typeof prev === "string" && hasPlaceholders(prev)) return placeholderStringMatches(prev, act, scope) ? prev : subst(act);
|
|
780
|
+
if (Array.isArray(prev) && Array.isArray(act)) return act.map((item, i) => i < prev.length ? merge(prev[i], item) : subst(item));
|
|
781
|
+
if (isPlainObject(prev) && isPlainObject(act)) return Object.fromEntries(Object.entries(act).map(([key, item]) => [key, key in prev ? merge(prev[key], item) : subst(item)]));
|
|
782
|
+
return subst(act);
|
|
783
|
+
}
|
|
784
|
+
return merge(previous, actual);
|
|
785
|
+
}
|
|
786
|
+
/**
|
|
787
|
+
* Update-mode merge for text snapshots: line-by-line, a previous line whose
|
|
788
|
+
* placeholders still match the actual line is preserved; every other line is
|
|
789
|
+
* taken from the actual output. Values the framework knows to be dynamic
|
|
790
|
+
* (`{{workdir}}`) are substituted automatically (CONVENTIONS D5).
|
|
791
|
+
*/
|
|
792
|
+
function mergeTextPreservingPlaceholders(previous, actual, scope) {
|
|
793
|
+
const substituted = scope.workdir === void 0 ? actual : actual.replaceAll(scope.workdir, "{{workdir}}");
|
|
794
|
+
if (previous === null) return substituted;
|
|
795
|
+
const prevLines = previous.split("\n");
|
|
796
|
+
return substituted.split("\n").map((line, i) => {
|
|
797
|
+
const prev = prevLines[i];
|
|
798
|
+
if (prev === void 0 || !hasPlaceholders(prev)) return line;
|
|
799
|
+
return textEquals(prev, line, new CaptureScope(scope.workdir)) || textEquals(prev, actual.split("\n")[i] ?? line, new CaptureScope(scope.workdir)) ? prev : line;
|
|
800
|
+
}).join("\n");
|
|
801
|
+
}
|
|
240
802
|
//#endregion
|
|
241
|
-
//#region src/
|
|
803
|
+
//#region src/core/specification/shared/reporter.ts
|
|
242
804
|
const GREEN = "\x1B[32m";
|
|
243
805
|
const RED = "\x1B[31m";
|
|
244
806
|
const DIM = "\x1B[2m";
|
|
@@ -320,6 +882,27 @@ function formatResponseDiff(file, expected, actual) {
|
|
|
320
882
|
}
|
|
321
883
|
return lines.join("\n");
|
|
322
884
|
}
|
|
885
|
+
function formatStdoutDiff(file, expected, actual) {
|
|
886
|
+
const lines = [];
|
|
887
|
+
lines.push(`Output mismatch (${file})`);
|
|
888
|
+
lines.push("");
|
|
889
|
+
lines.push(`${GREEN}- Expected${RESET}`);
|
|
890
|
+
lines.push(`${RED}+ Received${RESET}`);
|
|
891
|
+
lines.push("");
|
|
892
|
+
const expectedLines = expected.split("\n");
|
|
893
|
+
const actualLines = actual.split("\n");
|
|
894
|
+
const maxLines = Math.max(expectedLines.length, actualLines.length);
|
|
895
|
+
for (let i = 0; i < maxLines; i++) {
|
|
896
|
+
const exp = expectedLines[i];
|
|
897
|
+
const act = actualLines[i];
|
|
898
|
+
if (exp === act) lines.push(` ${exp}`);
|
|
899
|
+
else {
|
|
900
|
+
if (exp !== void 0) lines.push(`${GREEN}- ${exp}${RESET}`);
|
|
901
|
+
if (act !== void 0) lines.push(`${RED}+ ${act}${RESET}`);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
return lines.join("\n");
|
|
905
|
+
}
|
|
323
906
|
function formatDirectoryDiff(fixtureName, diff, hint) {
|
|
324
907
|
const lines = [];
|
|
325
908
|
const total = diff.added.length + diff.removed.length + diff.changed.length;
|
|
@@ -367,70 +950,155 @@ function rowLabel(n) {
|
|
|
367
950
|
function formatRow(row) {
|
|
368
951
|
return row.map((v) => String(v ?? "null")).join(" | ");
|
|
369
952
|
}
|
|
370
|
-
/** Strip ANSI escape codes from a string. */
|
|
371
|
-
function stripAnsi(str) {
|
|
372
|
-
return str.replace(/\x1b\[[0-9;]*m/g, "");
|
|
373
|
-
}
|
|
374
|
-
/** Strip ANSI codes and replace volatile tokens (ports, durations) with placeholders. */
|
|
375
|
-
function normalizeOutput(str) {
|
|
376
|
-
return stripAnsi(str).replace(/localhost:\d+/g, "localhost:PORT").replace(/\d+ms/g, "Xms").replace(/\d+\.\d+s/g, "X.Xs").trim();
|
|
377
|
-
}
|
|
378
953
|
//#endregion
|
|
379
|
-
//#region src/
|
|
954
|
+
//#region src/core/specification/shared/result/directory.ts
|
|
955
|
+
const DEFAULT_IGNORES = [
|
|
956
|
+
".git",
|
|
957
|
+
".DS_Store",
|
|
958
|
+
"node_modules",
|
|
959
|
+
".next",
|
|
960
|
+
"dist",
|
|
961
|
+
".turbo",
|
|
962
|
+
".cache"
|
|
963
|
+
];
|
|
380
964
|
/**
|
|
381
|
-
*
|
|
382
|
-
* - vitest run with `-u` / `--update`
|
|
383
|
-
* - JTERRAZZ_TEST_UPDATE=1
|
|
384
|
-
* - UPDATE_SNAPSHOTS=1
|
|
965
|
+
* Recursively walk a directory, returning sorted relative paths of files only.
|
|
385
966
|
*/
|
|
386
|
-
function
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
967
|
+
async function walkDirectory(root, options = {}) {
|
|
968
|
+
const ignores = /* @__PURE__ */ new Set([...DEFAULT_IGNORES, ...options.ignore ?? []]);
|
|
969
|
+
const out = [];
|
|
970
|
+
async function walk(current) {
|
|
971
|
+
let entries;
|
|
972
|
+
try {
|
|
973
|
+
entries = await readdir(current);
|
|
974
|
+
} catch {
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
for (const entry of entries) {
|
|
978
|
+
if (ignores.has(entry)) continue;
|
|
979
|
+
const abs = resolve(current, entry);
|
|
980
|
+
const stat = statSync(abs);
|
|
981
|
+
if (stat.isDirectory()) await walk(abs);
|
|
982
|
+
else if (stat.isFile()) out.push(relative(root, abs).split(sep).join("/"));
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
await walk(root);
|
|
986
|
+
out.sort();
|
|
987
|
+
return out;
|
|
391
988
|
}
|
|
392
989
|
/**
|
|
393
|
-
*
|
|
394
|
-
*
|
|
990
|
+
* Compare two directory trees file-by-file. Fixture file contents honor the
|
|
991
|
+
* unified `{{token}}` grammar (CONVENTIONS D4) — captures are recorded in
|
|
992
|
+
* `scope` in sorted-path order.
|
|
993
|
+
*/
|
|
994
|
+
async function diffDirectories(expectedRoot, actualRoot, options = {}) {
|
|
995
|
+
const expectedFiles = await walkDirectory(expectedRoot, options);
|
|
996
|
+
const actualFiles = await walkDirectory(actualRoot, options);
|
|
997
|
+
const expectedSet = new Set(expectedFiles);
|
|
998
|
+
const actualSet = new Set(actualFiles);
|
|
999
|
+
const scope = options.scope ?? new CaptureScope();
|
|
1000
|
+
const added = actualFiles.filter((f) => !expectedSet.has(f));
|
|
1001
|
+
const removed = expectedFiles.filter((f) => !actualSet.has(f));
|
|
1002
|
+
const changed = [];
|
|
1003
|
+
for (const file of expectedFiles) {
|
|
1004
|
+
if (!actualSet.has(file)) continue;
|
|
1005
|
+
const expected = readFileSync(resolve(expectedRoot, file), "utf8");
|
|
1006
|
+
const actual = readFileSync(resolve(actualRoot, file), "utf8");
|
|
1007
|
+
if (!textEquals(expected, actual, scope)) changed.push({
|
|
1008
|
+
actual,
|
|
1009
|
+
expected,
|
|
1010
|
+
path: file
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
return {
|
|
1014
|
+
added,
|
|
1015
|
+
changed,
|
|
1016
|
+
removed
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
/**
|
|
1020
|
+
* Read-only accessor for a directory produced by a spec action.
|
|
1021
|
+
*
|
|
1022
|
+
* Assertions go through `expect()` (async — they walk the disk):
|
|
1023
|
+
* `await expect(result.directory('out')).toMatch('scaffold/out')`
|
|
1024
|
+
* compares the tree against the fixture directory `expected/<name>/`.
|
|
395
1025
|
*/
|
|
396
1026
|
var DirectoryAccessor = class {
|
|
397
|
-
|
|
1027
|
+
/** @internal Ref-capture scope shared by the current spec execution. */
|
|
1028
|
+
captures;
|
|
1029
|
+
/** @internal Absolute path of the directory under assertion. */
|
|
1030
|
+
root;
|
|
1031
|
+
/** @internal Test-file directory — fixture resolution root for matchers. */
|
|
398
1032
|
testDir;
|
|
399
|
-
constructor(absPath, testDir) {
|
|
400
|
-
this.
|
|
1033
|
+
constructor(absPath, testDir, captures) {
|
|
1034
|
+
this.root = absPath;
|
|
401
1035
|
this.testDir = testDir;
|
|
1036
|
+
this.captures = captures ?? new CaptureScope();
|
|
402
1037
|
}
|
|
403
|
-
/**
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
* fixture is overwritten with the current contents instead.
|
|
407
|
-
*/
|
|
408
|
-
async toMatchFixture(name, options = {}) {
|
|
409
|
-
const fixtureDir = resolve(this.testDir, "expected", name);
|
|
410
|
-
if (options.update ?? shouldUpdateSnapshots()) {
|
|
411
|
-
rmSync(fixtureDir, {
|
|
412
|
-
force: true,
|
|
413
|
-
recursive: true
|
|
414
|
-
});
|
|
415
|
-
mkdirSync(fixtureDir, { recursive: true });
|
|
416
|
-
cpSync(this.absPath, fixtureDir, { recursive: true });
|
|
417
|
-
return;
|
|
418
|
-
}
|
|
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."));
|
|
1038
|
+
/** List all files (recursively) under the directory, sorted. */
|
|
1039
|
+
async files(options = {}) {
|
|
1040
|
+
return walkDirectory(this.root, options);
|
|
423
1041
|
}
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
1042
|
+
};
|
|
1043
|
+
//#endregion
|
|
1044
|
+
//#region src/core/specification/shared/result/filesystem.ts
|
|
1045
|
+
/**
|
|
1046
|
+
* Read-only accessor for the whole temporary working directory used by a
|
|
1047
|
+
* command spec.
|
|
1048
|
+
*
|
|
1049
|
+
* Assertions go through `expect()` (async — they walk the disk):
|
|
1050
|
+
* `await expect(result.filesystem).toMatch('scaffolded')` compares the
|
|
1051
|
+
* tree against the fixture directory `expected/<name>/`.
|
|
1052
|
+
*/
|
|
1053
|
+
var FilesystemAccessor = class {
|
|
1054
|
+
/** @internal Ref-capture scope shared by the current spec execution. */
|
|
1055
|
+
captures;
|
|
1056
|
+
/** The absolute path of the temporary working directory. */
|
|
1057
|
+
cwd;
|
|
1058
|
+
/** @internal Test-file directory — fixture resolution root for matchers. */
|
|
1059
|
+
testDir;
|
|
1060
|
+
constructor(cwd, testDir, captures) {
|
|
1061
|
+
this.cwd = cwd;
|
|
1062
|
+
this.testDir = testDir;
|
|
1063
|
+
this.captures = captures ?? new CaptureScope();
|
|
1064
|
+
}
|
|
1065
|
+
/** List all files (recursively) under the working directory, sorted. */
|
|
428
1066
|
async files(options = {}) {
|
|
429
|
-
return walkDirectory(this.
|
|
1067
|
+
return walkDirectory(this.cwd, options);
|
|
430
1068
|
}
|
|
431
1069
|
};
|
|
432
1070
|
//#endregion
|
|
433
|
-
//#region src/
|
|
1071
|
+
//#region src/core/specification/shared/caller.ts
|
|
1072
|
+
/**
|
|
1073
|
+
* Directory of the running framework module itself. From the built package
|
|
1074
|
+
* this is `<package root>/dist`, whatever the resolution path — including a
|
|
1075
|
+
* `file:` link, where the real path carries neither `node_modules` nor
|
|
1076
|
+
* `/src/…` and would otherwise be mistaken for a caller frame.
|
|
1077
|
+
*/
|
|
1078
|
+
const FRAMEWORK_DIR = dirname(fileURLToPath(import.meta.url));
|
|
1079
|
+
/**
|
|
1080
|
+
* Detect the directory of the first stack frame that lives outside this
|
|
1081
|
+
* package. Used to anchor fixture resolution (`seeds/`, `requests/`,
|
|
1082
|
+
* `expected/`, …) on the calling test / specification file.
|
|
1083
|
+
*
|
|
1084
|
+
* @internal
|
|
1085
|
+
*/
|
|
1086
|
+
function getCallerDir() {
|
|
1087
|
+
const stack = (/* @__PURE__ */ new Error("caller detection")).stack;
|
|
1088
|
+
if (!stack) throw new Error("Cannot detect caller directory: no stack trace");
|
|
1089
|
+
const lines = stack.split("\n");
|
|
1090
|
+
for (const line of lines) {
|
|
1091
|
+
const match = line.match(/at\s+(?:.*?\()?(?:file:\/\/)?(?<filePath>[^:)]+):\d+:\d+/);
|
|
1092
|
+
if (!match?.groups?.filePath) continue;
|
|
1093
|
+
const filePath = match.groups.filePath;
|
|
1094
|
+
if (filePath.includes("node_modules")) continue;
|
|
1095
|
+
if ((filePath.includes("/src/core/") || filePath.includes("/src/integrations/") || filePath.includes("/src/vitest/") || resolve(filePath, "..") === FRAMEWORK_DIR) && !/\.test\.[cm]?[jt]s$/.test(filePath)) continue;
|
|
1096
|
+
return resolve(filePath, "..");
|
|
1097
|
+
}
|
|
1098
|
+
throw new Error("Cannot detect caller directory from stack trace");
|
|
1099
|
+
}
|
|
1100
|
+
//#endregion
|
|
1101
|
+
//#region src/core/specification/shared/result/grep.ts
|
|
434
1102
|
/**
|
|
435
1103
|
* Extract text blocks from output that contain a pattern.
|
|
436
1104
|
* Splits by blank lines (how linter/compiler output is structured),
|
|
@@ -444,841 +1112,1461 @@ function grep(output, pattern) {
|
|
|
444
1112
|
return output.replace(/\x1b\[[0-9;]*m/g, "").split(/\n\s*\n/).filter((block) => block.includes(pattern)).join("\n\n");
|
|
445
1113
|
}
|
|
446
1114
|
//#endregion
|
|
447
|
-
//#region src/
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
1115
|
+
//#region src/core/specification/shared/result/text.ts
|
|
1116
|
+
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
1117
|
+
/** Strip ANSI escape sequences from a string. */
|
|
1118
|
+
function stripAnsiCodes(value) {
|
|
1119
|
+
return value.replace(ANSI_RE, "");
|
|
1120
|
+
}
|
|
1121
|
+
/**
|
|
1122
|
+
* Read-only accessor for a captured text handle — THE universal one: stdout,
|
|
1123
|
+
* stderr, container logs, and file text all surface as a `TextAccessor`.
|
|
1124
|
+
*
|
|
1125
|
+
* Backed by a primitive string, exposed via {@link text}, but also provides
|
|
1126
|
+
* `toString()` / `valueOf()` for string coercion, so common patterns like
|
|
1127
|
+
* `String(result.stdout)` and template-literal interpolation still work.
|
|
1128
|
+
*
|
|
1129
|
+
* ANSI escape sequences are stripped by default before every comparison
|
|
1130
|
+
* (CONVENTIONS D6) — `.text` stays raw. The runner `transform` option remains
|
|
1131
|
+
* an escape hatch for application noise not covered by the `{{token}}`
|
|
1132
|
+
* grammar.
|
|
1133
|
+
*
|
|
1134
|
+
* Text operations are **closed** over the type: `.grep(pattern)` returns a
|
|
1135
|
+
* `TextAccessor` (not a bare string), preserving the `expected/`-resolution
|
|
1136
|
+
* context and the `transform`, so results are chainable
|
|
1137
|
+
* (`result.stdout.grep(a).grep(b)`) and snapshot-able
|
|
1138
|
+
* (`expect(result.stdout.grep('users.ts')).toMatch('block.txt')`).
|
|
1139
|
+
*
|
|
1140
|
+
* Assertions go through `expect()` matchers: `expect(result.stdout).toContain(...)`
|
|
1141
|
+
* and `expect(result.stdout).toMatch('name.txt')` (resolved against
|
|
1142
|
+
* `expected/<name>`, flat — with `{{token}}` support, CONVENTIONS D4).
|
|
1143
|
+
*/
|
|
1144
|
+
var TextAccessor = class TextAccessor {
|
|
1145
|
+
/** @internal Ref-capture scope shared by the current spec execution. */
|
|
1146
|
+
captures;
|
|
1147
|
+
/** @internal Stream label used in failure messages ("stdout" / "stderr"). */
|
|
1148
|
+
streamName;
|
|
1149
|
+
/** @internal Test-file directory — fixture resolution root for matchers. */
|
|
451
1150
|
testDir;
|
|
452
|
-
|
|
453
|
-
|
|
1151
|
+
/** The raw captured text (never transformed, ANSI preserved). */
|
|
1152
|
+
text;
|
|
1153
|
+
/** @internal Normaliser applied before comparisons, never to fixtures. */
|
|
1154
|
+
transform;
|
|
1155
|
+
constructor(value, streamName, testDir, options = {}) {
|
|
1156
|
+
this.text = value;
|
|
1157
|
+
this.streamName = streamName;
|
|
454
1158
|
this.testDir = testDir;
|
|
1159
|
+
this.transform = options.transform;
|
|
1160
|
+
this.captures = options.captures ?? new CaptureScope();
|
|
455
1161
|
}
|
|
456
|
-
/**
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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;
|
|
1162
|
+
/** @internal The text as compared by matchers — ANSI stripped, transform applied. */
|
|
1163
|
+
get comparableText() {
|
|
1164
|
+
const stripped = stripAnsiCodes(this.text);
|
|
1165
|
+
return this.transform ? this.transform(stripped) : stripped;
|
|
476
1166
|
}
|
|
477
1167
|
/**
|
|
478
|
-
*
|
|
479
|
-
*
|
|
480
|
-
*
|
|
481
|
-
*
|
|
482
|
-
*
|
|
483
|
-
* rows: [["Alice", "alice@example.com"]],
|
|
484
|
-
* });
|
|
1168
|
+
* Keep only the blank-line-separated blocks of the text that contain
|
|
1169
|
+
* `pattern` (how linter/compiler output is structured), returned as a new
|
|
1170
|
+
* `TextAccessor` — the same subject type, so the result is chainable and
|
|
1171
|
+
* snapshot-able. The scalpel for probing large tool outputs; snapshot the
|
|
1172
|
+
* whole surface by default, reach for `.grep()` for targeted checks.
|
|
485
1173
|
*/
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
1174
|
+
grep(pattern) {
|
|
1175
|
+
return new TextAccessor(grep(this.text, pattern), this.streamName, this.testDir, {
|
|
1176
|
+
captures: this.captures,
|
|
1177
|
+
transform: this.transform
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
toString() {
|
|
1181
|
+
return this.text;
|
|
489
1182
|
}
|
|
490
|
-
|
|
491
|
-
|
|
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`);
|
|
1183
|
+
valueOf() {
|
|
1184
|
+
return this.text;
|
|
494
1185
|
}
|
|
495
1186
|
};
|
|
1187
|
+
/**
|
|
1188
|
+
* Wrap an arbitrary string into a {@link TextAccessor} anchored on the calling
|
|
1189
|
+
* test's directory — the same caller-detection the builders use.
|
|
1190
|
+
*
|
|
1191
|
+
* The product surface of a test framework is its own error messages, checker
|
|
1192
|
+
* output, and reports; those deserve the same goldening as any other output.
|
|
1193
|
+
* `text()` makes an ad-hoc string a first-class snapshot subject:
|
|
1194
|
+
*
|
|
1195
|
+
* ```typescript
|
|
1196
|
+
* const message = await catchMessage(() => expect(result.response).toMatch('wrong-body.http'));
|
|
1197
|
+
* expect(text(message)).toMatch('wrong-body-error.txt'); // resolves to expected/
|
|
1198
|
+
* ```
|
|
1199
|
+
*
|
|
1200
|
+
* ANSI is stripped before every comparison (the raw form stays on `.text`),
|
|
1201
|
+
* the `{{token}}` grammar applies to the fixture, and `.grep()` composition
|
|
1202
|
+
* works exactly as on any stream accessor.
|
|
1203
|
+
*/
|
|
1204
|
+
function text(value) {
|
|
1205
|
+
return new TextAccessor(value, "text", getCallerDir());
|
|
1206
|
+
}
|
|
496
1207
|
//#endregion
|
|
497
|
-
//#region src/
|
|
1208
|
+
//#region src/core/specification/shared/result/json.ts
|
|
498
1209
|
/**
|
|
499
|
-
*
|
|
500
|
-
*
|
|
1210
|
+
* Read-only accessor for a JSON payload parsed from a text stream (stdout).
|
|
1211
|
+
*
|
|
1212
|
+
* Lazily parses the JSON on first use; parse errors are deferred until the
|
|
1213
|
+
* value is read so that callers can still read the raw stream text without
|
|
1214
|
+
* triggering a throw.
|
|
1215
|
+
*
|
|
1216
|
+
* ANSI escape sequences are stripped by default before `JSON.parse`
|
|
1217
|
+
* (CONVENTIONS D6). When a `transform` is configured on the spec runner, it
|
|
1218
|
+
* runs on the stripped text — an escape hatch for application noise not
|
|
1219
|
+
* covered by the `{{token}}` grammar.
|
|
1220
|
+
*
|
|
1221
|
+
* Assertions go through `expect()`: `expect(result.json).toMatch('name.json')`
|
|
1222
|
+
* (deep-equal against `expected/<name>`, with `{{placeholder}}` support).
|
|
501
1223
|
*/
|
|
502
|
-
var
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
1224
|
+
var JsonAccessor = class {
|
|
1225
|
+
/** @internal Ref-capture scope shared by the current spec execution. */
|
|
1226
|
+
captures;
|
|
1227
|
+
/** @internal */
|
|
1228
|
+
rawText;
|
|
1229
|
+
/** @internal Test-file directory — fixture resolution root for matchers. */
|
|
507
1230
|
testDir;
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
this.
|
|
512
|
-
this.
|
|
513
|
-
this.
|
|
514
|
-
this.
|
|
515
|
-
this.workDir = options.workDir;
|
|
1231
|
+
/** @internal */
|
|
1232
|
+
transform;
|
|
1233
|
+
constructor(rawText, testDir, transform, captures) {
|
|
1234
|
+
this.rawText = rawText;
|
|
1235
|
+
this.testDir = testDir;
|
|
1236
|
+
this.transform = transform;
|
|
1237
|
+
this.captures = captures ?? new CaptureScope();
|
|
516
1238
|
}
|
|
517
|
-
/** The
|
|
518
|
-
get
|
|
519
|
-
|
|
520
|
-
|
|
1239
|
+
/** The parsed JSON value. Throws if the text is not valid JSON. */
|
|
1240
|
+
get value() {
|
|
1241
|
+
const stripped = stripAnsiCodes(this.rawText);
|
|
1242
|
+
const source = this.transform ? this.transform(stripped) : stripped;
|
|
1243
|
+
try {
|
|
1244
|
+
return JSON.parse(source);
|
|
1245
|
+
} catch {
|
|
1246
|
+
const preview = source.slice(0, 200);
|
|
1247
|
+
throw new Error(`stdout is not valid JSON: ${preview}`);
|
|
1248
|
+
}
|
|
521
1249
|
}
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
1250
|
+
};
|
|
1251
|
+
//#endregion
|
|
1252
|
+
//#region src/core/specification/shared/result/response.ts
|
|
1253
|
+
/**
|
|
1254
|
+
* Read-only accessor for an HTTP response.
|
|
1255
|
+
*
|
|
1256
|
+
* Assertions go through `expect()`:
|
|
1257
|
+
* `expect(result.response).toMatch('created.http')` compares status,
|
|
1258
|
+
* a subset of headers, and the JSON body against `expected/<name>` —
|
|
1259
|
+
* with `{{placeholder}}` support in all three.
|
|
1260
|
+
*/
|
|
1261
|
+
var ResponseAccessor = class {
|
|
1262
|
+
/** The parsed JSON response body (null when parsing failed). */
|
|
1263
|
+
body;
|
|
1264
|
+
/** @internal Ref-capture scope shared by the current spec execution. */
|
|
1265
|
+
captures;
|
|
1266
|
+
/** Response headers as a flat, lower-cased key-value map. */
|
|
1267
|
+
headers;
|
|
1268
|
+
/** The HTTP response status code. */
|
|
1269
|
+
status;
|
|
1270
|
+
/** @internal Test-file directory — fixture resolution root for matchers. */
|
|
1271
|
+
testDir;
|
|
1272
|
+
constructor(response, testDir, captures) {
|
|
1273
|
+
this.body = response.body;
|
|
1274
|
+
this.headers = response.headers;
|
|
1275
|
+
this.status = response.status;
|
|
1276
|
+
this.testDir = testDir;
|
|
1277
|
+
this.captures = captures ?? new CaptureScope();
|
|
526
1278
|
}
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
1279
|
+
};
|
|
1280
|
+
//#endregion
|
|
1281
|
+
//#region src/core/specification/shared/result/table.ts
|
|
1282
|
+
/**
|
|
1283
|
+
* Read-only accessor for a database table after a specification run.
|
|
1284
|
+
*
|
|
1285
|
+
* Assertions go through `expect()` (async — they query the database):
|
|
1286
|
+
*
|
|
1287
|
+
* await expect(result.table('users', { database: 'db' })).toMatchRows({
|
|
1288
|
+
* columns: ['name'],
|
|
1289
|
+
* rows: [['Alice']],
|
|
1290
|
+
* });
|
|
1291
|
+
* await expect(result.table('users', { database: 'db' })).toBeEmpty();
|
|
1292
|
+
*/
|
|
1293
|
+
var TableAccessor = class {
|
|
1294
|
+
/** @internal Ref-capture scope shared by the current spec execution. */
|
|
1295
|
+
captures;
|
|
1296
|
+
/** @internal */
|
|
1297
|
+
db;
|
|
1298
|
+
/** The table name this accessor reads. */
|
|
1299
|
+
name;
|
|
1300
|
+
constructor(name, db, captures) {
|
|
1301
|
+
this.name = name;
|
|
1302
|
+
this.db = db;
|
|
1303
|
+
this.captures = captures ?? new CaptureScope();
|
|
531
1304
|
}
|
|
532
|
-
/**
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
return this.commandResult.stderr;
|
|
1305
|
+
/** @internal Query the table — used by the toMatchRows / toBeEmpty matchers. */
|
|
1306
|
+
query(columns) {
|
|
1307
|
+
return this.db.query(this.name, columns);
|
|
536
1308
|
}
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
1309
|
+
};
|
|
1310
|
+
//#endregion
|
|
1311
|
+
//#region src/integrations/docker/docker-lookup.ts
|
|
1312
|
+
/**
|
|
1313
|
+
* Thin synchronous wrappers around the `docker` CLI used by the docker() spec
|
|
1314
|
+
* mode. Sync is deliberate — the calls are fast, run only on the host, and
|
|
1315
|
+
* keep the dispose path straightforward (`Symbol.asyncDispose` can still be
|
|
1316
|
+
* async without needing these to be).
|
|
1317
|
+
*/
|
|
1318
|
+
const DEFAULT_TIMEOUT = 1e4;
|
|
1319
|
+
/** Return all container IDs (running or stopped) that carry `key=value`. */
|
|
1320
|
+
function findContainersByLabel(key, value) {
|
|
1321
|
+
try {
|
|
1322
|
+
return execSync(`docker ps -aq -f label=${key}=${value}`, {
|
|
1323
|
+
encoding: "utf8",
|
|
1324
|
+
timeout: DEFAULT_TIMEOUT
|
|
1325
|
+
}).split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
|
|
1326
|
+
} catch {
|
|
1327
|
+
return [];
|
|
546
1328
|
}
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
1329
|
+
}
|
|
1330
|
+
/** Return the raw `docker inspect` payload (object, not array) for a container. */
|
|
1331
|
+
function inspectContainer(id) {
|
|
1332
|
+
const raw = execSync(`docker inspect ${id}`, {
|
|
1333
|
+
encoding: "utf8",
|
|
1334
|
+
timeout: DEFAULT_TIMEOUT
|
|
1335
|
+
});
|
|
1336
|
+
const parsed = JSON.parse(raw);
|
|
1337
|
+
return Array.isArray(parsed) ? parsed[0] : parsed;
|
|
1338
|
+
}
|
|
1339
|
+
/** Force-remove the given container IDs in a single call. Errors are swallowed. */
|
|
1340
|
+
function removeContainers(ids) {
|
|
1341
|
+
if (ids.length === 0) return;
|
|
1342
|
+
try {
|
|
1343
|
+
execSync(`docker rm -f ${ids.join(" ")}`, {
|
|
1344
|
+
encoding: "utf8",
|
|
1345
|
+
stdio: "pipe",
|
|
1346
|
+
timeout: DEFAULT_TIMEOUT
|
|
1347
|
+
});
|
|
1348
|
+
} catch {}
|
|
1349
|
+
}
|
|
1350
|
+
//#endregion
|
|
1351
|
+
//#region src/core/specification/shared/result/result.ts
|
|
1352
|
+
/**
|
|
1353
|
+
* Base result - common accessors available after any action type.
|
|
1354
|
+
* Extended by HttpResult, CliResult, and used directly by job results.
|
|
1355
|
+
*
|
|
1356
|
+
* Every result carries a fresh {@link CaptureScope}: `match.ref()` captures
|
|
1357
|
+
* and `{{type#ref}}` placeholders are scoped to one spec execution.
|
|
1358
|
+
*/
|
|
1359
|
+
var BaseResult = class {
|
|
1360
|
+
/** @internal Ref-capture scope shared by every assertion on this result. */
|
|
1361
|
+
captures;
|
|
1362
|
+
config;
|
|
1363
|
+
testDir;
|
|
1364
|
+
workDir;
|
|
1365
|
+
constructor(options) {
|
|
1366
|
+
this.config = options.config;
|
|
1367
|
+
this.testDir = options.testDir;
|
|
1368
|
+
this.workDir = options.workDir;
|
|
1369
|
+
this.captures = new CaptureScope(options.workDir ? safeRealpath(options.workDir) : void 0);
|
|
551
1370
|
}
|
|
552
1371
|
/** Access a directory (relative to the working directory) for snapshot assertions. */
|
|
553
1372
|
directory(path = ".") {
|
|
554
|
-
return new DirectoryAccessor(resolve(this.workDir ?? this.testDir, path), this.testDir);
|
|
1373
|
+
return new DirectoryAccessor(resolve(this.workDir ?? this.testDir, path), this.testDir, this.captures);
|
|
555
1374
|
}
|
|
556
1375
|
/** Access a single file (relative to the working directory) for content assertions. */
|
|
557
1376
|
file(path) {
|
|
558
1377
|
const resolvedPath = resolve(this.workDir ?? this.testDir, path);
|
|
559
1378
|
const exists = existsSync(resolvedPath);
|
|
1379
|
+
const { captures, testDir } = this;
|
|
560
1380
|
return {
|
|
561
1381
|
get content() {
|
|
562
1382
|
if (!exists) throw new Error(`File not found: ${path}`);
|
|
563
1383
|
return readFileSync(resolvedPath, "utf8");
|
|
564
1384
|
},
|
|
565
|
-
exists
|
|
1385
|
+
exists,
|
|
1386
|
+
grep(pattern) {
|
|
1387
|
+
if (!exists) throw new Error(`File not found: ${path}`);
|
|
1388
|
+
return new TextAccessor(readFileSync(resolvedPath, "utf8"), path, testDir, { captures }).grep(pattern);
|
|
1389
|
+
}
|
|
566
1390
|
};
|
|
567
1391
|
}
|
|
568
|
-
/** Access a database table for row-level assertions. */
|
|
1392
|
+
/** Access a database table for row-level assertions via expect() matchers. */
|
|
569
1393
|
table(tableName, options) {
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
1394
|
+
validateDatabaseOption("table", this.config, options?.database);
|
|
1395
|
+
const db = this.resolveDatabase(options?.database);
|
|
1396
|
+
if (!db) throw new Error(options?.database ? `table("${tableName}") requires database "${options.database}" but it was not found` : `table("${tableName}") requires a database adapter`);
|
|
1397
|
+
return new TableAccessor(tableName, db, this.captures);
|
|
573
1398
|
}
|
|
574
|
-
resolveDatabase(
|
|
575
|
-
if (
|
|
1399
|
+
resolveDatabase(databaseKey) {
|
|
1400
|
+
if (databaseKey && this.config.databases) return this.config.databases.get(databaseKey);
|
|
576
1401
|
return this.config.database;
|
|
577
1402
|
}
|
|
578
1403
|
};
|
|
1404
|
+
function safeRealpath(path) {
|
|
1405
|
+
try {
|
|
1406
|
+
return realpathSync(path);
|
|
1407
|
+
} catch {
|
|
1408
|
+
return path;
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
/**
|
|
1412
|
+
* Enforce CONVENTIONS A7: with two or more databases in the services record
|
|
1413
|
+
* the `database` option is mandatory on `.seed()` / `.table()`; with exactly
|
|
1414
|
+
* one it is forbidden (redundant).
|
|
1415
|
+
*
|
|
1416
|
+
* @internal
|
|
1417
|
+
*/
|
|
1418
|
+
function validateDatabaseOption(method, config, database) {
|
|
1419
|
+
const keys = config.databaseKeys;
|
|
1420
|
+
if (!keys) return;
|
|
1421
|
+
if (keys.length >= 2 && !database) throw new Error(`${method}(): ${keys.length} databases are declared (${keys.map((k) => `"${k}"`).join(", ")}) — pass { database: <key> } to target one of them.`);
|
|
1422
|
+
if (keys.length === 1 && database) throw new Error(`${method}(): redundant database option — "${keys[0]}" is the only declared database.`);
|
|
1423
|
+
}
|
|
579
1424
|
//#endregion
|
|
580
|
-
//#region src/
|
|
1425
|
+
//#region src/core/specification/cli/result.ts
|
|
581
1426
|
/**
|
|
582
|
-
*
|
|
1427
|
+
* Result from a command action (`.exec()`).
|
|
583
1428
|
*
|
|
584
|
-
*
|
|
585
|
-
*
|
|
586
|
-
*
|
|
1429
|
+
* When the runner was configured with a `docker` option, the result also
|
|
1430
|
+
* exposes container accessors and participates in `Symbol.asyncDispose`
|
|
1431
|
+
* cleanup. The Docker shell-outs are **lazy**: a test that never calls
|
|
1432
|
+
* `.container(...)` never queries the Docker daemon, so command-only tests
|
|
1433
|
+
* pay zero Docker cost even when the runner is Docker-aware.
|
|
1434
|
+
*
|
|
1435
|
+
* Dispose always runs one final label-filtered `docker ps` to catch
|
|
1436
|
+
* containers that were spawned during `.exec` but never asserted on —
|
|
1437
|
+
* tests still get leak-free cleanup even if they forget to reach into
|
|
1438
|
+
* a container.
|
|
587
1439
|
*/
|
|
588
|
-
var
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
seeds = [];
|
|
601
|
-
spawnConfig = null;
|
|
602
|
-
testDir;
|
|
603
|
-
constructor(config, testDir, label) {
|
|
604
|
-
this.config = config;
|
|
605
|
-
this.testDir = testDir;
|
|
606
|
-
this.label = label;
|
|
1440
|
+
var CliResult = class extends BaseResult {
|
|
1441
|
+
commandOutput;
|
|
1442
|
+
containersCache = null;
|
|
1443
|
+
dockerConfig;
|
|
1444
|
+
testRunId;
|
|
1445
|
+
transform;
|
|
1446
|
+
constructor(options) {
|
|
1447
|
+
super(options);
|
|
1448
|
+
this.commandOutput = options.commandOutput;
|
|
1449
|
+
this.dockerConfig = options.dockerConfig;
|
|
1450
|
+
this.testRunId = options.testRunId;
|
|
1451
|
+
this.transform = options.transform;
|
|
607
1452
|
}
|
|
608
|
-
/**
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
service: options?.service
|
|
1453
|
+
/** The process exit code. */
|
|
1454
|
+
get exitCode() {
|
|
1455
|
+
return this.commandOutput.exitCode;
|
|
1456
|
+
}
|
|
1457
|
+
/** Accessor for the captured standard output with file-based assertions. */
|
|
1458
|
+
get stdout() {
|
|
1459
|
+
return new TextAccessor(this.commandOutput.stdout, "stdout", this.testDir, {
|
|
1460
|
+
captures: this.captures,
|
|
1461
|
+
transform: this.transform
|
|
618
1462
|
});
|
|
619
|
-
return this;
|
|
620
1463
|
}
|
|
621
|
-
/**
|
|
622
|
-
|
|
623
|
-
this.
|
|
624
|
-
|
|
1464
|
+
/** Accessor for the captured standard error with file-based assertions. */
|
|
1465
|
+
get stderr() {
|
|
1466
|
+
return new TextAccessor(this.commandOutput.stderr, "stderr", this.testDir, {
|
|
1467
|
+
captures: this.captures,
|
|
1468
|
+
transform: this.transform
|
|
1469
|
+
});
|
|
625
1470
|
}
|
|
626
|
-
/**
|
|
627
|
-
|
|
628
|
-
this.
|
|
629
|
-
return this;
|
|
1471
|
+
/** Accessor for parsing stdout as JSON and asserting against JSON fixtures. */
|
|
1472
|
+
get json() {
|
|
1473
|
+
return new JsonAccessor(this.commandOutput.stdout, this.testDir, this.transform, this.captures);
|
|
630
1474
|
}
|
|
631
|
-
/**
|
|
632
|
-
|
|
633
|
-
this.
|
|
634
|
-
return this;
|
|
1475
|
+
/** Accessor for the temporary working directory the command ran in. */
|
|
1476
|
+
get filesystem() {
|
|
1477
|
+
if (!this.workDir) throw new Error("CliResult.filesystem requires a working directory");
|
|
1478
|
+
return new FilesystemAccessor(this.workDir, this.testDir, this.captures);
|
|
635
1479
|
}
|
|
636
1480
|
/**
|
|
637
|
-
*
|
|
638
|
-
*
|
|
639
|
-
*
|
|
640
|
-
*
|
|
641
|
-
*
|
|
1481
|
+
* Look up a container the command spawned during this run by the value of
|
|
1482
|
+
* its name-label (as declared in the `docker.nameLabel` of the
|
|
1483
|
+
* `specification.cli()` options).
|
|
1484
|
+
* First access triggers a one-shot `docker ps` + `docker inspect`
|
|
1485
|
+
* query and caches the result for the rest of the result's lifetime.
|
|
1486
|
+
* Tests that don't call this never touch Docker.
|
|
642
1487
|
*
|
|
643
|
-
*
|
|
644
|
-
*
|
|
1488
|
+
* Returns an accessor with `.exists === false` when no container
|
|
1489
|
+
* carries that name — callers can still assert absence without a try.
|
|
645
1490
|
*/
|
|
646
|
-
|
|
647
|
-
this.
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
return this;
|
|
1491
|
+
container(name) {
|
|
1492
|
+
this.ensureDockerAware("container");
|
|
1493
|
+
this.loadContainers();
|
|
1494
|
+
const captured = this.containersCache.get(name);
|
|
1495
|
+
if (!captured) return new ContainerAccessor(null, null, this.testDir, this.transform);
|
|
1496
|
+
return new ContainerAccessor(captured.id, captured.inspect, this.testDir, this.transform);
|
|
1497
|
+
}
|
|
1498
|
+
/** All captured container IDs. Triggers the same lazy query as `container()`. */
|
|
1499
|
+
get containerIds() {
|
|
1500
|
+
this.ensureDockerAware("containerIds");
|
|
1501
|
+
this.loadContainers();
|
|
1502
|
+
return [...this.containersCache.values()].map((c) => c.id);
|
|
1503
|
+
}
|
|
1504
|
+
async [Symbol.asyncDispose]() {
|
|
1505
|
+
if (!this.dockerConfig || !this.testRunId) return;
|
|
1506
|
+
removeContainers(this.containersCache !== null ? [...this.containersCache.values()].map((c) => c.id) : findContainersByLabel(this.dockerConfig.testRunLabel, this.testRunId));
|
|
1507
|
+
}
|
|
1508
|
+
ensureDockerAware(member) {
|
|
1509
|
+
if (!this.dockerConfig || !this.testRunId) throw new Error(`CliResult.${member}: runner was not configured with a docker option. Pass \`docker: { envVar, nameLabel, testRunLabel }\` in the specification.cli() options.`);
|
|
1510
|
+
}
|
|
1511
|
+
loadContainers() {
|
|
1512
|
+
if (this.containersCache !== null) return;
|
|
1513
|
+
const ids = findContainersByLabel(this.dockerConfig.testRunLabel, this.testRunId);
|
|
1514
|
+
const map = /* @__PURE__ */ new Map();
|
|
1515
|
+
for (const id of ids) {
|
|
1516
|
+
let inspect;
|
|
1517
|
+
try {
|
|
1518
|
+
inspect = inspectContainer(id);
|
|
1519
|
+
} catch {
|
|
1520
|
+
continue;
|
|
1521
|
+
}
|
|
1522
|
+
const nameValue = (inspect?.Config?.Labels ?? inspect?.config?.labels ?? {})[this.dockerConfig.nameLabel];
|
|
1523
|
+
const key = typeof nameValue === "string" && nameValue.length > 0 ? nameValue : id;
|
|
1524
|
+
map.set(key, {
|
|
1525
|
+
id,
|
|
1526
|
+
inspect
|
|
1527
|
+
});
|
|
1528
|
+
}
|
|
1529
|
+
this.containersCache = map;
|
|
652
1530
|
}
|
|
1531
|
+
};
|
|
1532
|
+
//#endregion
|
|
1533
|
+
//#region src/integrations/docker/container-accessor.ts
|
|
1534
|
+
const EXEC_TIMEOUT = 1e4;
|
|
1535
|
+
function readInspectState(inspect) {
|
|
1536
|
+
const state = inspect?.State ?? inspect?.state;
|
|
1537
|
+
if (!state) return {
|
|
1538
|
+
running: false,
|
|
1539
|
+
status: "unknown"
|
|
1540
|
+
};
|
|
1541
|
+
return {
|
|
1542
|
+
running: Boolean(state.Running ?? state.running ?? false),
|
|
1543
|
+
status: String(state.Status ?? state.status ?? "unknown")
|
|
1544
|
+
};
|
|
1545
|
+
}
|
|
1546
|
+
/**
|
|
1547
|
+
* Assertion accessor for a single Docker container captured by the docker()
|
|
1548
|
+
* spec mode. Mirrors the shape of {@link CliResult} so tests use the same
|
|
1549
|
+
* vocabulary (`stdout.toContain`, `file(...).content`, etc.) regardless
|
|
1550
|
+
* of where output came from.
|
|
1551
|
+
*
|
|
1552
|
+
* Sync state (`exists`, `running`, `status`) is derived from the `docker
|
|
1553
|
+
* inspect` payload captured at result-construction time. Logs are fetched
|
|
1554
|
+
* lazily on first access to `stdout`/`stderr`.
|
|
1555
|
+
*/
|
|
1556
|
+
var ContainerAccessor = class {
|
|
1557
|
+
cachedLogs = null;
|
|
1558
|
+
containerId;
|
|
1559
|
+
exists;
|
|
1560
|
+
inspectData;
|
|
1561
|
+
running;
|
|
1562
|
+
status;
|
|
1563
|
+
testDir;
|
|
1564
|
+
transform;
|
|
653
1565
|
/**
|
|
654
|
-
*
|
|
655
|
-
*
|
|
656
|
-
*
|
|
657
|
-
*
|
|
1566
|
+
* Underlying Docker container ID, or `null` if no container was captured
|
|
1567
|
+
* for this name. Useful when a follow-up CLI command needs to reference
|
|
1568
|
+
* the container by id (e.g. `spwn world inspect <id>`). Prefer the other
|
|
1569
|
+
* accessors for common reads — pulling the raw id is the escape hatch.
|
|
658
1570
|
*/
|
|
659
|
-
|
|
660
|
-
this.
|
|
661
|
-
...this.requestHeaders,
|
|
662
|
-
...headers
|
|
663
|
-
};
|
|
664
|
-
return this;
|
|
1571
|
+
get id() {
|
|
1572
|
+
return this.containerId;
|
|
665
1573
|
}
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
* .intercept(openai.agent({...}), 'ingest-tech.json')
|
|
681
|
-
* .intercept(http.get(url), 'world-news-tech.json')
|
|
682
|
-
*/
|
|
683
|
-
intercept(trigger, response) {
|
|
684
|
-
if (typeof response === "string") {
|
|
685
|
-
const slashIndex = response.indexOf("/");
|
|
686
|
-
if (slashIndex === -1) throw new Error(`.intercept(): file path must be 'adapter/filename.json' (e.g. 'openai/ingest-tech.json'), got '${response}'`);
|
|
687
|
-
const adapterName = response.slice(0, slashIndex);
|
|
688
|
-
if (adapterName !== trigger.adapter) throw new Error(`.intercept(): adapter mismatch - trigger uses '${trigger.adapter}' but file path starts with '${adapterName}/'`);
|
|
689
|
-
const filePath = resolve(this.testDir, "intercepts", response);
|
|
690
|
-
const data = JSON.parse(readFileSync(filePath, "utf8"));
|
|
691
|
-
const resolved = trigger.wrap(data);
|
|
692
|
-
this.intercepts.push({
|
|
693
|
-
trigger,
|
|
694
|
-
response: resolved
|
|
695
|
-
});
|
|
696
|
-
} else this.intercepts.push({
|
|
697
|
-
trigger,
|
|
698
|
-
response
|
|
699
|
-
});
|
|
700
|
-
return this;
|
|
1574
|
+
constructor(containerId, inspectData, testDir, transform) {
|
|
1575
|
+
this.containerId = containerId;
|
|
1576
|
+
this.inspectData = inspectData;
|
|
1577
|
+
this.testDir = testDir;
|
|
1578
|
+
this.transform = transform;
|
|
1579
|
+
this.exists = containerId !== null;
|
|
1580
|
+
if (this.exists) {
|
|
1581
|
+
const state = readInspectState(inspectData);
|
|
1582
|
+
this.running = state.running;
|
|
1583
|
+
this.status = state.status;
|
|
1584
|
+
} else {
|
|
1585
|
+
this.running = false;
|
|
1586
|
+
this.status = "absent";
|
|
1587
|
+
}
|
|
701
1588
|
}
|
|
702
1589
|
/**
|
|
703
|
-
*
|
|
704
|
-
*
|
|
705
|
-
* @example
|
|
706
|
-
* spec("list items").get("/api/items").run();
|
|
1590
|
+
* Raw `docker inspect` object for this container. Throws if the container
|
|
1591
|
+
* was not captured (i.e. `.exists === false`).
|
|
707
1592
|
*/
|
|
708
|
-
get(
|
|
709
|
-
this.
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
1593
|
+
get inspect() {
|
|
1594
|
+
this.requireExists("inspect");
|
|
1595
|
+
return new JsonAccessor(JSON.stringify(this.inspectData), this.testDir, this.transform);
|
|
1596
|
+
}
|
|
1597
|
+
/** Captured logs (stdout+stderr combined for v1). */
|
|
1598
|
+
get stdout() {
|
|
1599
|
+
this.requireExists("stdout");
|
|
1600
|
+
return new TextAccessor(this.loadLogs(), "stdout", this.testDir, { transform: this.transform });
|
|
1601
|
+
}
|
|
1602
|
+
/** Captured logs (stdout+stderr combined for v1). */
|
|
1603
|
+
get stderr() {
|
|
1604
|
+
this.requireExists("stderr");
|
|
1605
|
+
return new TextAccessor(this.loadLogs(), "stderr", this.testDir, { transform: this.transform });
|
|
714
1606
|
}
|
|
715
1607
|
/**
|
|
716
|
-
*
|
|
717
|
-
*
|
|
718
|
-
*
|
|
719
|
-
* @example
|
|
720
|
-
* spec("create item").post("/api/items", "new-item.json").run();
|
|
1608
|
+
* Read a file from inside the container via `docker exec cat`. The
|
|
1609
|
+
* returned object satisfies the same {@link FileAccessor} shape used by
|
|
1610
|
+
* the host filesystem accessor, so tests do not need to learn a new API.
|
|
721
1611
|
*/
|
|
722
|
-
|
|
723
|
-
this.
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
return this;
|
|
729
|
-
}
|
|
730
|
-
/** Send a PUT request to the server adapter. */
|
|
731
|
-
put(path, bodyFile) {
|
|
732
|
-
this.request = {
|
|
733
|
-
bodyFile,
|
|
734
|
-
method: "PUT",
|
|
1612
|
+
file(path) {
|
|
1613
|
+
this.requireExists("file");
|
|
1614
|
+
const id = this.containerId;
|
|
1615
|
+
const exists = this.containerExec(id, [
|
|
1616
|
+
"test",
|
|
1617
|
+
"-e",
|
|
735
1618
|
path
|
|
1619
|
+
]).exitCode === 0;
|
|
1620
|
+
const { testDir } = this;
|
|
1621
|
+
const readContent = () => {
|
|
1622
|
+
if (!exists) throw new Error(`File not found in container: ${path}`);
|
|
1623
|
+
return execSync(`docker exec ${id} cat ${JSON.stringify(path)}`, {
|
|
1624
|
+
encoding: "utf8",
|
|
1625
|
+
timeout: EXEC_TIMEOUT
|
|
1626
|
+
});
|
|
736
1627
|
};
|
|
737
|
-
return
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
1628
|
+
return {
|
|
1629
|
+
get content() {
|
|
1630
|
+
return readContent();
|
|
1631
|
+
},
|
|
1632
|
+
exists,
|
|
1633
|
+
grep(pattern) {
|
|
1634
|
+
return new TextAccessor(readContent(), path, testDir).grep(pattern);
|
|
1635
|
+
}
|
|
744
1636
|
};
|
|
745
|
-
return this;
|
|
746
1637
|
}
|
|
747
1638
|
/**
|
|
748
|
-
*
|
|
749
|
-
*
|
|
750
|
-
*
|
|
751
|
-
* @example
|
|
752
|
-
* spec("init project").exec("init --name demo").run();
|
|
753
|
-
* spec("multi-step").exec(["init", "build"]).run();
|
|
1639
|
+
* Run a shell command inside the container and get back the same
|
|
1640
|
+
* {@link CliResult} used for host-side executions.
|
|
754
1641
|
*/
|
|
755
|
-
exec(
|
|
756
|
-
this.
|
|
757
|
-
return
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
1642
|
+
async exec(cmd) {
|
|
1643
|
+
this.requireExists("exec");
|
|
1644
|
+
return new CliResult({
|
|
1645
|
+
commandOutput: this.containerExec(this.containerId, [
|
|
1646
|
+
"sh",
|
|
1647
|
+
"-c",
|
|
1648
|
+
cmd
|
|
1649
|
+
]),
|
|
1650
|
+
config: {},
|
|
1651
|
+
testDir: this.testDir,
|
|
1652
|
+
transform: this.transform,
|
|
1653
|
+
workDir: this.testDir
|
|
1654
|
+
});
|
|
766
1655
|
}
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
*
|
|
770
|
-
* @param name - The job name to trigger (must match a registered JobHandle.name).
|
|
771
|
-
*
|
|
772
|
-
* @example
|
|
773
|
-
* spec('pipeline').intercept(openai.chat(), openai.response({...})).job('report-refresh').run();
|
|
774
|
-
*/
|
|
775
|
-
job(name) {
|
|
776
|
-
this.jobName = name;
|
|
777
|
-
return this;
|
|
1656
|
+
requireExists(member) {
|
|
1657
|
+
if (!this.exists) throw new Error(`ContainerAccessor.${member}: container does not exist (check .exists first)`);
|
|
778
1658
|
}
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
* configured action (HTTP or CLI).
|
|
782
|
-
*
|
|
783
|
-
* @returns The result object used for assertions.
|
|
784
|
-
* @example
|
|
785
|
-
* const result = await spec("test").exec("status").run();
|
|
786
|
-
* expect(result.exitCode).toBe(0);
|
|
787
|
-
*/
|
|
788
|
-
async run() {
|
|
789
|
-
const hasHttpAction = this.request !== null;
|
|
790
|
-
const hasCliAction = this.commandArgs !== null || this.spawnConfig !== null;
|
|
791
|
-
const hasJobAction = this.jobName !== null;
|
|
792
|
-
const actionCount = [
|
|
793
|
-
hasHttpAction,
|
|
794
|
-
hasCliAction,
|
|
795
|
-
hasJobAction
|
|
796
|
-
].filter(Boolean).length;
|
|
797
|
-
if (actionCount === 0) throw new Error(`Specification "${this.label}": no action defined. Call .get(), .post(), .exec(), .job(), etc. before .run()`);
|
|
798
|
-
if (actionCount > 1) throw new Error(`Specification "${this.label}": cannot mix action types (.get/.post, .exec/.spawn, .job)`);
|
|
799
|
-
let workDir = null;
|
|
800
|
-
if (hasCliAction) workDir = this.prepareWorkDir();
|
|
801
|
-
if (this.config.databases) for (const db of this.config.databases.values()) await db.reset();
|
|
802
|
-
else if (this.config.database) await this.config.database.reset();
|
|
803
|
-
for (const entry of this.seeds) {
|
|
804
|
-
let db;
|
|
805
|
-
if (entry.service && this.config.databases) {
|
|
806
|
-
db = this.config.databases.get(entry.service);
|
|
807
|
-
if (!db) throw new Error(`seed() targets database "${entry.service}" but it was not found. Available: ${[...this.config.databases.keys()].join(", ")}`);
|
|
808
|
-
} else db = this.config.database;
|
|
809
|
-
if (!db) throw new Error("seed() requires a database adapter");
|
|
810
|
-
const sql = readFileSync(resolve(this.testDir, "seeds", entry.file), "utf8");
|
|
811
|
-
await db.seed(sql);
|
|
812
|
-
}
|
|
813
|
-
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 });
|
|
814
|
-
let cleanupIntercepts = null;
|
|
815
|
-
if (this.intercepts.length > 0) {
|
|
816
|
-
const { registerIntercepts } = await import("./intercept2.js");
|
|
817
|
-
cleanupIntercepts = await registerIntercepts(this.intercepts);
|
|
818
|
-
}
|
|
1659
|
+
loadLogs() {
|
|
1660
|
+
if (this.cachedLogs !== null) return this.cachedLogs;
|
|
819
1661
|
try {
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
const value = this.commandEnv[key];
|
|
833
|
-
resolved[key] = typeof value === "string" ? value.replace(/\$WORKDIR/g, workDir) : value;
|
|
834
|
-
}
|
|
835
|
-
return resolved;
|
|
836
|
-
}
|
|
837
|
-
prepareWorkDir() {
|
|
838
|
-
const tempDir = mkdtempSync(resolve(tmpdir(), "spec-cli-"));
|
|
839
|
-
if (this.projectName && this.config.fixturesRoot) {
|
|
840
|
-
const projectDir = resolve(this.config.fixturesRoot, this.projectName);
|
|
841
|
-
if (!existsSync(projectDir)) throw new Error(`project("${this.projectName}"): fixture project not found at ${projectDir}`);
|
|
842
|
-
cpSync(projectDir, tempDir, { recursive: true });
|
|
843
|
-
}
|
|
844
|
-
return tempDir;
|
|
845
|
-
}
|
|
846
|
-
async runHttpAction() {
|
|
847
|
-
if (!this.config.server) throw new Error("HTTP actions require a server adapter (use integration() or e2e())");
|
|
848
|
-
let body;
|
|
849
|
-
if (this.request.bodyFile) body = JSON.parse(readFileSync(resolve(this.testDir, "requests", this.request.bodyFile), "utf8"));
|
|
850
|
-
const headers = Object.keys(this.requestHeaders).length > 0 ? this.requestHeaders : void 0;
|
|
851
|
-
const response = await this.config.server.request(this.request.method, this.request.path, body, headers);
|
|
852
|
-
return new SpecificationResult({
|
|
853
|
-
config: this.config,
|
|
854
|
-
requestInfo: {
|
|
855
|
-
body,
|
|
856
|
-
method: this.request.method,
|
|
857
|
-
path: this.request.path
|
|
858
|
-
},
|
|
859
|
-
response,
|
|
860
|
-
testDir: this.testDir
|
|
861
|
-
});
|
|
862
|
-
}
|
|
863
|
-
async runJobAction() {
|
|
864
|
-
if (!this.config.jobs?.length) throw new Error("Job actions require jobs registered via app(() => ({ server, jobs }))");
|
|
865
|
-
const job = this.config.jobs.find((j) => j.name === this.jobName);
|
|
866
|
-
if (!job) {
|
|
867
|
-
const available = this.config.jobs.map((j) => j.name).join(", ");
|
|
868
|
-
throw new Error(`job("${this.jobName}"): not found. Available: ${available}`);
|
|
1662
|
+
const out = execSync(`docker logs ${this.containerId}`, {
|
|
1663
|
+
encoding: "utf8",
|
|
1664
|
+
stdio: [
|
|
1665
|
+
"ignore",
|
|
1666
|
+
"pipe",
|
|
1667
|
+
"pipe"
|
|
1668
|
+
],
|
|
1669
|
+
timeout: EXEC_TIMEOUT
|
|
1670
|
+
});
|
|
1671
|
+
this.cachedLogs = out;
|
|
1672
|
+
} catch (error) {
|
|
1673
|
+
this.cachedLogs = typeof error?.stdout === "string" ? error.stdout : String(error?.stderr ?? "");
|
|
869
1674
|
}
|
|
870
|
-
|
|
871
|
-
return new SpecificationResult({
|
|
872
|
-
config: this.config,
|
|
873
|
-
testDir: this.testDir
|
|
874
|
-
});
|
|
1675
|
+
return this.cachedLogs ?? "";
|
|
875
1676
|
}
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
let commandResult;
|
|
880
|
-
if (this.spawnConfig) commandResult = await this.config.command.spawn(this.spawnConfig.args, workDir, this.spawnConfig.options, env);
|
|
881
|
-
else if (Array.isArray(this.commandArgs)) {
|
|
882
|
-
commandResult = {
|
|
1677
|
+
containerExec(id, argv) {
|
|
1678
|
+
try {
|
|
1679
|
+
return {
|
|
883
1680
|
exitCode: 0,
|
|
884
1681
|
stderr: "",
|
|
885
|
-
stdout: ""
|
|
1682
|
+
stdout: execSync(`docker exec ${id} ${argv.map((a) => JSON.stringify(a)).join(" ")}`, {
|
|
1683
|
+
encoding: "utf8",
|
|
1684
|
+
stdio: [
|
|
1685
|
+
"ignore",
|
|
1686
|
+
"pipe",
|
|
1687
|
+
"pipe"
|
|
1688
|
+
],
|
|
1689
|
+
timeout: EXEC_TIMEOUT
|
|
1690
|
+
})
|
|
886
1691
|
};
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
config: this.config,
|
|
895
|
-
testDir: this.testDir,
|
|
896
|
-
workDir
|
|
897
|
-
});
|
|
1692
|
+
} catch (error) {
|
|
1693
|
+
return {
|
|
1694
|
+
exitCode: typeof error?.status === "number" ? error.status : 1,
|
|
1695
|
+
stderr: typeof error?.stderr === "string" ? error.stderr : String(error?.message ?? ""),
|
|
1696
|
+
stdout: typeof error?.stdout === "string" ? error.stdout : ""
|
|
1697
|
+
};
|
|
1698
|
+
}
|
|
898
1699
|
}
|
|
899
1700
|
};
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
return resolve(filePath, "..");
|
|
911
|
-
}
|
|
912
|
-
throw new Error("Cannot detect caller directory from stack trace");
|
|
1701
|
+
//#endregion
|
|
1702
|
+
//#region src/vitest/update.ts
|
|
1703
|
+
/**
|
|
1704
|
+
* Snapshot update-mode detection. `TEST_UPDATE=1` (framework convention, see
|
|
1705
|
+
* CONVENTIONS E1) or vitest's `-u` / `--update` flag turn fixture mismatches
|
|
1706
|
+
* into fixture rewrites.
|
|
1707
|
+
*/
|
|
1708
|
+
function shouldUpdateSnapshots() {
|
|
1709
|
+
if (process.env.TEST_UPDATE === "1") return true;
|
|
1710
|
+
return process.argv.includes("-u") || process.argv.includes("--update");
|
|
913
1711
|
}
|
|
1712
|
+
/** Standard hint appended to missing-fixture errors. */
|
|
1713
|
+
const UPDATE_HINT = "Run with TEST_UPDATE=1 (or vitest -u) to create it.";
|
|
1714
|
+
//#endregion
|
|
1715
|
+
//#region src/vitest/matchers.ts
|
|
914
1716
|
/**
|
|
915
|
-
*
|
|
916
|
-
*
|
|
1717
|
+
* Vitest custom matchers for `@jterrazz/test` accessors.
|
|
1718
|
+
*
|
|
1719
|
+
* Auto-registered (idempotently) on the first `specification.api()` /
|
|
1720
|
+
* `specification.jobs()` / `specification.cli()` call via a dynamic
|
|
1721
|
+
* `import('vitest')` — the library never hard-imports vitest at module load.
|
|
1722
|
+
*
|
|
1723
|
+
* All assertions go through `expect()` (CONVENTIONS D1). Only matchers that
|
|
1724
|
+
* do IO are async (D2): `table` (SQL query), `filesystem`/`directory`
|
|
1725
|
+
* (disk walk), `container` (docker). Everything else is synchronous.
|
|
917
1726
|
*/
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
1727
|
+
const PASS = (label) => ({
|
|
1728
|
+
message: () => label,
|
|
1729
|
+
pass: true
|
|
1730
|
+
});
|
|
1731
|
+
const FAIL = (message) => ({
|
|
1732
|
+
message: () => message,
|
|
1733
|
+
pass: false
|
|
1734
|
+
});
|
|
1735
|
+
function requireExtension(name, subject) {
|
|
1736
|
+
if (!/\.[A-Za-z0-9]+$/.test(name)) throw new Error(`toMatch("${name}"): the extension is part of the name and is required for ${subject} subjects (e.g. "help.txt").`);
|
|
1737
|
+
}
|
|
1738
|
+
function formatJson(value) {
|
|
1739
|
+
return `${JSON.stringify(value, null, 4)}\n`;
|
|
1740
|
+
}
|
|
1741
|
+
function matchStreamFile(accessor, name, frozen) {
|
|
1742
|
+
requireExtension(name, "stream");
|
|
1743
|
+
const filePath = resolve(accessor.testDir, "expected", name);
|
|
1744
|
+
const actual = accessor.comparableText;
|
|
1745
|
+
if (shouldUpdateSnapshots() && !frozen) {
|
|
1746
|
+
const merged = mergeTextPreservingPlaceholders(existsSync(filePath) ? readFileSync(filePath, "utf8") : null, actual, accessor.captures);
|
|
1747
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
1748
|
+
writeFileSync(filePath, merged);
|
|
1749
|
+
return PASS(`updated expected/${name}`);
|
|
1750
|
+
}
|
|
1751
|
+
if (!existsSync(filePath)) return FAIL(`${accessor.streamName} fixture "${name}" does not exist at ${filePath}.\n${UPDATE_HINT}`);
|
|
1752
|
+
const expected = readFileSync(filePath, "utf8");
|
|
1753
|
+
if (textEquals(expected, actual, accessor.captures)) return PASS(`expected ${accessor.streamName} not to match expected/${name}`);
|
|
1754
|
+
return FAIL(formatStdoutDiff(name, expected, actual));
|
|
1755
|
+
}
|
|
1756
|
+
function matchJsonFile(accessor, name, frozen) {
|
|
1757
|
+
requireExtension(name, "json");
|
|
1758
|
+
const filePath = resolve(accessor.testDir, "expected", name);
|
|
1759
|
+
const actual = accessor.value;
|
|
1760
|
+
if (shouldUpdateSnapshots() && !frozen) {
|
|
1761
|
+
const merged = existsSync(filePath) ? mergePreservingPlaceholders(JSON.parse(readFileSync(filePath, "utf8")), actual, accessor.captures.workdir) : mergePreservingPlaceholders(null, actual, accessor.captures.workdir);
|
|
1762
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
1763
|
+
writeFileSync(filePath, formatJson(merged));
|
|
1764
|
+
return PASS(`updated expected/${name}`);
|
|
1765
|
+
}
|
|
1766
|
+
if (!existsSync(filePath)) return FAIL(`JSON fixture "${name}" does not exist at ${filePath}.\n${UPDATE_HINT}`);
|
|
1767
|
+
const expected = JSON.parse(readFileSync(filePath, "utf8"));
|
|
1768
|
+
if (structuralEquals(expected, actual, accessor.captures)) return PASS(`expected JSON not to match expected/${name}`);
|
|
1769
|
+
return FAIL(formatResponseDiff(name, renderExpected(expected), actual));
|
|
1770
|
+
}
|
|
1771
|
+
/**
|
|
1772
|
+
* Build the updated `expected/*.http` fixture content from the previous
|
|
1773
|
+
* fixture and the actual response (CONVENTIONS D5). Headers are the
|
|
1774
|
+
* INTERSECTION with the actual response: placeholders still matching are
|
|
1775
|
+
* preserved, stale values are replaced, headers absent from the actual
|
|
1776
|
+
* response are dropped — so a freshly updated fixture passes the next run.
|
|
1777
|
+
*
|
|
1778
|
+
* @internal Exported for unit tests.
|
|
1779
|
+
*/
|
|
1780
|
+
function buildUpdatedResponse(previous, actual, workdir) {
|
|
1781
|
+
const scope = new CaptureScope();
|
|
1782
|
+
const status = previous && structuralEquals(previous.status, String(actual.status), scope) ? previous.status : String(actual.status);
|
|
1783
|
+
const headers = {};
|
|
1784
|
+
if (previous) for (const [key, value] of Object.entries(previous.headers)) {
|
|
1785
|
+
const actualValue = actual.headers[key.toLowerCase()];
|
|
1786
|
+
if (actualValue === void 0) continue;
|
|
1787
|
+
headers[key] = structuralEquals(value, actualValue, scope) ? value : actualValue;
|
|
1788
|
+
}
|
|
1789
|
+
else if (actual.headers["content-type"]) headers["content-type"] = actual.headers["content-type"];
|
|
1790
|
+
const hasBody = actual.body !== null && actual.body !== void 0;
|
|
1791
|
+
return {
|
|
1792
|
+
body: previous?.hasBody && hasBody ? mergePreservingPlaceholders(previous.body, actual.body, workdir) : mergePreservingPlaceholders(null, actual.body, workdir),
|
|
1793
|
+
hasBody,
|
|
1794
|
+
headers,
|
|
1795
|
+
status
|
|
921
1796
|
};
|
|
922
1797
|
}
|
|
923
|
-
//#endregion
|
|
924
|
-
//#region src/docker/docker-adapter.ts
|
|
925
1798
|
/**
|
|
926
|
-
*
|
|
927
|
-
*
|
|
1799
|
+
* Compare a parsed `expected/*.http` fixture against an actual response.
|
|
1800
|
+
* Returns the failure message, or null when everything matches.
|
|
1801
|
+
*
|
|
1802
|
+
* @internal Exported for unit tests.
|
|
928
1803
|
*/
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
1804
|
+
function compareResponse(name, expected, actual, scope) {
|
|
1805
|
+
if (!(/^\d+$/.test(expected.status) ? Number(expected.status) === actual.status : structuralEquals(expected.status, String(actual.status), scope))) return `Response status mismatch (${name})\n expected: ${expected.status}\n received: ${actual.status}`;
|
|
1806
|
+
for (const [key, value] of Object.entries(expected.headers)) {
|
|
1807
|
+
const actualValue = actual.headers[key.toLowerCase()];
|
|
1808
|
+
if (actualValue === void 0 || !structuralEquals(value, actualValue, scope)) return `Response header mismatch (${name})\n header: ${key}\n expected: ${value}\n received: ${actualValue ?? "(absent)"}`;
|
|
933
1809
|
}
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
1810
|
+
if (expected.hasBody && !structuralEquals(expected.body, actual.body, scope)) return formatResponseDiff(name, renderExpected(expected.body), actual.body);
|
|
1811
|
+
return null;
|
|
1812
|
+
}
|
|
1813
|
+
function matchResponseFile(accessor, name, frozen) {
|
|
1814
|
+
requireExtension(name, "response");
|
|
1815
|
+
const filePath = resolve(accessor.testDir, "expected", name);
|
|
1816
|
+
if (shouldUpdateSnapshots() && !frozen) {
|
|
1817
|
+
const updated = buildUpdatedResponse(existsSync(filePath) ? parseResponseFile(readFileSync(filePath, "utf8"), `expected/${name}`) : null, accessor, accessor.captures.workdir);
|
|
1818
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
1819
|
+
writeFileSync(filePath, serializeResponseFile(updated));
|
|
1820
|
+
return PASS(`updated expected/${name}`);
|
|
1821
|
+
}
|
|
1822
|
+
if (!existsSync(filePath)) return FAIL(`Response fixture "${name}" does not exist at ${filePath}.\n${UPDATE_HINT}`);
|
|
1823
|
+
const failure = compareResponse(name, parseResponseFile(readFileSync(filePath, "utf8"), `expected/${name}`), accessor, accessor.captures);
|
|
1824
|
+
return failure === null ? PASS(`expected response not to match expected/${name}`) : FAIL(failure);
|
|
1825
|
+
}
|
|
1826
|
+
async function matchTreeFile(actualRoot, testDir, name, scope, frozen) {
|
|
1827
|
+
const fixtureDir = resolve(testDir, "expected", name);
|
|
1828
|
+
if (shouldUpdateSnapshots() && !frozen) {
|
|
1829
|
+
const previousContents = /* @__PURE__ */ new Map();
|
|
1830
|
+
if (existsSync(fixtureDir)) for (const file of await walkDirectory(fixtureDir)) previousContents.set(file, readFileSync(resolve(fixtureDir, file), "utf8"));
|
|
1831
|
+
rmSync(fixtureDir, {
|
|
1832
|
+
force: true,
|
|
1833
|
+
recursive: true
|
|
1834
|
+
});
|
|
1835
|
+
mkdirSync(fixtureDir, { recursive: true });
|
|
1836
|
+
cpSync(actualRoot, fixtureDir, { recursive: true });
|
|
1837
|
+
for (const file of await walkDirectory(fixtureDir)) {
|
|
1838
|
+
const previous = previousContents.get(file) ?? null;
|
|
1839
|
+
const actual = readFileSync(resolve(fixtureDir, file), "utf8");
|
|
1840
|
+
const merged = mergeTextPreservingPlaceholders(previous, actual, scope);
|
|
1841
|
+
if (merged !== actual) writeFileSync(resolve(fixtureDir, file), merged);
|
|
951
1842
|
}
|
|
1843
|
+
return PASS(`updated expected/${name}/`);
|
|
952
1844
|
}
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
1845
|
+
if (!existsSync(fixtureDir)) return FAIL(`Directory fixture "${name}" does not exist at ${fixtureDir}.\n${UPDATE_HINT}`);
|
|
1846
|
+
const diff = await diffDirectories(fixtureDir, actualRoot, { scope });
|
|
1847
|
+
if (diff.added.length === 0 && diff.removed.length === 0 && diff.changed.length === 0) return PASS(`expected directory not to match expected/${name}/`);
|
|
1848
|
+
return FAIL(formatDirectoryDiff(name, diff, "Run with TEST_UPDATE=1 to update the fixture."));
|
|
1849
|
+
}
|
|
1850
|
+
/**
|
|
1851
|
+
* Guard the accessor `toMatch` subjects: their argument is a fixture NAME, never
|
|
1852
|
+
* a regex or other value. A `RegExp` (the natural instinct, since vitest-native
|
|
1853
|
+
* `toMatch` takes one) must fail loudly with the escape hatch, not silently trip
|
|
1854
|
+
* the extension check or be coerced to `"/re/"`.
|
|
1855
|
+
*/
|
|
1856
|
+
function requireFixtureName(name, subjectKind) {
|
|
1857
|
+
if (typeof name !== "string") {
|
|
1858
|
+
const got = name instanceof RegExp ? "a regular expression" : `a ${typeof name}`;
|
|
1859
|
+
throw new TypeError(`toMatch on accessors takes a fixture name (extension included) — the ${subjectKind} subject received ${got}. For a regex, assert on the raw text instead: use expect(x.text).toMatch(/re/) for regex matching.`);
|
|
962
1860
|
}
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
1861
|
+
}
|
|
1862
|
+
function toMatch(received, expected, options) {
|
|
1863
|
+
const frozen = options?.frozen === true;
|
|
1864
|
+
if (received instanceof TextAccessor) {
|
|
1865
|
+
requireFixtureName(expected, "stream");
|
|
1866
|
+
return matchStreamFile(received, expected, frozen);
|
|
1867
|
+
}
|
|
1868
|
+
if (received instanceof JsonAccessor) {
|
|
1869
|
+
requireFixtureName(expected, "json");
|
|
1870
|
+
return matchJsonFile(received, expected, frozen);
|
|
1871
|
+
}
|
|
1872
|
+
if (received instanceof ResponseAccessor) {
|
|
1873
|
+
requireFixtureName(expected, "response");
|
|
1874
|
+
return matchResponseFile(received, expected, frozen);
|
|
1875
|
+
}
|
|
1876
|
+
if (received instanceof FilesystemAccessor) {
|
|
1877
|
+
requireFixtureName(expected, "filesystem");
|
|
1878
|
+
return matchTreeFile(received.cwd, received.testDir, expected, received.captures, frozen);
|
|
1879
|
+
}
|
|
1880
|
+
if (received instanceof DirectoryAccessor) {
|
|
1881
|
+
requireFixtureName(expected, "directory");
|
|
1882
|
+
return matchTreeFile(received.root, received.testDir, expected, received.captures, frozen);
|
|
1883
|
+
}
|
|
1884
|
+
if (typeof received === "string") {
|
|
1885
|
+
const pass = expected instanceof RegExp ? expected.test(received) : received.includes(String(expected));
|
|
1886
|
+
return {
|
|
1887
|
+
message: () => `expected ${JSON.stringify(received)} ${pass ? "not " : ""}to match ${String(expected)}`,
|
|
1888
|
+
pass
|
|
1889
|
+
};
|
|
968
1890
|
}
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
const
|
|
1891
|
+
throw new TypeError("toMatch: unsupported subject — expected a stream, json, response, filesystem, or directory accessor, or a string.");
|
|
1892
|
+
}
|
|
1893
|
+
function toContain(received, expected) {
|
|
1894
|
+
if (received instanceof TextAccessor) {
|
|
1895
|
+
const actual = received.comparableText;
|
|
1896
|
+
const text = String(expected);
|
|
1897
|
+
const pass = actual.includes(text);
|
|
975
1898
|
return {
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
state: {
|
|
979
|
-
running: data.State.Running,
|
|
980
|
-
exitCode: data.State.ExitCode,
|
|
981
|
-
status: data.State.Status
|
|
982
|
-
},
|
|
983
|
-
config: {
|
|
984
|
-
image: data.Config.Image,
|
|
985
|
-
env: data.Config.Env || []
|
|
986
|
-
},
|
|
987
|
-
hostConfig: {
|
|
988
|
-
memory: data.HostConfig.Memory || 0,
|
|
989
|
-
cpuQuota: data.HostConfig.CpuQuota || 0,
|
|
990
|
-
networkMode: data.HostConfig.NetworkMode || "",
|
|
991
|
-
mounts: (data.Mounts || []).map((m) => ({
|
|
992
|
-
source: m.Source,
|
|
993
|
-
destination: m.Destination,
|
|
994
|
-
type: m.Type
|
|
995
|
-
}))
|
|
996
|
-
},
|
|
997
|
-
networkSettings: { networks: Object.fromEntries(Object.entries(data.NetworkSettings.Networks || {}).map(([name, net]) => [name, {
|
|
998
|
-
gateway: net.Gateway,
|
|
999
|
-
ipAddress: net.IPAddress
|
|
1000
|
-
}])) }
|
|
1899
|
+
message: () => pass ? `expected ${received.streamName} not to contain ${JSON.stringify(text)}` : `${received.streamName} does not contain expected substring.\n expected to contain: ${JSON.stringify(text)}\n actual: ${JSON.stringify(actual.length > 500 ? `${actual.slice(0, 500)}…` : actual)}`,
|
|
1900
|
+
pass
|
|
1001
1901
|
};
|
|
1002
1902
|
}
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1903
|
+
if (typeof received === "string") {
|
|
1904
|
+
const pass = received.includes(String(expected));
|
|
1905
|
+
return {
|
|
1906
|
+
message: () => `expected ${JSON.stringify(received)} ${pass ? "not " : ""}to contain ${JSON.stringify(expected)}`,
|
|
1907
|
+
pass
|
|
1908
|
+
};
|
|
1909
|
+
}
|
|
1910
|
+
if (received != null && typeof received[Symbol.iterator] === "function") {
|
|
1911
|
+
const pass = [...received].includes(expected);
|
|
1912
|
+
return {
|
|
1913
|
+
message: () => `expected iterable ${pass ? "not " : ""}to contain ${JSON.stringify(expected)}`,
|
|
1914
|
+
pass
|
|
1915
|
+
};
|
|
1916
|
+
}
|
|
1917
|
+
throw new TypeError(`toContain: unsupported subject of type ${typeof received} — expected a stream accessor, string, or iterable.`);
|
|
1918
|
+
}
|
|
1919
|
+
async function toMatchRows(received, expected) {
|
|
1920
|
+
if (!(received instanceof TableAccessor)) throw new TypeError("toMatchRows: unsupported subject — expected result.table(...).");
|
|
1921
|
+
const actual = await received.query(expected.columns);
|
|
1922
|
+
const pass = actual.length === expected.rows.length && expected.rows.every((row, i) => row.length === actual[i].length && row.every((cell, j) => structuralEquals(cell, actual[i][j], received.captures)));
|
|
1923
|
+
return {
|
|
1924
|
+
message: () => pass ? `expected table "${received.name}" not to match the given rows` : formatTableDiff(received.name, expected.columns, expected.rows.map((row) => row.map((cell) => renderExpected(cell))), actual),
|
|
1925
|
+
pass
|
|
1926
|
+
};
|
|
1927
|
+
}
|
|
1928
|
+
async function toBeEmpty(received) {
|
|
1929
|
+
if (!(received instanceof TableAccessor)) throw new TypeError("toBeEmpty: unsupported subject — expected result.table(...).");
|
|
1930
|
+
const rows = await received.query(["*"]);
|
|
1931
|
+
const pass = rows.length === 0;
|
|
1932
|
+
return {
|
|
1933
|
+
message: () => pass ? `expected table "${received.name}" not to be empty` : `Expected table "${received.name}" to be empty, but it has ${rows.length} row${rows.length === 1 ? "" : "s"}`,
|
|
1934
|
+
pass
|
|
1935
|
+
};
|
|
1936
|
+
}
|
|
1937
|
+
async function toBeRunning(received) {
|
|
1938
|
+
if (!(received instanceof ContainerAccessor)) throw new TypeError("toBeRunning: unsupported subject — expected a container accessor (result.container(...) or spec.docker(...)).");
|
|
1939
|
+
const pass = received.running;
|
|
1940
|
+
return {
|
|
1941
|
+
message: () => {
|
|
1942
|
+
if (pass) return "expected container not to be running";
|
|
1943
|
+
if (received.exists) return `Expected container to be running, but its status is "${received.status}"`;
|
|
1944
|
+
return "Expected container to be running, but it does not exist";
|
|
1945
|
+
},
|
|
1946
|
+
pass
|
|
1947
|
+
};
|
|
1948
|
+
}
|
|
1949
|
+
const REGISTERED = Symbol.for("@jterrazz/test:matchers-registered");
|
|
1950
|
+
/**
|
|
1951
|
+
* Register the custom matchers with vitest's `expect`. Idempotent — safe to
|
|
1952
|
+
* call from every `specification.*` constructor. A missing vitest peer is
|
|
1953
|
+
* tolerated (the library can be imported outside a vitest run).
|
|
1954
|
+
*/
|
|
1955
|
+
async function registerMatchers() {
|
|
1956
|
+
const globals = globalThis;
|
|
1957
|
+
if (globals[REGISTERED]) return;
|
|
1958
|
+
globals[REGISTERED] = true;
|
|
1959
|
+
try {
|
|
1960
|
+
const { expect } = await import("vitest");
|
|
1961
|
+
expect.extend({
|
|
1962
|
+
toBeEmpty,
|
|
1963
|
+
toBeRunning,
|
|
1964
|
+
toContain,
|
|
1965
|
+
toMatch,
|
|
1966
|
+
toMatchRows
|
|
1967
|
+
});
|
|
1968
|
+
} catch {
|
|
1969
|
+
globals[REGISTERED] = false;
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
//#endregion
|
|
1973
|
+
//#region src/core/specification/api/result.ts
|
|
1974
|
+
/** Result from an HTTP action (.request(), .get(), .post(), .put(), .delete()). */
|
|
1975
|
+
var HttpResult = class extends BaseResult {
|
|
1976
|
+
responseData;
|
|
1977
|
+
constructor(options) {
|
|
1978
|
+
super(options);
|
|
1979
|
+
this.responseData = options.response;
|
|
1980
|
+
}
|
|
1981
|
+
/** Access the HTTP response (status, headers, body) for assertions. */
|
|
1982
|
+
get response() {
|
|
1983
|
+
return new ResponseAccessor(this.responseData, this.testDir, this.captures);
|
|
1984
|
+
}
|
|
1985
|
+
/** The HTTP response status code. */
|
|
1986
|
+
get status() {
|
|
1987
|
+
return this.responseData.status;
|
|
1014
1988
|
}
|
|
1015
1989
|
};
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1990
|
+
//#endregion
|
|
1991
|
+
//#region src/core/specification/shared/binding.ts
|
|
1992
|
+
/**
|
|
1993
|
+
* Compose-binding resolution and the case conversions it shares with the
|
|
1994
|
+
* automatic env injection (CONVENTIONS A6 / B6).
|
|
1995
|
+
*
|
|
1996
|
+
* A services-record key is written in the natural TypeScript style
|
|
1997
|
+
* (`analyticsDb`), while a compose service name is kebab-case
|
|
1998
|
+
* (`analytics-db`). These helpers bridge the two vocabularies deterministically
|
|
1999
|
+
* so the record key stays the single source of truth and `composeService`
|
|
2000
|
+
* remains a rare escape hatch.
|
|
2001
|
+
*/
|
|
2002
|
+
/**
|
|
2003
|
+
* Convert a record key to its kebab-case form: camelCase boundaries become
|
|
2004
|
+
* `-`, everything is lowercased. `analyticsDb` → `analytics-db`, `db` → `db`.
|
|
2005
|
+
*/
|
|
2006
|
+
function toKebabCase(key) {
|
|
2007
|
+
return key.replace(/(?<lower>[a-z0-9])(?<upper>[A-Z])/g, "$<lower>-$<upper>").toLowerCase();
|
|
2008
|
+
}
|
|
2009
|
+
/**
|
|
2010
|
+
* Convert a record key to CONSTANT_CASE for env injection: insert `_` at
|
|
2011
|
+
* camelCase boundaries, map any remaining non-alphanumeric run to `_`, then
|
|
2012
|
+
* uppercase. `analyticsDb` → `ANALYTICS_DB`, `db-main` → `DB_MAIN`, `db` → `DB`.
|
|
2013
|
+
*/
|
|
2014
|
+
function toConstantCase(key) {
|
|
2015
|
+
return key.replace(/(?<lower>[a-z0-9])(?<upper>[A-Z])/g, "$<lower>_$<upper>").replace(/[^A-Za-z0-9]/g, "_").toUpperCase();
|
|
2016
|
+
}
|
|
2017
|
+
/**
|
|
2018
|
+
* Resolve the compose service a record key binds to (CONVENTIONS A6).
|
|
2019
|
+
*
|
|
2020
|
+
* Order:
|
|
2021
|
+
* 1. An explicit `composeService` (`explicitComposeName`) always wins — the
|
|
2022
|
+
* escape hatch for non-derivable names.
|
|
2023
|
+
* 2. Otherwise the key binds to the compose service named exactly like it,
|
|
2024
|
+
* else to the kebab-case conversion of the key.
|
|
2025
|
+
* 3. If BOTH the exact key and its kebab-case form name a service in the
|
|
2026
|
+
* compose file, the binding is ambiguous → throw (Lint runtime).
|
|
2027
|
+
* 4. If neither is present, fall back to the key itself (single-word keys and
|
|
2028
|
+
* runs without a compose file stay unchanged).
|
|
2029
|
+
*/
|
|
2030
|
+
function resolveComposeBinding(key, explicitComposeName, serviceNames) {
|
|
2031
|
+
if (explicitComposeName !== null) return explicitComposeName;
|
|
2032
|
+
const kebab = toKebabCase(key);
|
|
2033
|
+
const exactExists = serviceNames.includes(key);
|
|
2034
|
+
const kebabExists = kebab !== key && serviceNames.includes(kebab);
|
|
2035
|
+
if (exactExists && kebabExists) throw new Error(`Ambiguous compose binding for service key "${key}": the compose file declares both "${key}" and "${kebab}". Rename one service, or set composeService explicitly on the handle to pick the intended one.`);
|
|
2036
|
+
if (kebabExists) return kebab;
|
|
2037
|
+
return key;
|
|
1019
2038
|
}
|
|
1020
2039
|
//#endregion
|
|
1021
|
-
//#region src/
|
|
2040
|
+
//#region src/core/specification/shared/fixtures.ts
|
|
1022
2041
|
/**
|
|
1023
|
-
*
|
|
1024
|
-
*
|
|
2042
|
+
* Fixture path resolution + copy semantics for the `cli` facet's `.fixture()`.
|
|
2043
|
+
*
|
|
2044
|
+
* A fixture path is one of two shapes:
|
|
2045
|
+
*
|
|
2046
|
+
* - `$FIXTURES/<rest>` — the shared pool at `<specs-root>/fixtures/<rest>`,
|
|
2047
|
+
* where `<specs-root>` is the nearest ancestor directory named `specs`.
|
|
2048
|
+
* - `<path>` (no marker) — feature-local, at `<test-dir>/fixtures/<path>`.
|
|
2049
|
+
*
|
|
2050
|
+
* Any other `$`-prefixed marker is a usage error (listed against the known
|
|
2051
|
+
* markers). Copy semantics mirror rsync's trailing-slash rule (see
|
|
2052
|
+
* {@link copyPlan}).
|
|
2053
|
+
*/
|
|
2054
|
+
/**
|
|
2055
|
+
* Markers understood in a `.fixture()` path. Extend here + in
|
|
2056
|
+
* {@link resolveFixtureSource}. Exported so the lint layer (rule
|
|
2057
|
+
* `jterrazz/b2-known-fixture-marker`) validates literals against the same list.
|
|
2058
|
+
*/
|
|
2059
|
+
const KNOWN_FIXTURE_MARKERS = ["$FIXTURES"];
|
|
2060
|
+
/**
|
|
2061
|
+
* Walk up from `startDir` to the nearest ancestor directory named `specs`.
|
|
2062
|
+
* Throws with guidance if none is found up to the filesystem root — the
|
|
2063
|
+
* `$FIXTURES` marker is meaningless without a specs root.
|
|
1025
2064
|
*/
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
2065
|
+
function discoverSpecsRoot(startDir) {
|
|
2066
|
+
let dir = startDir;
|
|
2067
|
+
for (;;) {
|
|
2068
|
+
if (basename(dir) === "specs") return dir;
|
|
2069
|
+
const parent = dirname(dir);
|
|
2070
|
+
if (parent === dir) break;
|
|
2071
|
+
dir = parent;
|
|
2072
|
+
}
|
|
2073
|
+
throw new Error(`.fixture(): the $FIXTURES marker resolves to <specs-root>/fixtures, but no directory named "specs" was found walking up from ${startDir}. Move the specification under a specs/ directory, or use a feature-local fixtures/ path (no $FIXTURES marker).`);
|
|
2074
|
+
}
|
|
2075
|
+
/**
|
|
2076
|
+
* Resolve the absolute source path of a fixture, applying marker resolution.
|
|
2077
|
+
* The trailing slash (spread semantics) is preserved by callers via
|
|
2078
|
+
* {@link copyPlan} — it is irrelevant to source resolution.
|
|
2079
|
+
*/
|
|
2080
|
+
function resolveFixtureSource(path, testDir) {
|
|
2081
|
+
const clean = path.replace(/\/+$/, "");
|
|
2082
|
+
if (clean === "$FIXTURES" || path.startsWith("$FIXTURES/")) {
|
|
2083
|
+
const rest = clean.slice(9).replace(/^\/+/, "");
|
|
2084
|
+
return resolve(discoverSpecsRoot(testDir), "fixtures", rest);
|
|
1030
2085
|
}
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
return this;
|
|
2086
|
+
if (path.startsWith("$")) {
|
|
2087
|
+
const marker = clean.split("/")[0];
|
|
2088
|
+
throw new Error(`.fixture("${path}"): unknown marker "${marker}". Known markers: ${KNOWN_FIXTURE_MARKERS.join(", ")}. A path without a marker is feature-local (resolved under <test-dir>/fixtures/).`);
|
|
1035
2089
|
}
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
2090
|
+
return resolve(testDir, "fixtures", clean);
|
|
2091
|
+
}
|
|
2092
|
+
/**
|
|
2093
|
+
* Compute the source + destination for copying a fixture into the working
|
|
2094
|
+
* directory. Copy semantics follow rsync's trailing-slash rule:
|
|
2095
|
+
*
|
|
2096
|
+
* - `dir/` (trailing slash) — the CONTENTS are spread into the cwd.
|
|
2097
|
+
* - `dir` (no slash, a directory) — the directory is copied as `<cwd>/<basename>`.
|
|
2098
|
+
* - `file` — copied as `<cwd>/<basename>`.
|
|
2099
|
+
*
|
|
2100
|
+
* Chained `.fixture()` calls layer in order: a later fixture overwrites files
|
|
2101
|
+
* written by an earlier one.
|
|
2102
|
+
*/
|
|
2103
|
+
function copyPlan(path, testDir, workDir) {
|
|
2104
|
+
const src = resolveFixtureSource(path, testDir);
|
|
2105
|
+
return {
|
|
2106
|
+
dest: /\/+$/.test(path) ? workDir : resolve(workDir, basename(src)),
|
|
2107
|
+
src
|
|
2108
|
+
};
|
|
2109
|
+
}
|
|
2110
|
+
//#endregion
|
|
2111
|
+
//#region src/core/specification/shared/builder.ts
|
|
2112
|
+
/**
|
|
2113
|
+
* Fluent builder for declaring a single test specification.
|
|
2114
|
+
*
|
|
2115
|
+
* Chain setup methods ({@link seed}, {@link fixture}, {@link env}), then call
|
|
2116
|
+
* an action ({@link get}, {@link post}, {@link exec}, {@link trigger}) —
|
|
2117
|
+
* actions are terminal: they execute the specification and resolve to a
|
|
2118
|
+
* typed result.
|
|
2119
|
+
*
|
|
2120
|
+
* Facets expose this class through the narrower {@link ApiSpecification} /
|
|
2121
|
+
* {@link JobsSpecification} / {@link CliSpecification} views so each facet
|
|
2122
|
+
* only surfaces the methods that make sense for it.
|
|
2123
|
+
*/
|
|
2124
|
+
var SpecificationBuilder = class {
|
|
2125
|
+
commandEnv = {};
|
|
2126
|
+
config;
|
|
2127
|
+
fixtures = [];
|
|
2128
|
+
intercepts = [];
|
|
2129
|
+
requestHeaders = {};
|
|
2130
|
+
seeds = [];
|
|
2131
|
+
testDir;
|
|
2132
|
+
constructor(config, testDir) {
|
|
2133
|
+
this.config = config;
|
|
2134
|
+
this.testDir = testDir;
|
|
2135
|
+
}
|
|
2136
|
+
/**
|
|
2137
|
+
* Queue a SQL seed file to run before the action.
|
|
2138
|
+
*
|
|
2139
|
+
* With two or more declared databases the `database` option is mandatory;
|
|
2140
|
+
* with exactly one it is forbidden (CONVENTIONS A7).
|
|
2141
|
+
*
|
|
2142
|
+
* @example
|
|
2143
|
+
* api.seed("users.sql", { database: "db" }).get("/users");
|
|
2144
|
+
*/
|
|
2145
|
+
seed(file, options) {
|
|
2146
|
+
validateDatabaseOption("seed", this.config, options?.database);
|
|
2147
|
+
this.seeds.push({
|
|
2148
|
+
database: options?.database,
|
|
2149
|
+
file
|
|
2150
|
+
});
|
|
1039
2151
|
return this;
|
|
1040
2152
|
}
|
|
1041
|
-
/**
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
2153
|
+
/**
|
|
2154
|
+
* Copy a fixture into the working directory before execution.
|
|
2155
|
+
*
|
|
2156
|
+
* The path is feature-local (resolved under `<test-dir>/fixtures/`) or, with
|
|
2157
|
+
* a `$FIXTURES/` prefix, from the shared pool at `<specs-root>/fixtures/`.
|
|
2158
|
+
* Copy semantics follow rsync's trailing-slash rule: `dir/` spreads the
|
|
2159
|
+
* directory's contents into the cwd, while `dir` (or a plain file) is copied
|
|
2160
|
+
* under its own basename. Chained calls layer in order — a later fixture
|
|
2161
|
+
* overwrites files written by an earlier one.
|
|
2162
|
+
*
|
|
2163
|
+
* @example
|
|
2164
|
+
* cli.fixture('$FIXTURES/cli-app/').exec('build'); // shared project, spread
|
|
2165
|
+
* cli.fixture('config.toml').exec('migrate'); // feature-local file
|
|
2166
|
+
*/
|
|
2167
|
+
fixture(path) {
|
|
2168
|
+
this.fixtures.push({ file: path });
|
|
1046
2169
|
return this;
|
|
1047
2170
|
}
|
|
1048
|
-
/**
|
|
1049
|
-
|
|
1050
|
-
|
|
2171
|
+
/**
|
|
2172
|
+
* Set environment variables for the command process. Merged on top of process.env.
|
|
2173
|
+
* Use `null` to unset a variable. Multiple calls merge.
|
|
2174
|
+
*
|
|
2175
|
+
* The token `$WORKDIR` (in any value) is replaced with the actual working
|
|
2176
|
+
* directory at run-time — useful for tests that need a fully isolated `HOME`.
|
|
2177
|
+
*
|
|
2178
|
+
* @example
|
|
2179
|
+
* cli.env({ HOME: "$WORKDIR", TZ: "UTC" }).exec("status");
|
|
2180
|
+
*/
|
|
2181
|
+
env(env) {
|
|
2182
|
+
this.commandEnv = {
|
|
2183
|
+
...this.commandEnv,
|
|
2184
|
+
...env
|
|
2185
|
+
};
|
|
1051
2186
|
return this;
|
|
1052
2187
|
}
|
|
1053
|
-
/**
|
|
1054
|
-
|
|
1055
|
-
|
|
2188
|
+
/**
|
|
2189
|
+
* Set HTTP headers for the request. Multiple calls merge; chain headers
|
|
2190
|
+
* win over headers from a `requests/*.http` file.
|
|
2191
|
+
*
|
|
2192
|
+
* @example
|
|
2193
|
+
* api.headers({ 'Accept-Language': 'fr' }).get("/articles");
|
|
2194
|
+
*/
|
|
2195
|
+
headers(headers) {
|
|
2196
|
+
this.requestHeaders = {
|
|
2197
|
+
...this.requestHeaders,
|
|
2198
|
+
...headers
|
|
2199
|
+
};
|
|
1056
2200
|
return this;
|
|
1057
2201
|
}
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
throw new Error(`Expected mount at ${destination}, found: [${available}]`);
|
|
2202
|
+
intercept(triggerOrContracts, maybeResponse) {
|
|
2203
|
+
if (this.config.interceptDisabledReason) throw new Error(`.intercept(): ${this.config.interceptDisabledReason}`);
|
|
2204
|
+
if (Array.isArray(triggerOrContracts)) {
|
|
2205
|
+
for (const contract of triggerOrContracts) this.registerIntercept(contract);
|
|
2206
|
+
return this;
|
|
1064
2207
|
}
|
|
2208
|
+
this.registerIntercept(triggerOrContracts, maybeResponse);
|
|
1065
2209
|
return this;
|
|
1066
2210
|
}
|
|
1067
|
-
/**
|
|
1068
|
-
|
|
1069
|
-
const
|
|
1070
|
-
|
|
1071
|
-
|
|
2211
|
+
/** Register a single intercept — a contract, or a trigger + response pair. */
|
|
2212
|
+
registerIntercept(triggerOrContract, maybeResponse) {
|
|
2213
|
+
const isContract = "trigger" in triggerOrContract && "response" in triggerOrContract;
|
|
2214
|
+
const trigger = isContract ? triggerOrContract.trigger : triggerOrContract;
|
|
2215
|
+
const response = isContract ? triggerOrContract.response : maybeResponse;
|
|
2216
|
+
if (typeof response === "string") {
|
|
2217
|
+
const slashIndex = response.indexOf("/");
|
|
2218
|
+
if (slashIndex === -1) throw new Error(`.intercept(): file path must be 'adapter/filename.json' (e.g. 'openai/ingest-tech.json'), got '${response}'`);
|
|
2219
|
+
const adapterName = response.slice(0, slashIndex);
|
|
2220
|
+
if (adapterName !== trigger.adapter) throw new Error(`.intercept(): adapter mismatch - trigger uses '${trigger.adapter}' but file path starts with '${adapterName}/'`);
|
|
2221
|
+
const filePath = resolve(this.testDir, "intercepts", response);
|
|
2222
|
+
const data = JSON.parse(readFileSync(filePath, "utf8"));
|
|
2223
|
+
const resolved = trigger.wrap(data);
|
|
2224
|
+
this.intercepts.push({
|
|
2225
|
+
trigger,
|
|
2226
|
+
response: resolved
|
|
2227
|
+
});
|
|
2228
|
+
} else this.intercepts.push({
|
|
2229
|
+
trigger,
|
|
2230
|
+
response
|
|
2231
|
+
});
|
|
1072
2232
|
}
|
|
1073
|
-
/**
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
2233
|
+
/**
|
|
2234
|
+
* Send the complete request described by `requests/<file>` — first line
|
|
2235
|
+
* `METHOD /path`, then headers until a blank line, then the body.
|
|
2236
|
+
* Headers set via `.headers()` merge on top of the file's headers.
|
|
2237
|
+
*
|
|
2238
|
+
* @example
|
|
2239
|
+
* const result = await api.request("create-user.http");
|
|
2240
|
+
*/
|
|
2241
|
+
request(file) {
|
|
2242
|
+
return this.executeHttp({
|
|
2243
|
+
method: "",
|
|
2244
|
+
path: "",
|
|
2245
|
+
requestFile: file
|
|
2246
|
+
});
|
|
1078
2247
|
}
|
|
1079
|
-
/**
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
2248
|
+
/**
|
|
2249
|
+
* Send a GET request to the server adapter and resolve with the result.
|
|
2250
|
+
*
|
|
2251
|
+
* @example
|
|
2252
|
+
* const result = await api.get("/api/items");
|
|
2253
|
+
*/
|
|
2254
|
+
get(path) {
|
|
2255
|
+
return this.executeHttp({
|
|
2256
|
+
method: "GET",
|
|
2257
|
+
path
|
|
2258
|
+
});
|
|
1084
2259
|
}
|
|
1085
|
-
/**
|
|
1086
|
-
|
|
1087
|
-
|
|
2260
|
+
/**
|
|
2261
|
+
* Send a POST request to the server adapter and resolve with the result.
|
|
2262
|
+
*
|
|
2263
|
+
* @param body - Optional inline JSON body. Prefer `.request('name.http')`
|
|
2264
|
+
* for file-based request bodies.
|
|
2265
|
+
* @example
|
|
2266
|
+
* const result = await api.post("/api/items", { name: "Widget" });
|
|
2267
|
+
*/
|
|
2268
|
+
post(path, body) {
|
|
2269
|
+
return this.executeHttp({
|
|
2270
|
+
body,
|
|
2271
|
+
method: "POST",
|
|
2272
|
+
path
|
|
2273
|
+
});
|
|
2274
|
+
}
|
|
2275
|
+
/** Send a PUT request to the server adapter and resolve with the result. */
|
|
2276
|
+
put(path, body) {
|
|
2277
|
+
return this.executeHttp({
|
|
2278
|
+
body,
|
|
2279
|
+
method: "PUT",
|
|
2280
|
+
path
|
|
2281
|
+
});
|
|
2282
|
+
}
|
|
2283
|
+
/** Send a DELETE request to the server adapter and resolve with the result. */
|
|
2284
|
+
delete(path) {
|
|
2285
|
+
return this.executeHttp({
|
|
2286
|
+
method: "DELETE",
|
|
2287
|
+
path
|
|
2288
|
+
});
|
|
1088
2289
|
}
|
|
1089
|
-
/**
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
2290
|
+
/**
|
|
2291
|
+
* Execute a command (or a sequence of commands) in an isolated working
|
|
2292
|
+
* directory and resolve with the result. When an array is passed, commands
|
|
2293
|
+
* run sequentially and stop on the first non-zero exit code.
|
|
2294
|
+
*
|
|
2295
|
+
* With `{ waitFor, timeout }` the process is treated as long-running: it
|
|
2296
|
+
* resolves (exit code 0) as soon as `waitFor` appears in stdout/stderr,
|
|
2297
|
+
* and is killed at `timeout` (exit code 124). This is the single
|
|
2298
|
+
* execution method — there is no `.spawn()` (CONVENTIONS B2).
|
|
2299
|
+
*
|
|
2300
|
+
* Invoked with no arguments, the binary runs bare (no CLI args) — clearer
|
|
2301
|
+
* than the `.exec('')` idiom. An empty ARRAY stays an error: a command
|
|
2302
|
+
* sequence must name at least one command.
|
|
2303
|
+
*
|
|
2304
|
+
* @example
|
|
2305
|
+
* const result = await cli.exec(); // run the binary bare
|
|
2306
|
+
* const result = await cli.exec("init --name demo");
|
|
2307
|
+
* const result = await cli.exec(["init", "build"]);
|
|
2308
|
+
* const result = await cli.exec("dev --port 0", { waitFor: "Listening on", timeout: 10_000 });
|
|
2309
|
+
*/
|
|
2310
|
+
exec(args = "", options) {
|
|
2311
|
+
if (Array.isArray(args) && args.length === 0) throw new Error("exec([]) requires at least one command");
|
|
2312
|
+
if (options && Array.isArray(args)) throw new Error(".exec(): waitFor/timeout options are not supported with a command sequence");
|
|
2313
|
+
return this.executeCommand({
|
|
2314
|
+
args,
|
|
2315
|
+
options
|
|
2316
|
+
});
|
|
1094
2317
|
}
|
|
1095
|
-
/**
|
|
1096
|
-
|
|
1097
|
-
|
|
2318
|
+
/**
|
|
2319
|
+
* Execute the named job registered via the `jobs` option of
|
|
2320
|
+
* `specification.jobs()` and resolve with the result.
|
|
2321
|
+
*
|
|
2322
|
+
* @example
|
|
2323
|
+
* const result = await jobs.intercept(classifyArticle).trigger('report-refresh');
|
|
2324
|
+
*/
|
|
2325
|
+
async trigger(name) {
|
|
2326
|
+
return this.executeSetup(null, () => this.runJobAction(name));
|
|
1098
2327
|
}
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
//#region src/infra/adapters/compose.adapter.ts
|
|
1102
|
-
/**
|
|
1103
|
-
* Start the full compose stack and stop it all on cleanup.
|
|
1104
|
-
* Supports per-worker project names for parallel execution.
|
|
1105
|
-
*/
|
|
1106
|
-
var ComposeStackAdapter = class {
|
|
1107
|
-
composeFile;
|
|
1108
|
-
projectName;
|
|
1109
|
-
started = false;
|
|
1110
|
-
constructor(composeFile, projectName) {
|
|
1111
|
-
this.composeFile = composeFile;
|
|
1112
|
-
this.projectName = projectName ?? null;
|
|
2328
|
+
async executeHttp(request) {
|
|
2329
|
+
return this.executeSetup(null, () => this.runHttpAction(request));
|
|
1113
2330
|
}
|
|
1114
|
-
|
|
1115
|
-
|
|
2331
|
+
async executeCommand(action) {
|
|
2332
|
+
const workDir = this.prepareWorkDir();
|
|
2333
|
+
return this.executeSetup(workDir, () => this.runCommandAction(workDir, action));
|
|
1116
2334
|
}
|
|
1117
|
-
|
|
2335
|
+
/**
|
|
2336
|
+
* Shared setup pipeline: reset databases, run seeds, copy fixtures,
|
|
2337
|
+
* register intercepts — then run the action and clean up intercepts.
|
|
2338
|
+
*/
|
|
2339
|
+
async executeSetup(workDir, action) {
|
|
2340
|
+
if (this.config.databases) for (const db of this.config.databases.values()) await db.reset();
|
|
2341
|
+
else if (this.config.database) await this.config.database.reset();
|
|
2342
|
+
for (const entry of this.seeds) {
|
|
2343
|
+
let db;
|
|
2344
|
+
if (entry.database && this.config.databases) {
|
|
2345
|
+
db = this.config.databases.get(entry.database);
|
|
2346
|
+
if (!db) throw new Error(`seed() targets database "${entry.database}" but it was not found. Available: ${[...this.config.databases.keys()].join(", ")}`);
|
|
2347
|
+
} else db = this.config.database;
|
|
2348
|
+
if (!db) throw new Error("seed() requires a database adapter");
|
|
2349
|
+
const sql = readFileSync(resolve(this.testDir, "seeds", entry.file), "utf8");
|
|
2350
|
+
await db.seed(sql);
|
|
2351
|
+
}
|
|
2352
|
+
if (this.fixtures.length > 0 && workDir) for (const entry of this.fixtures) {
|
|
2353
|
+
const { dest, src } = copyPlan(entry.file, this.testDir, workDir);
|
|
2354
|
+
cpSync(src, dest, { recursive: true });
|
|
2355
|
+
}
|
|
2356
|
+
let registration = null;
|
|
2357
|
+
if (this.intercepts.length > 0) {
|
|
2358
|
+
const { registerIntercepts } = await import("./intercept.js");
|
|
2359
|
+
registration = await registerIntercepts(this.intercepts);
|
|
2360
|
+
}
|
|
1118
2361
|
try {
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
}).trim();
|
|
2362
|
+
const value = await action();
|
|
2363
|
+
const violation = registration?.violation();
|
|
2364
|
+
if (violation) throw violation;
|
|
2365
|
+
return value;
|
|
1124
2366
|
} catch (error) {
|
|
1125
|
-
|
|
1126
|
-
|
|
2367
|
+
throw registration?.violation() ?? error;
|
|
2368
|
+
} finally {
|
|
2369
|
+
registration?.cleanup();
|
|
1127
2370
|
}
|
|
1128
2371
|
}
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
this.run(`docker compose ${this.projectFlag} -f ${this.composeFile} down -v`);
|
|
1137
|
-
this.started = false;
|
|
1138
|
-
}
|
|
1139
|
-
getMappedPort(serviceName, containerPort) {
|
|
1140
|
-
const port = this.run(`docker compose ${this.projectFlag} -f ${this.composeFile} port ${serviceName} ${containerPort}`).split(":").pop();
|
|
1141
|
-
return Number(port);
|
|
1142
|
-
}
|
|
1143
|
-
getHost() {
|
|
1144
|
-
return "localhost";
|
|
1145
|
-
}
|
|
1146
|
-
};
|
|
1147
|
-
//#endregion
|
|
1148
|
-
//#region src/infra/adapters/testcontainers.adapter.ts
|
|
1149
|
-
/**
|
|
1150
|
-
* Container adapter using testcontainers.
|
|
1151
|
-
* Wraps a GenericContainer for programmatic container lifecycle.
|
|
1152
|
-
*/
|
|
1153
|
-
var TestcontainersAdapter = class {
|
|
1154
|
-
image;
|
|
1155
|
-
containerPort;
|
|
1156
|
-
env;
|
|
1157
|
-
reuse;
|
|
1158
|
-
container = null;
|
|
1159
|
-
constructor(options) {
|
|
1160
|
-
this.image = options.image;
|
|
1161
|
-
this.containerPort = options.port;
|
|
1162
|
-
this.env = options.env ?? {};
|
|
1163
|
-
this.reuse = options.reuse ?? false;
|
|
1164
|
-
}
|
|
1165
|
-
async start() {
|
|
1166
|
-
const { GenericContainer, Wait } = await import("testcontainers");
|
|
1167
|
-
let builder = new GenericContainer(this.image).withExposedPorts(this.containerPort);
|
|
1168
|
-
for (const [key, value] of Object.entries(this.env)) builder = builder.withEnvironment({ [key]: value });
|
|
1169
|
-
if (this.image.startsWith("postgres")) builder = builder.withWaitStrategy(Wait.forLogMessage(/database system is ready to accept connections/, 2));
|
|
1170
|
-
if (this.reuse) builder = builder.withReuse();
|
|
1171
|
-
this.container = await builder.start();
|
|
1172
|
-
}
|
|
1173
|
-
async stop() {
|
|
1174
|
-
if (this.container && !this.reuse) {
|
|
1175
|
-
await this.container.stop();
|
|
1176
|
-
this.container = null;
|
|
2372
|
+
resolveEnv(workDir) {
|
|
2373
|
+
const keys = Object.keys(this.commandEnv);
|
|
2374
|
+
if (keys.length === 0) return;
|
|
2375
|
+
const resolved = {};
|
|
2376
|
+
for (const key of keys) {
|
|
2377
|
+
const value = this.commandEnv[key];
|
|
2378
|
+
resolved[key] = typeof value === "string" ? value.replace(/\$WORKDIR/g, workDir) : value;
|
|
1177
2379
|
}
|
|
2380
|
+
return resolved;
|
|
1178
2381
|
}
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
2382
|
+
prepareWorkDir() {
|
|
2383
|
+
return mkdtempSync(resolve(tmpdir(), "spec-command-"));
|
|
2384
|
+
}
|
|
2385
|
+
async runHttpAction(request) {
|
|
2386
|
+
if (!this.config.server) throw new Error("HTTP actions require a server adapter (use specification.api())");
|
|
2387
|
+
let { body, method, path } = request;
|
|
2388
|
+
let fileHeaders;
|
|
2389
|
+
if (request.requestFile) {
|
|
2390
|
+
const parsed = parseRequestFile(readFileSync(resolve(this.testDir, "requests", request.requestFile), "utf8"), `requests/${request.requestFile}`);
|
|
2391
|
+
body = parsed.body;
|
|
2392
|
+
fileHeaders = parsed.headers;
|
|
2393
|
+
method = parsed.method;
|
|
2394
|
+
path = parsed.path;
|
|
2395
|
+
}
|
|
2396
|
+
const headers = {
|
|
2397
|
+
...fileHeaders,
|
|
2398
|
+
...this.requestHeaders
|
|
2399
|
+
};
|
|
2400
|
+
const response = await this.config.server.request(method, path, body, Object.keys(headers).length > 0 ? headers : void 0);
|
|
2401
|
+
return new HttpResult({
|
|
2402
|
+
config: this.config,
|
|
2403
|
+
response,
|
|
2404
|
+
testDir: this.testDir
|
|
2405
|
+
});
|
|
1186
2406
|
}
|
|
1187
|
-
|
|
1188
|
-
|
|
2407
|
+
async runJobAction(name) {
|
|
2408
|
+
if (!this.config.jobs?.length) throw new Error("Job actions require jobs registered via the jobs option of specification.jobs()");
|
|
2409
|
+
const job = this.config.jobs.find((j) => j.name === name);
|
|
2410
|
+
if (!job) {
|
|
2411
|
+
const available = this.config.jobs.map((j) => j.name).join(", ");
|
|
2412
|
+
throw new Error(`trigger("${name}"): job not found. Available: ${available}`);
|
|
2413
|
+
}
|
|
2414
|
+
await job.execute();
|
|
2415
|
+
return new BaseResult({
|
|
2416
|
+
config: this.config,
|
|
2417
|
+
testDir: this.testDir
|
|
2418
|
+
});
|
|
1189
2419
|
}
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
}
|
|
2420
|
+
/**
|
|
2421
|
+
* Automatic connection-URL injection (CONVENTIONS B6): `<KEY>_URL` for
|
|
2422
|
+
* every declared service, plus the standard aliases when unambiguous —
|
|
2423
|
+
* `DATABASE_URL` (exactly one SQL database) and `REDIS_URL` (exactly one
|
|
2424
|
+
* redis). `.env()` overrides; `null` unsets.
|
|
2425
|
+
*/
|
|
2426
|
+
serviceEnv() {
|
|
2427
|
+
const services = this.config.services;
|
|
2428
|
+
if (!services || Object.keys(services).length === 0) return;
|
|
2429
|
+
const env = {};
|
|
2430
|
+
const sqlHandles = [];
|
|
2431
|
+
const redisHandles = [];
|
|
2432
|
+
for (const [key, handle] of Object.entries(services)) {
|
|
2433
|
+
env[`${toConstantCase(key)}_URL`] = handle.connectionString;
|
|
2434
|
+
if (handle.createDatabaseAdapter() !== null) sqlHandles.push(handle);
|
|
2435
|
+
if (handle.type === "redis") redisHandles.push(handle);
|
|
2436
|
+
}
|
|
2437
|
+
if (sqlHandles.length === 1) env.DATABASE_URL = sqlHandles[0].connectionString;
|
|
2438
|
+
if (redisHandles.length === 1) env.REDIS_URL = redisHandles[0].connectionString;
|
|
2439
|
+
return env;
|
|
2440
|
+
}
|
|
2441
|
+
async runCommandAction(workDir, action) {
|
|
2442
|
+
if (!this.config.command) throw new Error("Command actions require a command adapter");
|
|
2443
|
+
const dockerConfig = this.config.dockerConfig;
|
|
2444
|
+
const testRunId = this.config.dockerTestRunId;
|
|
2445
|
+
let env = this.serviceEnv();
|
|
2446
|
+
if (dockerConfig && testRunId) env = {
|
|
2447
|
+
...env,
|
|
2448
|
+
[dockerConfig.envVar]: testRunId
|
|
2449
|
+
};
|
|
2450
|
+
const userEnv = this.resolveEnv(workDir);
|
|
2451
|
+
if (userEnv) env = {
|
|
2452
|
+
...env,
|
|
2453
|
+
...userEnv
|
|
2454
|
+
};
|
|
2455
|
+
let commandOutput;
|
|
2456
|
+
if (action.options) commandOutput = await this.config.command.watch(action.args, workDir, action.options, env);
|
|
2457
|
+
else if (Array.isArray(action.args)) {
|
|
2458
|
+
commandOutput = {
|
|
2459
|
+
exitCode: 0,
|
|
2460
|
+
stderr: "",
|
|
2461
|
+
stdout: ""
|
|
2462
|
+
};
|
|
2463
|
+
for (const args of action.args) {
|
|
2464
|
+
commandOutput = await this.config.command.exec(args, workDir, env);
|
|
2465
|
+
if (commandOutput.exitCode !== 0) break;
|
|
2466
|
+
}
|
|
2467
|
+
} else commandOutput = await this.config.command.exec(action.args, workDir, env);
|
|
2468
|
+
return new CliResult({
|
|
2469
|
+
commandOutput,
|
|
2470
|
+
config: this.config,
|
|
2471
|
+
dockerConfig: dockerConfig ?? void 0,
|
|
2472
|
+
testDir: this.testDir,
|
|
2473
|
+
testRunId: testRunId ?? void 0,
|
|
2474
|
+
transform: this.config.transform,
|
|
2475
|
+
workDir
|
|
1204
2476
|
});
|
|
1205
2477
|
}
|
|
1206
2478
|
};
|
|
1207
|
-
|
|
1208
|
-
|
|
2479
|
+
function withDockerTestRunId(config) {
|
|
2480
|
+
if (config.dockerConfig && !config.dockerTestRunId) return {
|
|
2481
|
+
...config,
|
|
2482
|
+
dockerTestRunId: `t-${Math.random().toString(36).slice(2, 10)}-${Date.now().toString(36)}`
|
|
2483
|
+
};
|
|
2484
|
+
return config;
|
|
2485
|
+
}
|
|
1209
2486
|
/**
|
|
1210
|
-
*
|
|
2487
|
+
* Create the `api` facet bound to the given adapter configuration. The test
|
|
2488
|
+
* file directory is auto-detected from the call stack at each chain start.
|
|
1211
2489
|
*/
|
|
1212
|
-
function
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
2490
|
+
function createApiFacet(config) {
|
|
2491
|
+
const start = () => new SpecificationBuilder(config, getCallerDir());
|
|
2492
|
+
return {
|
|
2493
|
+
delete: (path) => start().delete(path),
|
|
2494
|
+
get: (path) => start().get(path),
|
|
2495
|
+
headers: (headers) => start().headers(headers),
|
|
2496
|
+
intercept: (triggerOrContracts, response) => Array.isArray(triggerOrContracts) ? start().intercept(triggerOrContracts) : start().intercept(triggerOrContracts, response),
|
|
2497
|
+
post: (path, body) => start().post(path, body),
|
|
2498
|
+
put: (path, body) => start().put(path, body),
|
|
2499
|
+
request: (file) => start().request(file),
|
|
2500
|
+
seed: (file, options) => start().seed(file, options)
|
|
2501
|
+
};
|
|
1218
2502
|
}
|
|
1219
2503
|
/**
|
|
1220
|
-
*
|
|
1221
|
-
* Looks for docker/compose.test.yaml or docker-compose.test.yaml.
|
|
2504
|
+
* Create the `jobs` facet bound to the given adapter configuration.
|
|
1222
2505
|
*/
|
|
1223
|
-
function
|
|
1224
|
-
const
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
for (const candidate of candidates) if (existsSync(candidate)) return candidate;
|
|
1231
|
-
return null;
|
|
2506
|
+
function createJobsFacet(config) {
|
|
2507
|
+
const start = () => new SpecificationBuilder(config, getCallerDir());
|
|
2508
|
+
return {
|
|
2509
|
+
intercept: (triggerOrContracts, response) => Array.isArray(triggerOrContracts) ? start().intercept(triggerOrContracts) : start().intercept(triggerOrContracts, response),
|
|
2510
|
+
seed: (file, options) => start().seed(file, options),
|
|
2511
|
+
trigger: (name) => start().trigger(name)
|
|
2512
|
+
};
|
|
1232
2513
|
}
|
|
1233
2514
|
/**
|
|
1234
|
-
*
|
|
2515
|
+
* Create the `cli` facet bound to the given adapter configuration.
|
|
1235
2516
|
*/
|
|
1236
|
-
function
|
|
1237
|
-
const
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
2517
|
+
function createCliFacet(config) {
|
|
2518
|
+
const resolved = withDockerTestRunId(config);
|
|
2519
|
+
const start = () => new SpecificationBuilder(resolved, getCallerDir());
|
|
2520
|
+
return {
|
|
2521
|
+
env: (env) => start().env(env),
|
|
2522
|
+
exec: (args, options) => start().exec(args, options),
|
|
2523
|
+
fixture: (path) => start().fixture(path),
|
|
2524
|
+
seed: (file, options) => start().seed(file, options)
|
|
1242
2525
|
};
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
}
|
|
1255
|
-
|
|
1256
|
-
if (def.environment) if (Array.isArray(def.environment)) for (const env of def.environment) {
|
|
1257
|
-
const [key, ...rest] = String(env).split("=");
|
|
1258
|
-
environment[key] = rest.join("=");
|
|
2526
|
+
}
|
|
2527
|
+
//#endregion
|
|
2528
|
+
//#region src/core/specification/shared/docker-reader.ts
|
|
2529
|
+
/**
|
|
2530
|
+
* Build the `docker(containerId)` reader handed out by `specification.api()`
|
|
2531
|
+
* and `specification.cli()` — a lazy accessor over `docker inspect`.
|
|
2532
|
+
*/
|
|
2533
|
+
function createDockerReader(testDir) {
|
|
2534
|
+
return (containerId) => {
|
|
2535
|
+
try {
|
|
2536
|
+
return new ContainerAccessor(containerId, inspectContainer(containerId), testDir);
|
|
2537
|
+
} catch {
|
|
2538
|
+
return new ContainerAccessor(null, null, testDir);
|
|
1259
2539
|
}
|
|
1260
|
-
else Object.assign(environment, def.environment);
|
|
1261
|
-
const volumes = def.volumes ? def.volumes.map((v) => String(v)) : [];
|
|
1262
|
-
let dependsOn = [];
|
|
1263
|
-
if (def.depends_on) dependsOn = Array.isArray(def.depends_on) ? def.depends_on : Object.keys(def.depends_on);
|
|
1264
|
-
return {
|
|
1265
|
-
name,
|
|
1266
|
-
image: def.image,
|
|
1267
|
-
build: def.build,
|
|
1268
|
-
ports,
|
|
1269
|
-
environment,
|
|
1270
|
-
volumes,
|
|
1271
|
-
dependsOn
|
|
1272
|
-
};
|
|
1273
|
-
});
|
|
1274
|
-
return {
|
|
1275
|
-
services,
|
|
1276
|
-
appService: services.find((s) => s.build !== void 0) ?? null,
|
|
1277
|
-
infraServices: services.filter((s) => s.build === void 0)
|
|
1278
2540
|
};
|
|
1279
2541
|
}
|
|
1280
2542
|
//#endregion
|
|
1281
|
-
//#region src/
|
|
2543
|
+
//#region src/core/specification/shared/compose-file.ts
|
|
2544
|
+
/**
|
|
2545
|
+
* Detect the service type from the image name.
|
|
2546
|
+
*/
|
|
2547
|
+
function detectServiceType(image) {
|
|
2548
|
+
if (!image) return "app";
|
|
2549
|
+
const lower = image.toLowerCase();
|
|
2550
|
+
if (lower.startsWith("postgres")) return "postgres";
|
|
2551
|
+
if (lower.startsWith("redis")) return "redis";
|
|
2552
|
+
return "unknown";
|
|
2553
|
+
}
|
|
2554
|
+
/**
|
|
2555
|
+
* Find the compose file in the project.
|
|
2556
|
+
* Looks for docker/compose.test.yaml or docker-compose.test.yaml.
|
|
2557
|
+
*/
|
|
2558
|
+
function findComposeFile(projectRoot) {
|
|
2559
|
+
const candidates = [
|
|
2560
|
+
resolve(projectRoot, "docker/compose.test.yaml"),
|
|
2561
|
+
resolve(projectRoot, "docker/compose.test.yml"),
|
|
2562
|
+
resolve(projectRoot, "docker-compose.test.yaml"),
|
|
2563
|
+
resolve(projectRoot, "docker-compose.test.yml")
|
|
2564
|
+
];
|
|
2565
|
+
for (const candidate of candidates) if (existsSync(candidate)) return candidate;
|
|
2566
|
+
return null;
|
|
2567
|
+
}
|
|
2568
|
+
//#endregion
|
|
2569
|
+
//#region src/core/specification/shared/orchestrator.ts
|
|
1282
2570
|
/**
|
|
1283
2571
|
* Orchestrator for test infrastructure.
|
|
1284
2572
|
* Integration: starts services via testcontainers.
|
|
@@ -1300,6 +2588,17 @@ var Orchestrator = class {
|
|
|
1300
2588
|
this.projectName = options.projectName;
|
|
1301
2589
|
}
|
|
1302
2590
|
/**
|
|
2591
|
+
* Bind each declared handle to a compose service (CONVENTIONS A6): a record
|
|
2592
|
+
* key resolves to the service named exactly like it, else the kebab-case
|
|
2593
|
+
* conversion of the key; an explicit `composeService` wins. Throws on an
|
|
2594
|
+
* ambiguous binding (both names present). Runs once compose config is known
|
|
2595
|
+
* so the two-step resolution can consult the real service list.
|
|
2596
|
+
*/
|
|
2597
|
+
resolveBindings(composeConfig) {
|
|
2598
|
+
const serviceNames = composeConfig?.services.map((s) => s.name) ?? [];
|
|
2599
|
+
for (const [key, handle] of Object.entries(this.services)) handle.composeName = resolveComposeBinding(key, handle.composeName, serviceNames);
|
|
2600
|
+
}
|
|
2601
|
+
/**
|
|
1303
2602
|
* Start declared services via testcontainers (integration mode).
|
|
1304
2603
|
* Phase 1: start all containers in parallel (the slow part).
|
|
1305
2604
|
* Phase 2: wire connections, healthcheck, and init sequentially (fast).
|
|
@@ -1308,10 +2607,11 @@ var Orchestrator = class {
|
|
|
1308
2607
|
if (this.started) return;
|
|
1309
2608
|
const composePath = findComposeFile(this.root);
|
|
1310
2609
|
const composeDir = composePath ? dirname(composePath) : this.root;
|
|
1311
|
-
const composeConfig = composePath ? parseComposeFile(composePath) : null;
|
|
2610
|
+
const composeConfig = composePath ? getContainerIntegrations().parseComposeFile(composePath) : null;
|
|
2611
|
+
this.resolveBindings(composeConfig);
|
|
1312
2612
|
const containerServices = [];
|
|
1313
2613
|
const embeddedServices = [];
|
|
1314
|
-
for (const handle of this.services) {
|
|
2614
|
+
for (const handle of Object.values(this.services)) {
|
|
1315
2615
|
if (handle.defaultPort === 0) {
|
|
1316
2616
|
embeddedServices.push(handle);
|
|
1317
2617
|
continue;
|
|
@@ -1329,7 +2629,7 @@ var Orchestrator = class {
|
|
|
1329
2629
|
Object.assign(handle.environment, composeService.environment);
|
|
1330
2630
|
}
|
|
1331
2631
|
}
|
|
1332
|
-
const container =
|
|
2632
|
+
const container = getContainerIntegrations().createContainer({
|
|
1333
2633
|
image,
|
|
1334
2634
|
port: handle.defaultPort,
|
|
1335
2635
|
env
|
|
@@ -1392,395 +2692,1137 @@ var Orchestrator = class {
|
|
|
1392
2692
|
console.log(output);
|
|
1393
2693
|
}
|
|
1394
2694
|
/**
|
|
1395
|
-
* Stop testcontainers (integration mode).
|
|
2695
|
+
* Stop testcontainers (integration mode).
|
|
2696
|
+
*/
|
|
2697
|
+
async stop() {
|
|
2698
|
+
for (const { container } of this.running) if (container) await container.stop();
|
|
2699
|
+
this.running = [];
|
|
2700
|
+
this.started = false;
|
|
2701
|
+
}
|
|
2702
|
+
/**
|
|
2703
|
+
* Start full docker compose stack (e2e mode).
|
|
2704
|
+
* Auto-detects infra services and creates handles for them.
|
|
2705
|
+
*/
|
|
2706
|
+
async startCompose() {
|
|
2707
|
+
const composePath = findComposeFile(this.root);
|
|
2708
|
+
if (!composePath) throw new Error(`E2E: no compose file found in ${this.root}`);
|
|
2709
|
+
const startTime = Date.now();
|
|
2710
|
+
const composeDir = dirname(composePath);
|
|
2711
|
+
const composeConfig = getContainerIntegrations().parseComposeFile(composePath);
|
|
2712
|
+
this.resolveBindings(composeConfig);
|
|
2713
|
+
this.composeStack = getContainerIntegrations().createComposeStack(composePath, this.projectName);
|
|
2714
|
+
await this.composeStack.start();
|
|
2715
|
+
const declaredComposeNames = /* @__PURE__ */ new Set();
|
|
2716
|
+
for (const handle of Object.values(this.services)) {
|
|
2717
|
+
if (handle.defaultPort === 0) {
|
|
2718
|
+
await handle.initialize(composeDir);
|
|
2719
|
+
handle.started = true;
|
|
2720
|
+
continue;
|
|
2721
|
+
}
|
|
2722
|
+
const composeService = composeConfig.services.find((s) => s.name === handle.composeName);
|
|
2723
|
+
if (!composeService || !handle.composeName) continue;
|
|
2724
|
+
declaredComposeNames.add(handle.composeName);
|
|
2725
|
+
Object.assign(handle.environment, composeService.environment);
|
|
2726
|
+
const port = this.composeStack.getMappedPort(handle.composeName, handle.defaultPort);
|
|
2727
|
+
handle.connectionString = handle.buildConnectionString("localhost", port);
|
|
2728
|
+
await handle.healthcheck();
|
|
2729
|
+
await handle.initialize(composeDir);
|
|
2730
|
+
handle.started = true;
|
|
2731
|
+
}
|
|
2732
|
+
for (const service of composeConfig.infraServices) {
|
|
2733
|
+
if (declaredComposeNames.has(service.name)) continue;
|
|
2734
|
+
const factory = getComposeServiceFactory(detectServiceType(service.image));
|
|
2735
|
+
if (!factory) continue;
|
|
2736
|
+
const handle = factory(service);
|
|
2737
|
+
const port = this.composeStack.getMappedPort(service.name, handle.defaultPort);
|
|
2738
|
+
handle.connectionString = handle.buildConnectionString("localhost", port);
|
|
2739
|
+
await handle.initialize(composeDir);
|
|
2740
|
+
handle.started = true;
|
|
2741
|
+
this.composeHandles.push(handle);
|
|
2742
|
+
}
|
|
2743
|
+
const durationMs = Date.now() - startTime;
|
|
2744
|
+
const output = formatStartupReport("e2e", [...this.allHandles().values()].filter((h) => h.started).map((h) => ({
|
|
2745
|
+
name: h.composeName ?? h.type,
|
|
2746
|
+
type: h.type,
|
|
2747
|
+
connectionString: h.connectionString,
|
|
2748
|
+
durationMs
|
|
2749
|
+
})), {
|
|
2750
|
+
type: "http",
|
|
2751
|
+
url: this.getAppUrl() ?? void 0
|
|
2752
|
+
});
|
|
2753
|
+
console.log(output);
|
|
2754
|
+
}
|
|
2755
|
+
/**
|
|
2756
|
+
* Stop docker compose stack (e2e mode).
|
|
2757
|
+
*/
|
|
2758
|
+
async stopCompose() {
|
|
2759
|
+
if (this.composeStack) {
|
|
2760
|
+
await this.composeStack.stop();
|
|
2761
|
+
this.composeStack = null;
|
|
2762
|
+
}
|
|
2763
|
+
this.composeHandles = [];
|
|
2764
|
+
}
|
|
2765
|
+
/**
|
|
2766
|
+
* Get the default database — the first declared handle that is one.
|
|
2767
|
+
*/
|
|
2768
|
+
getDatabase() {
|
|
2769
|
+
for (const handle of this.allHandles().values()) {
|
|
2770
|
+
const adapter = handle.createDatabaseAdapter();
|
|
2771
|
+
if (adapter) return adapter;
|
|
2772
|
+
}
|
|
2773
|
+
return null;
|
|
2774
|
+
}
|
|
2775
|
+
/**
|
|
2776
|
+
* Get all database services keyed by their record key (declared services)
|
|
2777
|
+
* or compose service name (stack-detected services).
|
|
2778
|
+
*/
|
|
2779
|
+
getDatabases() {
|
|
2780
|
+
const map = /* @__PURE__ */ new Map();
|
|
2781
|
+
for (const [key, handle] of this.allHandles()) {
|
|
2782
|
+
const adapter = handle.createDatabaseAdapter();
|
|
2783
|
+
if (adapter) map.set(key, adapter);
|
|
2784
|
+
}
|
|
2785
|
+
return map;
|
|
2786
|
+
}
|
|
2787
|
+
allHandles() {
|
|
2788
|
+
const map = new Map(Object.entries(this.services));
|
|
2789
|
+
for (const handle of this.composeHandles) {
|
|
2790
|
+
const key = handle.composeName ?? handle.type;
|
|
2791
|
+
if (!map.has(key)) map.set(key, handle);
|
|
2792
|
+
}
|
|
2793
|
+
return map;
|
|
2794
|
+
}
|
|
2795
|
+
/**
|
|
2796
|
+
* Get app URL from compose (e2e mode).
|
|
2797
|
+
*/
|
|
2798
|
+
getAppUrl() {
|
|
2799
|
+
const composePath = findComposeFile(this.root);
|
|
2800
|
+
if (!composePath || !this.composeStack) return null;
|
|
2801
|
+
const appService = getContainerIntegrations().parseComposeFile(composePath).appService;
|
|
2802
|
+
if (!appService || appService.ports.length === 0) return null;
|
|
2803
|
+
return `http://localhost:${this.composeStack.getMappedPort(appService.name, appService.ports[0].container)}`;
|
|
2804
|
+
}
|
|
2805
|
+
};
|
|
2806
|
+
//#endregion
|
|
2807
|
+
//#region src/core/specification/shared/resolve.ts
|
|
2808
|
+
/**
|
|
2809
|
+
* Auto-discover the project root from a starting directory (CONVENTIONS A9):
|
|
2810
|
+
* walk up to the first directory containing `docker/compose.test.yaml`;
|
|
2811
|
+
* if none, walk up to the first directory containing `package.json`;
|
|
2812
|
+
* if none, the starting directory itself.
|
|
2813
|
+
*/
|
|
2814
|
+
function discoverRoot(startDir) {
|
|
2815
|
+
for (const marker of ["docker/compose.test.yaml", "package.json"]) {
|
|
2816
|
+
let dir = startDir;
|
|
2817
|
+
for (;;) {
|
|
2818
|
+
if (existsSync(resolve(dir, marker))) return dir;
|
|
2819
|
+
const parent = dirname(dir);
|
|
2820
|
+
if (parent === dir) break;
|
|
2821
|
+
dir = parent;
|
|
2822
|
+
}
|
|
2823
|
+
}
|
|
2824
|
+
return startDir;
|
|
2825
|
+
}
|
|
2826
|
+
/**
|
|
2827
|
+
* Resolve the project root for a specification. An explicit `root` option is
|
|
2828
|
+
* an override (resolved from the caller's directory when relative); when
|
|
2829
|
+
* absent, the root is auto-discovered by walking up from the calling
|
|
2830
|
+
* specification file.
|
|
2831
|
+
*/
|
|
2832
|
+
function resolveRoot(root, callerDir) {
|
|
2833
|
+
if (!root) return discoverRoot(callerDir);
|
|
2834
|
+
if (isAbsolute(root)) return root;
|
|
2835
|
+
return resolve(callerDir, root);
|
|
2836
|
+
}
|
|
2837
|
+
/**
|
|
2838
|
+
* Resolve a command — checks node_modules/.bin, then treats as absolute/PATH.
|
|
2839
|
+
*/
|
|
2840
|
+
function resolveCommand(command, root) {
|
|
2841
|
+
if (isAbsolute(command)) return command;
|
|
2842
|
+
const binPath = resolve(root, "node_modules/.bin", command);
|
|
2843
|
+
if (existsSync(binPath)) return binPath;
|
|
2844
|
+
const cwdBinPath = resolve(process.cwd(), "node_modules/.bin", command);
|
|
2845
|
+
if (existsSync(cwdBinPath)) return cwdBinPath;
|
|
2846
|
+
return command;
|
|
2847
|
+
}
|
|
2848
|
+
//#endregion
|
|
2849
|
+
//#region src/core/specification/shared/services.ts
|
|
2850
|
+
function getWorkerId() {
|
|
2851
|
+
return process.env.VITEST_POOL_ID ?? "0";
|
|
2852
|
+
}
|
|
2853
|
+
async function acquireIsolation(services) {
|
|
2854
|
+
const workerId = getWorkerId();
|
|
2855
|
+
for (const service of Object.values(services)) await service.isolation().acquire(workerId);
|
|
2856
|
+
}
|
|
2857
|
+
async function releaseIsolation(services) {
|
|
2858
|
+
for (const service of Object.values(services)) await service.isolation().release();
|
|
2859
|
+
}
|
|
2860
|
+
function declaredDatabaseKeys(services) {
|
|
2861
|
+
return Object.keys(services).filter((key) => services[key].createDatabaseAdapter() !== null);
|
|
2862
|
+
}
|
|
2863
|
+
/** Start a services record via testcontainers and acquire worker isolation. */
|
|
2864
|
+
async function startServices(services, root) {
|
|
2865
|
+
const orchestrator = new Orchestrator({
|
|
2866
|
+
mode: "integration",
|
|
2867
|
+
root,
|
|
2868
|
+
services
|
|
2869
|
+
});
|
|
2870
|
+
await orchestrator.start();
|
|
2871
|
+
await acquireIsolation(services);
|
|
2872
|
+
const databases = orchestrator.getDatabases();
|
|
2873
|
+
return {
|
|
2874
|
+
database: orchestrator.getDatabase() ?? void 0,
|
|
2875
|
+
databases: databases.size > 0 ? databases : void 0,
|
|
2876
|
+
orchestrator
|
|
2877
|
+
};
|
|
2878
|
+
}
|
|
2879
|
+
//#endregion
|
|
2880
|
+
//#region src/core/specification/api/fetch.adapter.ts
|
|
2881
|
+
/**
|
|
2882
|
+
* Transient connection-level failures against a live compose app. Under
|
|
2883
|
+
* parallel load (every `api-stack` worker hammering its own compose project)
|
|
2884
|
+
* `fetch` intermittently rejects before the app's keep-alive socket is ready —
|
|
2885
|
+
* `undici` surfaces these as `UND_ERR_SOCKET` / `ECONNRESET` / `ECONNREFUSED`.
|
|
2886
|
+
* They are races, not real failures, so the request path retries them; an HTTP
|
|
2887
|
+
* *response* (any status) is never retried — it is a real answer.
|
|
2888
|
+
*/
|
|
2889
|
+
const TRANSIENT_CONNECTION_CODES = /* @__PURE__ */ new Set([
|
|
2890
|
+
"ECONNREFUSED",
|
|
2891
|
+
"ECONNRESET",
|
|
2892
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
2893
|
+
"UND_ERR_SOCKET"
|
|
2894
|
+
]);
|
|
2895
|
+
/** Bounded retry budget: attempts and the base backoff (doubled each retry). */
|
|
2896
|
+
const MAX_ATTEMPTS = 5;
|
|
2897
|
+
const BASE_BACKOFF_MS = 50;
|
|
2898
|
+
function isTransientConnectionError(error) {
|
|
2899
|
+
if (!(error instanceof Error)) return false;
|
|
2900
|
+
const cause = error.cause;
|
|
2901
|
+
return cause?.code !== void 0 && TRANSIENT_CONNECTION_CODES.has(cause.code) || error.message.includes("fetch failed");
|
|
2902
|
+
}
|
|
2903
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
2904
|
+
/**
|
|
2905
|
+
* Server adapter that sends real HTTP requests via the Fetch API.
|
|
2906
|
+
* Used by the `e2e()` specification runner to hit a live server.
|
|
2907
|
+
*/
|
|
2908
|
+
var FetchAdapter = class {
|
|
2909
|
+
baseUrl;
|
|
2910
|
+
constructor(url) {
|
|
2911
|
+
this.baseUrl = url.replace(/\/$/, "");
|
|
2912
|
+
}
|
|
2913
|
+
async request(method, path, body, headers) {
|
|
2914
|
+
const init = {
|
|
2915
|
+
method,
|
|
2916
|
+
headers: {
|
|
2917
|
+
"Content-Type": "application/json",
|
|
2918
|
+
...headers
|
|
2919
|
+
}
|
|
2920
|
+
};
|
|
2921
|
+
if (body !== void 0) init.body = typeof body === "string" ? body : JSON.stringify(body);
|
|
2922
|
+
const response = await this.fetchWithRetry(`${this.baseUrl}${path}`, init);
|
|
2923
|
+
const responseBody = await response.json().catch(() => null);
|
|
2924
|
+
const responseHeaders = {};
|
|
2925
|
+
response.headers.forEach((value, key) => {
|
|
2926
|
+
responseHeaders[key] = value;
|
|
2927
|
+
});
|
|
2928
|
+
return {
|
|
2929
|
+
status: response.status,
|
|
2930
|
+
body: responseBody,
|
|
2931
|
+
headers: responseHeaders
|
|
2932
|
+
};
|
|
2933
|
+
}
|
|
2934
|
+
/**
|
|
2935
|
+
* `fetch` with a bounded, backing-off retry over transient connection
|
|
2936
|
+
* races (see {@link isTransientConnectionError}). The last error is rethrown
|
|
2937
|
+
* once the budget is spent so a genuinely-down server still surfaces.
|
|
2938
|
+
*/
|
|
2939
|
+
async fetchWithRetry(url, init) {
|
|
2940
|
+
for (let attempt = 1;; attempt += 1) try {
|
|
2941
|
+
return await fetch(url, init);
|
|
2942
|
+
} catch (error) {
|
|
2943
|
+
if (attempt >= MAX_ATTEMPTS || !isTransientConnectionError(error)) throw error;
|
|
2944
|
+
await delay(BASE_BACKOFF_MS * 2 ** (attempt - 1));
|
|
2945
|
+
}
|
|
2946
|
+
}
|
|
2947
|
+
};
|
|
2948
|
+
//#endregion
|
|
2949
|
+
//#region src/core/specification/api/start-api.ts
|
|
2950
|
+
function resolveMode(explicit) {
|
|
2951
|
+
const value = explicit ?? process.env.TEST_MODE ?? "node";
|
|
2952
|
+
if (value !== "compose" && value !== "node") throw new Error(`Invalid test mode "${value}" — expected 'node' or 'compose' (options.mode or TEST_MODE).`);
|
|
2953
|
+
return value;
|
|
2954
|
+
}
|
|
2955
|
+
async function startApi(options) {
|
|
2956
|
+
const callerDir = getCallerDir();
|
|
2957
|
+
await registerMatchers();
|
|
2958
|
+
const root = resolveRoot(options.root, callerDir);
|
|
2959
|
+
const mode = resolveMode(options.mode);
|
|
2960
|
+
const services = options.services ?? {};
|
|
2961
|
+
const databaseKeys = declaredDatabaseKeys(services);
|
|
2962
|
+
if (mode === "node") {
|
|
2963
|
+
if (!options.server) throw new Error("specification.api(): 'server' is required in node mode — provide server: (services) => app, or run in compose mode (TEST_MODE=compose).");
|
|
2964
|
+
const { database, databases, orchestrator } = await startServices(services, root);
|
|
2965
|
+
return {
|
|
2966
|
+
api: createApiFacet({
|
|
2967
|
+
database,
|
|
2968
|
+
databaseKeys,
|
|
2969
|
+
databases,
|
|
2970
|
+
server: new HonoAdapter(options.server(services))
|
|
2971
|
+
}),
|
|
2972
|
+
cleanup: async () => {
|
|
2973
|
+
await releaseIsolation(services);
|
|
2974
|
+
await orchestrator.stop();
|
|
2975
|
+
},
|
|
2976
|
+
docker: createDockerReader(callerDir),
|
|
2977
|
+
orchestrator
|
|
2978
|
+
};
|
|
2979
|
+
}
|
|
2980
|
+
const orchestrator = new Orchestrator({
|
|
2981
|
+
mode: "e2e",
|
|
2982
|
+
projectName: `test-worker-${getWorkerId()}`,
|
|
2983
|
+
root,
|
|
2984
|
+
services
|
|
2985
|
+
});
|
|
2986
|
+
await orchestrator.startCompose();
|
|
2987
|
+
const appUrl = orchestrator.getAppUrl();
|
|
2988
|
+
if (!appUrl) throw new Error("specification.api(): could not detect app URL from compose. Ensure an app service with ports is defined.");
|
|
2989
|
+
const databases = orchestrator.getDatabases();
|
|
2990
|
+
return {
|
|
2991
|
+
api: createApiFacet({
|
|
2992
|
+
database: orchestrator.getDatabase() ?? void 0,
|
|
2993
|
+
databaseKeys,
|
|
2994
|
+
databases: databases.size > 0 ? databases : void 0,
|
|
2995
|
+
interceptDisabledReason: "intercepts are in-process (MSW) and not available in compose mode — keep intercept specs in node-only vitest projects.",
|
|
2996
|
+
server: new FetchAdapter(appUrl)
|
|
2997
|
+
}),
|
|
2998
|
+
cleanup: () => orchestrator.stopCompose(),
|
|
2999
|
+
docker: createDockerReader(callerDir),
|
|
3000
|
+
orchestrator
|
|
3001
|
+
};
|
|
3002
|
+
}
|
|
3003
|
+
//#endregion
|
|
3004
|
+
//#region src/core/specification/cli/exec.adapter.ts
|
|
3005
|
+
const DEFAULT_WATCH_TIMEOUT = 1e4;
|
|
3006
|
+
/** Grace period between SIGTERM and the SIGKILL escalation. */
|
|
3007
|
+
const KILL_GRACE_MS = 2e3;
|
|
3008
|
+
/**
|
|
3009
|
+
* Build a child-process env from the parent env plus user overrides.
|
|
3010
|
+
* `null` overrides delete keys (e.g. `INIT_CWD: null`).
|
|
3011
|
+
*
|
|
3012
|
+
* @internal Exported for unit tests.
|
|
3013
|
+
*/
|
|
3014
|
+
function buildEnv(extra) {
|
|
3015
|
+
const env = {
|
|
3016
|
+
...process.env,
|
|
3017
|
+
INIT_CWD: void 0
|
|
3018
|
+
};
|
|
3019
|
+
if (extra) for (const [key, value] of Object.entries(extra)) if (value === null) delete env[key];
|
|
3020
|
+
else env[key] = value;
|
|
3021
|
+
return env;
|
|
3022
|
+
}
|
|
3023
|
+
/**
|
|
3024
|
+
* Observe a long-running child (`.exec(args, { waitFor, timeout })`):
|
|
3025
|
+
* resolve with exit code 0 as soon as `waitFor` appears in stdout/stderr,
|
|
3026
|
+
* with the process's own code when it exits first, and with 124 at the
|
|
3027
|
+
* timeout. On resolution the child is terminated with SIGTERM, escalating
|
|
3028
|
+
* to SIGKILL after a 2 s grace period; all timers are cleared on exit and
|
|
3029
|
+
* the timeout timer is unref'd so it never holds the runner open.
|
|
3030
|
+
*
|
|
3031
|
+
* @internal Exported for unit tests (driven with a fake child).
|
|
3032
|
+
*/
|
|
3033
|
+
function observeProcess(child, options) {
|
|
3034
|
+
const timeout = options.timeout ?? DEFAULT_WATCH_TIMEOUT;
|
|
3035
|
+
const waitFor = options.waitFor;
|
|
3036
|
+
return new Promise((resolve) => {
|
|
3037
|
+
let stdout = "";
|
|
3038
|
+
let stderr = "";
|
|
3039
|
+
let resolved = false;
|
|
3040
|
+
let exited = false;
|
|
3041
|
+
let patternMatched = false;
|
|
3042
|
+
let killTimer = null;
|
|
3043
|
+
const timeoutTimer = setTimeout(() => finish(124), timeout);
|
|
3044
|
+
timeoutTimer.unref?.();
|
|
3045
|
+
const terminate = () => {
|
|
3046
|
+
if (exited) return;
|
|
3047
|
+
child.kill("SIGTERM");
|
|
3048
|
+
killTimer = setTimeout(() => {
|
|
3049
|
+
if (!exited) child.kill("SIGKILL");
|
|
3050
|
+
}, KILL_GRACE_MS);
|
|
3051
|
+
};
|
|
3052
|
+
const finish = (exitCode) => {
|
|
3053
|
+
if (resolved) return;
|
|
3054
|
+
resolved = true;
|
|
3055
|
+
clearTimeout(timeoutTimer);
|
|
3056
|
+
terminate();
|
|
3057
|
+
resolve({
|
|
3058
|
+
exitCode,
|
|
3059
|
+
stderr,
|
|
3060
|
+
stdout
|
|
3061
|
+
});
|
|
3062
|
+
};
|
|
3063
|
+
const checkPattern = () => {
|
|
3064
|
+
if (waitFor !== void 0 && !patternMatched && (stdout.includes(waitFor) || stderr.includes(waitFor))) {
|
|
3065
|
+
patternMatched = true;
|
|
3066
|
+
finish(0);
|
|
3067
|
+
}
|
|
3068
|
+
};
|
|
3069
|
+
child.stdout?.on("data", (data) => {
|
|
3070
|
+
stdout += data.toString();
|
|
3071
|
+
checkPattern();
|
|
3072
|
+
});
|
|
3073
|
+
child.stderr?.on("data", (data) => {
|
|
3074
|
+
stderr += data.toString();
|
|
3075
|
+
checkPattern();
|
|
3076
|
+
});
|
|
3077
|
+
child.on("exit", (code) => {
|
|
3078
|
+
exited = true;
|
|
3079
|
+
if (killTimer) clearTimeout(killTimer);
|
|
3080
|
+
if (patternMatched) return;
|
|
3081
|
+
if (waitFor === void 0) {
|
|
3082
|
+
finish(code ?? 1);
|
|
3083
|
+
return;
|
|
3084
|
+
}
|
|
3085
|
+
finish(code === 0 ? 1 : code ?? 1);
|
|
3086
|
+
});
|
|
3087
|
+
});
|
|
3088
|
+
}
|
|
3089
|
+
/**
|
|
3090
|
+
* Executes commands via Node.js child_process.
|
|
3091
|
+
* Uses `spawnSync` for one-shot commands and `spawn` for long-running
|
|
3092
|
+
* processes (`.exec(args, { waitFor, timeout })`).
|
|
3093
|
+
*
|
|
3094
|
+
* Both paths run the full command line through the shell — quoting behaves
|
|
3095
|
+
* identically whether or not `waitFor` is used.
|
|
3096
|
+
*
|
|
3097
|
+
* `spawnSync` is used (over the simpler `execSync`) so stdout AND stderr are
|
|
3098
|
+
* captured regardless of exit code. Many CLIs follow the Unix convention of
|
|
3099
|
+
* writing status banners to stderr on success — `execSync` would silently
|
|
3100
|
+
* discard them, leaving snapshot tests with no output to assert on.
|
|
3101
|
+
*/
|
|
3102
|
+
var ExecAdapter = class {
|
|
3103
|
+
command;
|
|
3104
|
+
constructor(command) {
|
|
3105
|
+
this.command = command;
|
|
3106
|
+
}
|
|
3107
|
+
async exec(args, cwd, extraEnv) {
|
|
3108
|
+
const env = buildEnv(extraEnv);
|
|
3109
|
+
const result = spawnSync(`${this.command} ${args}`, [], {
|
|
3110
|
+
cwd,
|
|
3111
|
+
encoding: "utf8",
|
|
3112
|
+
env,
|
|
3113
|
+
shell: true,
|
|
3114
|
+
stdio: [
|
|
3115
|
+
"pipe",
|
|
3116
|
+
"pipe",
|
|
3117
|
+
"pipe"
|
|
3118
|
+
]
|
|
3119
|
+
});
|
|
3120
|
+
return {
|
|
3121
|
+
exitCode: result.status ?? 1,
|
|
3122
|
+
stderr: result.stderr ?? "",
|
|
3123
|
+
stdout: result.stdout ?? ""
|
|
3124
|
+
};
|
|
3125
|
+
}
|
|
3126
|
+
async watch(args, cwd, options, extraEnv) {
|
|
3127
|
+
const env = buildEnv(extraEnv);
|
|
3128
|
+
const child = spawn(`${this.command} ${args}`, [], {
|
|
3129
|
+
cwd,
|
|
3130
|
+
detached: process.platform !== "win32",
|
|
3131
|
+
env,
|
|
3132
|
+
shell: true,
|
|
3133
|
+
stdio: [
|
|
3134
|
+
"pipe",
|
|
3135
|
+
"pipe",
|
|
3136
|
+
"pipe"
|
|
3137
|
+
]
|
|
3138
|
+
});
|
|
3139
|
+
const kill = (signal) => {
|
|
3140
|
+
if (child.pid !== void 0 && process.platform !== "win32") try {
|
|
3141
|
+
process.kill(-child.pid, signal ?? "SIGTERM");
|
|
3142
|
+
return true;
|
|
3143
|
+
} catch {}
|
|
3144
|
+
return child.kill(signal);
|
|
3145
|
+
};
|
|
3146
|
+
return observeProcess({
|
|
3147
|
+
kill,
|
|
3148
|
+
on: child.on.bind(child),
|
|
3149
|
+
stderr: child.stderr,
|
|
3150
|
+
stdout: child.stdout
|
|
3151
|
+
}, options);
|
|
3152
|
+
}
|
|
3153
|
+
};
|
|
3154
|
+
//#endregion
|
|
3155
|
+
//#region src/core/specification/cli/start-cli.ts
|
|
3156
|
+
async function startCli(bin, options = {}) {
|
|
3157
|
+
const callerDir = getCallerDir();
|
|
3158
|
+
await registerMatchers();
|
|
3159
|
+
const root = resolveRoot(options.root, callerDir);
|
|
3160
|
+
const resolvedBin = resolveCommand(bin, root);
|
|
3161
|
+
const services = options.services ?? {};
|
|
3162
|
+
const databaseKeys = declaredDatabaseKeys(services);
|
|
3163
|
+
let started = null;
|
|
3164
|
+
if (Object.keys(services).length > 0) started = await startServices(services, root);
|
|
3165
|
+
return {
|
|
3166
|
+
cleanup: async () => {
|
|
3167
|
+
await releaseIsolation(services);
|
|
3168
|
+
if (started) await started.orchestrator.stop();
|
|
3169
|
+
},
|
|
3170
|
+
cli: createCliFacet({
|
|
3171
|
+
command: new ExecAdapter(resolvedBin),
|
|
3172
|
+
database: started?.database,
|
|
3173
|
+
databaseKeys,
|
|
3174
|
+
databases: started?.databases,
|
|
3175
|
+
dockerConfig: options.docker,
|
|
3176
|
+
services: Object.keys(services).length > 0 ? services : void 0,
|
|
3177
|
+
transform: options.transform
|
|
3178
|
+
}),
|
|
3179
|
+
docker: createDockerReader(callerDir),
|
|
3180
|
+
orchestrator: started?.orchestrator ?? null
|
|
3181
|
+
};
|
|
3182
|
+
}
|
|
3183
|
+
//#endregion
|
|
3184
|
+
//#region src/core/specification/jobs/start-jobs.ts
|
|
3185
|
+
async function startJobs(options) {
|
|
3186
|
+
const callerDir = getCallerDir();
|
|
3187
|
+
await registerMatchers();
|
|
3188
|
+
const root = resolveRoot(options.root, callerDir);
|
|
3189
|
+
const services = options.services ?? {};
|
|
3190
|
+
const databaseKeys = declaredDatabaseKeys(services);
|
|
3191
|
+
let started = null;
|
|
3192
|
+
if (Object.keys(services).length > 0) started = await startServices(services, root);
|
|
3193
|
+
const jobHandles = typeof options.jobs === "function" ? options.jobs(services) : options.jobs;
|
|
3194
|
+
return {
|
|
3195
|
+
cleanup: async () => {
|
|
3196
|
+
await releaseIsolation(services);
|
|
3197
|
+
if (started) await started.orchestrator.stop();
|
|
3198
|
+
},
|
|
3199
|
+
jobs: createJobsFacet({
|
|
3200
|
+
database: started?.database,
|
|
3201
|
+
databaseKeys,
|
|
3202
|
+
databases: started?.databases,
|
|
3203
|
+
jobs: jobHandles
|
|
3204
|
+
}),
|
|
3205
|
+
orchestrator: started?.orchestrator ?? null
|
|
3206
|
+
};
|
|
3207
|
+
}
|
|
3208
|
+
//#endregion
|
|
3209
|
+
//#region src/core/specification/shared/specification.ts
|
|
3210
|
+
/**
|
|
3211
|
+
* The three specification constructors (CONVENTIONS A2) — created in a
|
|
3212
|
+
* `*.specification.ts` file under `specs/`, destructured with canonical
|
|
3213
|
+
* names, and cleaned up via `afterAll(cleanup)` (A1/A3/A4).
|
|
3214
|
+
*
|
|
3215
|
+
* @example
|
|
3216
|
+
* // specs/api/api.specification.ts
|
|
3217
|
+
* export const { api, cleanup } = await specification.api({
|
|
3218
|
+
* services: { db: postgres() },
|
|
3219
|
+
* server: ({ db }) => createApp({ databaseUrl: db.connectionString }),
|
|
3220
|
+
* });
|
|
3221
|
+
* afterAll(cleanup);
|
|
3222
|
+
*
|
|
3223
|
+
* // specs/jobs/jobs.specification.ts
|
|
3224
|
+
* export const { jobs, cleanup } = await specification.jobs({
|
|
3225
|
+
* services: { db: postgres() },
|
|
3226
|
+
* jobs: ({ db }) => [nightlyReport(db)],
|
|
3227
|
+
* });
|
|
3228
|
+
* afterAll(cleanup);
|
|
3229
|
+
*
|
|
3230
|
+
* // specs/setup/cli.specification.ts
|
|
3231
|
+
* export const { cli, cleanup } = await specification.cli('my-cli');
|
|
3232
|
+
* afterAll(cleanup);
|
|
3233
|
+
*/
|
|
3234
|
+
const specification = {
|
|
3235
|
+
/**
|
|
3236
|
+
* Test an HTTP app. Mode `'node'` (default) starts the declared services
|
|
3237
|
+
* via testcontainers and runs the app in-process; mode `'compose'` runs
|
|
3238
|
+
* `docker compose up` on `docker/compose.test.yaml` and sends real HTTP
|
|
3239
|
+
* requests to the app service. Resolution: `options.mode` > `TEST_MODE`
|
|
3240
|
+
* env var > `'node'`. Only `.api()` has a mode.
|
|
1396
3241
|
*/
|
|
1397
|
-
|
|
1398
|
-
for (const { container } of this.running) if (container) await container.stop();
|
|
1399
|
-
this.running = [];
|
|
1400
|
-
this.started = false;
|
|
1401
|
-
}
|
|
3242
|
+
api: startApi,
|
|
1402
3243
|
/**
|
|
1403
|
-
*
|
|
1404
|
-
*
|
|
3244
|
+
* Test a command binary. Each spec runs in a fresh temp directory.
|
|
3245
|
+
*
|
|
3246
|
+
* @param bin - Path to the binary (resolved from node_modules/.bin or PATH).
|
|
1405
3247
|
*/
|
|
1406
|
-
|
|
1407
|
-
const composePath = findComposeFile(this.root);
|
|
1408
|
-
if (!composePath) throw new Error(`E2E: no compose file found in ${this.root}`);
|
|
1409
|
-
const startTime = Date.now();
|
|
1410
|
-
const composeDir = dirname(composePath);
|
|
1411
|
-
const composeConfig = parseComposeFile(composePath);
|
|
1412
|
-
this.composeStack = new ComposeStackAdapter(composePath, this.projectName);
|
|
1413
|
-
await this.composeStack.start();
|
|
1414
|
-
for (const service of composeConfig.infraServices) {
|
|
1415
|
-
const type = detectServiceType(service.image);
|
|
1416
|
-
if (type === "postgres") {
|
|
1417
|
-
const handle = postgres({
|
|
1418
|
-
compose: service.name,
|
|
1419
|
-
env: service.environment
|
|
1420
|
-
});
|
|
1421
|
-
const port = this.composeStack.getMappedPort(service.name, 5432);
|
|
1422
|
-
handle.connectionString = handle.buildConnectionString("localhost", port);
|
|
1423
|
-
await handle.initialize(composeDir);
|
|
1424
|
-
handle.started = true;
|
|
1425
|
-
this.composeHandles.push(handle);
|
|
1426
|
-
} else if (type === "redis") {
|
|
1427
|
-
const handle = redis({ compose: service.name });
|
|
1428
|
-
const port = this.composeStack.getMappedPort(service.name, 6379);
|
|
1429
|
-
handle.connectionString = handle.buildConnectionString("localhost", port);
|
|
1430
|
-
handle.started = true;
|
|
1431
|
-
this.composeHandles.push(handle);
|
|
1432
|
-
}
|
|
1433
|
-
}
|
|
1434
|
-
const durationMs = Date.now() - startTime;
|
|
1435
|
-
const output = formatStartupReport("e2e", this.composeHandles.map((h) => ({
|
|
1436
|
-
name: h.composeName ?? h.type,
|
|
1437
|
-
type: h.type,
|
|
1438
|
-
connectionString: h.connectionString,
|
|
1439
|
-
durationMs
|
|
1440
|
-
})), {
|
|
1441
|
-
type: "http",
|
|
1442
|
-
url: this.getAppUrl() ?? void 0
|
|
1443
|
-
});
|
|
1444
|
-
console.log(output);
|
|
1445
|
-
}
|
|
3248
|
+
cli: startCli,
|
|
1446
3249
|
/**
|
|
1447
|
-
*
|
|
3250
|
+
* Test background jobs. Jobs run in-process by definition — no HTTP
|
|
3251
|
+
* server, no mode. `.trigger(name)` is the terminal action.
|
|
1448
3252
|
*/
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
3253
|
+
jobs: startJobs
|
|
3254
|
+
};
|
|
3255
|
+
//#endregion
|
|
3256
|
+
//#region src/integrations/sqlite/sqlite.ts
|
|
3257
|
+
var SqliteHandle = class {
|
|
3258
|
+
type = "sqlite";
|
|
3259
|
+
composeName = null;
|
|
3260
|
+
defaultPort = 0;
|
|
3261
|
+
defaultImage = "";
|
|
3262
|
+
environment = {};
|
|
3263
|
+
connectionString = "";
|
|
3264
|
+
started = false;
|
|
3265
|
+
db = null;
|
|
3266
|
+
templatePath = "";
|
|
3267
|
+
workerDbPath = "";
|
|
3268
|
+
initSql;
|
|
3269
|
+
prismaSchema;
|
|
3270
|
+
constructor(options = {}) {
|
|
3271
|
+
this.initSql = options.init ?? null;
|
|
3272
|
+
this.prismaSchema = options.prismaSchema ?? null;
|
|
3273
|
+
}
|
|
3274
|
+
buildConnectionString() {
|
|
3275
|
+
return `file:${this.workerDbPath || this.templatePath}`;
|
|
3276
|
+
}
|
|
3277
|
+
createDatabaseAdapter() {
|
|
3278
|
+
return this;
|
|
3279
|
+
}
|
|
3280
|
+
async healthcheck() {}
|
|
3281
|
+
async initialize() {
|
|
3282
|
+
this.templatePath = resolve(tmpdir(), "jterrazz-test-sqlite-template.sqlite");
|
|
3283
|
+
const lockPath = `${this.templatePath}.lock`;
|
|
3284
|
+
if (existsSync(lockPath)) {
|
|
3285
|
+
const start = Date.now();
|
|
3286
|
+
while (existsSync(lockPath) && Date.now() - start < 3e4) await new Promise((r) => setTimeout(r, 100));
|
|
1453
3287
|
}
|
|
1454
|
-
this.
|
|
3288
|
+
if (existsSync(this.templatePath)) {
|
|
3289
|
+
this.connectionString = `file:${this.templatePath}`;
|
|
3290
|
+
this.started = true;
|
|
3291
|
+
return;
|
|
3292
|
+
}
|
|
3293
|
+
const { writeFileSync } = await import("node:fs");
|
|
3294
|
+
writeFileSync(lockPath, process.pid.toString());
|
|
3295
|
+
if (this.prismaSchema) {
|
|
3296
|
+
const { execSync } = await import("node:child_process");
|
|
3297
|
+
execSync("npx prisma db push --force-reset", {
|
|
3298
|
+
env: {
|
|
3299
|
+
...process.env,
|
|
3300
|
+
DATABASE_URL: `file:${this.templatePath}`,
|
|
3301
|
+
PRISMA_USER_CONSENT_FOR_DANGEROUS_AI_ACTION: "yes"
|
|
3302
|
+
},
|
|
3303
|
+
stdio: "pipe"
|
|
3304
|
+
});
|
|
3305
|
+
const tmpDb = new Database(this.templatePath);
|
|
3306
|
+
tmpDb.pragma("wal_checkpoint(TRUNCATE)");
|
|
3307
|
+
tmpDb.close();
|
|
3308
|
+
} else if (this.initSql) {
|
|
3309
|
+
const sql = readFileSync(this.initSql, "utf8");
|
|
3310
|
+
const templateDb = new Database(this.templatePath);
|
|
3311
|
+
templateDb.exec(sql);
|
|
3312
|
+
templateDb.close();
|
|
3313
|
+
} else new Database(this.templatePath).close();
|
|
3314
|
+
try {
|
|
3315
|
+
unlinkSync(lockPath);
|
|
3316
|
+
} catch {}
|
|
3317
|
+
this.connectionString = `file:${this.templatePath}`;
|
|
3318
|
+
this.started = true;
|
|
1455
3319
|
}
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
if (serviceName && handle.composeName !== serviceName) continue;
|
|
1462
|
-
const adapter = handle.createDatabaseAdapter();
|
|
1463
|
-
if (adapter) return adapter;
|
|
3320
|
+
getDb() {
|
|
3321
|
+
const dbPath = this.workerDbPath || this.templatePath;
|
|
3322
|
+
if (!this.db) {
|
|
3323
|
+
this.db = new Database(dbPath);
|
|
3324
|
+
this.db.pragma("journal_mode = WAL");
|
|
1464
3325
|
}
|
|
1465
|
-
return
|
|
3326
|
+
return this.db;
|
|
1466
3327
|
}
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
const map = /* @__PURE__ */ new Map();
|
|
1472
|
-
for (const handle of [...this.services, ...this.composeHandles]) {
|
|
1473
|
-
const adapter = handle.createDatabaseAdapter();
|
|
1474
|
-
if (adapter && handle.composeName) map.set(handle.composeName, adapter);
|
|
3328
|
+
closeDb() {
|
|
3329
|
+
if (this.db) {
|
|
3330
|
+
this.db.close();
|
|
3331
|
+
this.db = null;
|
|
1475
3332
|
}
|
|
1476
|
-
return map;
|
|
1477
3333
|
}
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
*/
|
|
1481
|
-
getAppUrl() {
|
|
1482
|
-
const composePath = findComposeFile(this.root);
|
|
1483
|
-
if (!composePath || !this.composeStack) return null;
|
|
1484
|
-
const appService = parseComposeFile(composePath).appService;
|
|
1485
|
-
if (!appService || appService.ports.length === 0) return null;
|
|
1486
|
-
return `http://localhost:${this.composeStack.getMappedPort(appService.name, appService.ports[0].container)}`;
|
|
3334
|
+
async seed(sql) {
|
|
3335
|
+
this.getDb().exec(sql);
|
|
1487
3336
|
}
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
/**
|
|
1492
|
-
* Resolve root — if relative, resolves from the caller's directory.
|
|
1493
|
-
*/
|
|
1494
|
-
function resolveProjectRoot(root) {
|
|
1495
|
-
if (!root) return process.cwd();
|
|
1496
|
-
if (isAbsolute(root)) return root;
|
|
1497
|
-
const stack = (/* @__PURE__ */ new Error("resolve root")).stack;
|
|
1498
|
-
if (stack) {
|
|
1499
|
-
const lines = stack.split("\n");
|
|
1500
|
-
for (const line of lines) {
|
|
1501
|
-
const match = line.match(/at\s+(?:.*?\()?(?:file:\/\/)?([^:)]+):\d+:\d+/);
|
|
1502
|
-
if (!match) continue;
|
|
1503
|
-
const filePath = match[1];
|
|
1504
|
-
if (filePath.includes("node_modules") || filePath.includes("/src/spec/")) continue;
|
|
1505
|
-
return resolve(filePath, "..", root);
|
|
1506
|
-
}
|
|
3337
|
+
async query(table, columns) {
|
|
3338
|
+
const columnList = columns.join(", ");
|
|
3339
|
+
return this.getDb().prepare(`SELECT ${columnList} FROM "${table}" ORDER BY 1`).all().map((row) => columns.map((col) => row[col]));
|
|
1507
3340
|
}
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
}
|
|
1521
|
-
|
|
1522
|
-
|
|
3341
|
+
async reset() {
|
|
3342
|
+
const db = this.getDb();
|
|
3343
|
+
const tables = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE '_prisma%' AND name != 'sqlite_sequence'`).all();
|
|
3344
|
+
for (const { name } of tables) db.exec(`DELETE FROM "${name}"`);
|
|
3345
|
+
}
|
|
3346
|
+
isolation() {
|
|
3347
|
+
return {
|
|
3348
|
+
acquire: async (workerId) => {
|
|
3349
|
+
this.closeDb();
|
|
3350
|
+
this.workerDbPath = resolve(tmpdir(), `test-worker-${workerId}-${Date.now()}.sqlite`);
|
|
3351
|
+
copyFileSync(this.templatePath, this.workerDbPath);
|
|
3352
|
+
this.connectionString = `file:${this.workerDbPath}`;
|
|
3353
|
+
},
|
|
3354
|
+
reset: async () => {
|
|
3355
|
+
await this.reset();
|
|
3356
|
+
},
|
|
3357
|
+
release: async () => {
|
|
3358
|
+
this.closeDb();
|
|
3359
|
+
if (this.workerDbPath && existsSync(this.workerDbPath)) unlinkSync(this.workerDbPath);
|
|
3360
|
+
this.workerDbPath = "";
|
|
3361
|
+
this.connectionString = `file:${this.templatePath}`;
|
|
3362
|
+
}
|
|
3363
|
+
};
|
|
3364
|
+
}
|
|
3365
|
+
};
|
|
1523
3366
|
/**
|
|
1524
|
-
* Create a
|
|
1525
|
-
*
|
|
1526
|
-
* @param target - What to test against: {@link app}, {@link stack}, or {@link command}.
|
|
1527
|
-
* @param options - Shared options: root directory, infrastructure services.
|
|
3367
|
+
* Create a SQLite service handle. Uses file-copy isolation for parallel tests.
|
|
1528
3368
|
*
|
|
1529
3369
|
* @example
|
|
1530
|
-
* //
|
|
1531
|
-
* const
|
|
3370
|
+
* // With Prisma schema
|
|
3371
|
+
* const db = sqlite({ prismaSchema: './prisma/schema' });
|
|
1532
3372
|
*
|
|
1533
|
-
* //
|
|
1534
|
-
* const
|
|
3373
|
+
* // With raw SQL init
|
|
3374
|
+
* const db = sqlite({ init: './schema.sql' });
|
|
1535
3375
|
*
|
|
1536
|
-
* //
|
|
1537
|
-
* const
|
|
3376
|
+
* // Empty database
|
|
3377
|
+
* const db = sqlite();
|
|
1538
3378
|
*/
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
case "app": return startApp(target, options);
|
|
1542
|
-
case "stack": return startStack(target, options);
|
|
1543
|
-
case "command": return startCommand(target, options);
|
|
1544
|
-
}
|
|
1545
|
-
}
|
|
1546
|
-
function getWorkerId() {
|
|
1547
|
-
return process.env.VITEST_POOL_ID ?? "0";
|
|
1548
|
-
}
|
|
1549
|
-
async function acquireIsolation(services) {
|
|
1550
|
-
const workerId = getWorkerId();
|
|
1551
|
-
for (const service of services) await service.isolation().acquire(workerId);
|
|
3379
|
+
function sqlite(options = {}) {
|
|
3380
|
+
return new SqliteHandle(options);
|
|
1552
3381
|
}
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
databases: databases.size > 0 ? databases : void 0,
|
|
1579
|
-
jobs,
|
|
1580
|
-
server: new HonoAdapter(honoApp)
|
|
1581
|
-
});
|
|
1582
|
-
runner.cleanup = async () => {
|
|
1583
|
-
await releaseIsolation(services);
|
|
1584
|
-
await orchestrator.stop();
|
|
1585
|
-
};
|
|
1586
|
-
runner.docker = (id) => new DockerAssertion(dockerContainer(id));
|
|
1587
|
-
runner.orchestrator = orchestrator;
|
|
1588
|
-
return runner;
|
|
3382
|
+
//#endregion
|
|
3383
|
+
//#region src/integrations/anthropic/anthropic.ts
|
|
3384
|
+
const ANTHROPIC_MESSAGES_URL = "https://api.anthropic.com/v1/messages";
|
|
3385
|
+
function matchesFilter(body, filter) {
|
|
3386
|
+
if (filter.model) {
|
|
3387
|
+
const model = body?.model;
|
|
3388
|
+
if (typeof filter.model === "string" && model !== filter.model) return false;
|
|
3389
|
+
if (filter.model instanceof RegExp && !filter.model.test(model ?? "")) return false;
|
|
3390
|
+
}
|
|
3391
|
+
if (filter.system) {
|
|
3392
|
+
const system = typeof body?.system === "string" ? body.system : "";
|
|
3393
|
+
if (typeof filter.system === "string" && !system.includes(filter.system)) return false;
|
|
3394
|
+
if (filter.system instanceof RegExp && !filter.system.test(system)) return false;
|
|
3395
|
+
}
|
|
3396
|
+
if (filter.user) {
|
|
3397
|
+
const userMsg = body?.messages?.find((m) => m.role === "user")?.content ?? "";
|
|
3398
|
+
const text = typeof userMsg === "string" ? userMsg : JSON.stringify(userMsg);
|
|
3399
|
+
if (typeof filter.user === "string" && !text.includes(filter.user)) return false;
|
|
3400
|
+
if (filter.user instanceof RegExp && !filter.user.test(text)) return false;
|
|
3401
|
+
}
|
|
3402
|
+
if (filter.tools) {
|
|
3403
|
+
const names = body?.tools?.map((t) => t.name).filter(Boolean) ?? [];
|
|
3404
|
+
if (!filter.tools.every((t) => names.includes(t))) return false;
|
|
3405
|
+
}
|
|
3406
|
+
return true;
|
|
1589
3407
|
}
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
});
|
|
1609
|
-
runner.cleanup = () => orchestrator.stopCompose();
|
|
1610
|
-
runner.docker = (id) => new DockerAssertion(dockerContainer(id));
|
|
1611
|
-
runner.orchestrator = orchestrator;
|
|
1612
|
-
return runner;
|
|
1613
|
-
}
|
|
1614
|
-
async function startCommand(target, options) {
|
|
1615
|
-
const root = resolveProjectRoot(options.root);
|
|
1616
|
-
const bin = resolveCommand(target.bin, root);
|
|
1617
|
-
const services = options.services ?? [];
|
|
1618
|
-
let orchestrator = null;
|
|
1619
|
-
let database;
|
|
1620
|
-
let databases;
|
|
1621
|
-
if (services.length) {
|
|
1622
|
-
orchestrator = new Orchestrator({
|
|
1623
|
-
mode: "integration",
|
|
1624
|
-
root,
|
|
1625
|
-
services
|
|
1626
|
-
});
|
|
1627
|
-
await orchestrator.start();
|
|
1628
|
-
await acquireIsolation(services);
|
|
1629
|
-
database = orchestrator.getDatabase() ?? void 0;
|
|
1630
|
-
const dbMap = orchestrator.getDatabases();
|
|
1631
|
-
databases = dbMap.size > 0 ? dbMap : void 0;
|
|
1632
|
-
}
|
|
1633
|
-
const runner = createSpecificationRunner({
|
|
1634
|
-
command: new ExecAdapter(bin),
|
|
1635
|
-
database,
|
|
1636
|
-
databases,
|
|
1637
|
-
fixturesRoot: root
|
|
1638
|
-
});
|
|
1639
|
-
runner.cleanup = async () => {
|
|
1640
|
-
await releaseIsolation(services);
|
|
1641
|
-
if (orchestrator) await orchestrator.stop();
|
|
3408
|
+
function buildReply(data) {
|
|
3409
|
+
return {
|
|
3410
|
+
status: 200,
|
|
3411
|
+
body: {
|
|
3412
|
+
id: "msg-test",
|
|
3413
|
+
type: "message",
|
|
3414
|
+
role: "assistant",
|
|
3415
|
+
content: [{
|
|
3416
|
+
type: "text",
|
|
3417
|
+
text: typeof data === "string" ? data : JSON.stringify(data)
|
|
3418
|
+
}],
|
|
3419
|
+
model: "claude-sonnet-4-20250514",
|
|
3420
|
+
stop_reason: "end_turn",
|
|
3421
|
+
usage: {
|
|
3422
|
+
input_tokens: 10,
|
|
3423
|
+
output_tokens: 10
|
|
3424
|
+
}
|
|
3425
|
+
}
|
|
1642
3426
|
};
|
|
1643
|
-
runner.docker = (id) => new DockerAssertion(dockerContainer(id));
|
|
1644
|
-
runner.orchestrator = orchestrator;
|
|
1645
|
-
return runner;
|
|
1646
3427
|
}
|
|
1647
|
-
//#endregion
|
|
1648
|
-
//#region src/spec/targets.ts
|
|
1649
3428
|
/**
|
|
1650
|
-
*
|
|
1651
|
-
* so you can wire connection strings into your app/DI container.
|
|
1652
|
-
*
|
|
1653
|
-
* @param factory - Function that receives services and returns a Hono app instance.
|
|
1654
|
-
*
|
|
1655
|
-
* @example
|
|
1656
|
-
* const db = postgres({ compose: 'db' });
|
|
1657
|
-
* await spec(
|
|
1658
|
-
* app((services) => createApp({ databaseUrl: services.db.connectionString })),
|
|
1659
|
-
* { services: [db] },
|
|
1660
|
-
* );
|
|
3429
|
+
* Anthropic API intercept helpers.
|
|
1661
3430
|
*/
|
|
1662
|
-
|
|
3431
|
+
const anthropic = {
|
|
3432
|
+
/**
|
|
3433
|
+
* Trigger: match Messages API requests, optionally routed through a
|
|
3434
|
+
* custom gateway URL. When used with a JSON fixture file, the data is
|
|
3435
|
+
* returned as-is (no wrapping) because Anthropic fixtures are typically
|
|
3436
|
+
* already in the Messages API response shape.
|
|
3437
|
+
*
|
|
3438
|
+
* @example
|
|
3439
|
+
* anthropic.messages()
|
|
3440
|
+
* anthropic.messages({ system: /classify/ })
|
|
3441
|
+
* anthropic.messages({ user: /classify/ }, GATEWAY)
|
|
3442
|
+
*/
|
|
3443
|
+
messages(filter, url) {
|
|
3444
|
+
return {
|
|
3445
|
+
adapter: "anthropic",
|
|
3446
|
+
method: "POST",
|
|
3447
|
+
url: url ?? ANTHROPIC_MESSAGES_URL,
|
|
3448
|
+
match: filter ? ({ body }) => matchesFilter(body, filter) : void 0,
|
|
3449
|
+
wrap(data) {
|
|
3450
|
+
if (data && typeof data === "object" && !Array.isArray(data)) return {
|
|
3451
|
+
status: 200,
|
|
3452
|
+
body: data
|
|
3453
|
+
};
|
|
3454
|
+
return buildReply(data);
|
|
3455
|
+
}
|
|
3456
|
+
};
|
|
3457
|
+
},
|
|
3458
|
+
/** Response: wrap data in Anthropic messages format. */
|
|
3459
|
+
reply: buildReply,
|
|
3460
|
+
/** Response: return an Anthropic error. */
|
|
3461
|
+
error(status, message) {
|
|
3462
|
+
return {
|
|
3463
|
+
status,
|
|
3464
|
+
body: {
|
|
3465
|
+
type: "error",
|
|
3466
|
+
error: {
|
|
3467
|
+
type: status === 429 ? "rate_limit_error" : "api_error",
|
|
3468
|
+
message: message ?? `Anthropic error (${status})`
|
|
3469
|
+
}
|
|
3470
|
+
}
|
|
3471
|
+
};
|
|
3472
|
+
},
|
|
3473
|
+
/** Response: simulate a timeout. */
|
|
3474
|
+
timeout() {
|
|
3475
|
+
return {
|
|
3476
|
+
status: 200,
|
|
3477
|
+
body: {},
|
|
3478
|
+
delay: 3e4
|
|
3479
|
+
};
|
|
3480
|
+
}
|
|
3481
|
+
};
|
|
3482
|
+
//#endregion
|
|
3483
|
+
//#region src/core/contracts/http.ts
|
|
3484
|
+
function wrapJson(data) {
|
|
1663
3485
|
return {
|
|
1664
|
-
|
|
1665
|
-
|
|
3486
|
+
status: 200,
|
|
3487
|
+
body: data
|
|
1666
3488
|
};
|
|
1667
3489
|
}
|
|
3490
|
+
function matchesBody(body, expected) {
|
|
3491
|
+
if (typeof expected === "string") return (typeof body === "string" ? body : JSON.stringify(body ?? "")).includes(expected);
|
|
3492
|
+
if (expected instanceof RegExp) {
|
|
3493
|
+
const text = typeof body === "string" ? body : JSON.stringify(body ?? "");
|
|
3494
|
+
return expected.test(text);
|
|
3495
|
+
}
|
|
3496
|
+
return structuralSubset(expected, body, new CaptureScope());
|
|
3497
|
+
}
|
|
3498
|
+
function matchesEntries(expected, lookup) {
|
|
3499
|
+
return Object.entries(expected).every(([key, value]) => {
|
|
3500
|
+
const actual = lookup(key);
|
|
3501
|
+
if (actual === void 0) return false;
|
|
3502
|
+
return value instanceof RegExp ? value.test(actual) : actual === value;
|
|
3503
|
+
});
|
|
3504
|
+
}
|
|
1668
3505
|
/**
|
|
1669
|
-
*
|
|
1670
|
-
*
|
|
1671
|
-
*
|
|
1672
|
-
* @param root - Project root containing `docker/compose.test.yaml`.
|
|
1673
|
-
*
|
|
1674
|
-
* @example
|
|
1675
|
-
* await spec(stack('../../'));
|
|
3506
|
+
* Build the `match` predicate for an HTTP trigger filter, or `undefined` when
|
|
3507
|
+
* no filter is supplied (fires on any URL/method match).
|
|
1676
3508
|
*/
|
|
1677
|
-
function
|
|
1678
|
-
return
|
|
1679
|
-
|
|
1680
|
-
|
|
3509
|
+
function buildMatch(filter) {
|
|
3510
|
+
if (!filter) return;
|
|
3511
|
+
return (request) => {
|
|
3512
|
+
if (filter.body !== void 0 && !matchesBody(request.body, filter.body)) return false;
|
|
3513
|
+
if (filter.headers && !matchesEntries(filter.headers, (key) => request.headers[key.toLowerCase()])) return false;
|
|
3514
|
+
if (filter.query) {
|
|
3515
|
+
let params;
|
|
3516
|
+
try {
|
|
3517
|
+
params = new URL(request.url).searchParams;
|
|
3518
|
+
} catch {
|
|
3519
|
+
return false;
|
|
3520
|
+
}
|
|
3521
|
+
if (!matchesEntries(filter.query, (key) => params.get(key) ?? void 0)) return false;
|
|
3522
|
+
}
|
|
3523
|
+
return true;
|
|
1681
3524
|
};
|
|
1682
3525
|
}
|
|
1683
3526
|
/**
|
|
1684
|
-
*
|
|
1685
|
-
*
|
|
1686
|
-
*
|
|
3527
|
+
* Generic HTTP intercept helpers for any URL. An optional {@link
|
|
3528
|
+
* HttpInterceptFilter} narrows matching by request body, headers, or query —
|
|
3529
|
+
* a request that hits the URL/method but fails the filter counts as unmatched
|
|
3530
|
+
* (strict intercepts, CONVENTIONS D7).
|
|
1687
3531
|
*
|
|
1688
3532
|
* @example
|
|
1689
|
-
*
|
|
3533
|
+
* .intercept(http.get('https://api.example.com/data'), 'http/response.json')
|
|
3534
|
+
* .intercept(http.post(URL, { body: { user: 'alice' } }), http.json({ ok: true }))
|
|
1690
3535
|
*/
|
|
1691
|
-
|
|
3536
|
+
const http = {
|
|
3537
|
+
get(url, filter) {
|
|
3538
|
+
return {
|
|
3539
|
+
adapter: "http",
|
|
3540
|
+
match: buildMatch(filter),
|
|
3541
|
+
method: "GET",
|
|
3542
|
+
url,
|
|
3543
|
+
wrap: wrapJson
|
|
3544
|
+
};
|
|
3545
|
+
},
|
|
3546
|
+
post(url, filter) {
|
|
3547
|
+
return {
|
|
3548
|
+
adapter: "http",
|
|
3549
|
+
match: buildMatch(filter),
|
|
3550
|
+
method: "POST",
|
|
3551
|
+
url,
|
|
3552
|
+
wrap: wrapJson
|
|
3553
|
+
};
|
|
3554
|
+
},
|
|
3555
|
+
put(url, filter) {
|
|
3556
|
+
return {
|
|
3557
|
+
adapter: "http",
|
|
3558
|
+
match: buildMatch(filter),
|
|
3559
|
+
method: "PUT",
|
|
3560
|
+
url,
|
|
3561
|
+
wrap: wrapJson
|
|
3562
|
+
};
|
|
3563
|
+
},
|
|
3564
|
+
delete(url, filter) {
|
|
3565
|
+
return {
|
|
3566
|
+
adapter: "http",
|
|
3567
|
+
match: buildMatch(filter),
|
|
3568
|
+
method: "DELETE",
|
|
3569
|
+
url,
|
|
3570
|
+
wrap: wrapJson
|
|
3571
|
+
};
|
|
3572
|
+
},
|
|
3573
|
+
any(url, filter) {
|
|
3574
|
+
return {
|
|
3575
|
+
adapter: "http",
|
|
3576
|
+
match: buildMatch(filter),
|
|
3577
|
+
method: "*",
|
|
3578
|
+
url,
|
|
3579
|
+
wrap: wrapJson
|
|
3580
|
+
};
|
|
3581
|
+
},
|
|
3582
|
+
/** Response: simple JSON success. */
|
|
3583
|
+
json(data, status = 200) {
|
|
3584
|
+
return {
|
|
3585
|
+
status,
|
|
3586
|
+
body: data
|
|
3587
|
+
};
|
|
3588
|
+
},
|
|
3589
|
+
/** Response: error with message. */
|
|
3590
|
+
error(status, message) {
|
|
3591
|
+
return {
|
|
3592
|
+
status,
|
|
3593
|
+
body: { error: message ?? `HTTP ${status}` }
|
|
3594
|
+
};
|
|
3595
|
+
}
|
|
3596
|
+
};
|
|
3597
|
+
//#endregion
|
|
3598
|
+
//#region src/integrations/openai/openai.ts
|
|
3599
|
+
const OPENAI_CHAT_URL = "https://api.openai.com/v1/chat/completions";
|
|
3600
|
+
const OPENAI_RESPONSES_URL = "https://api.openai.com/v1/responses";
|
|
3601
|
+
function matchesChatFilter(body, filter) {
|
|
3602
|
+
if (filter.model) {
|
|
3603
|
+
const model = body?.model;
|
|
3604
|
+
if (typeof filter.model === "string" && model !== filter.model) return false;
|
|
3605
|
+
if (filter.model instanceof RegExp && !filter.model.test(model ?? "")) return false;
|
|
3606
|
+
}
|
|
3607
|
+
if (filter.system) {
|
|
3608
|
+
const msg = body?.messages?.find((m) => m.role === "system")?.content ?? "";
|
|
3609
|
+
if (typeof filter.system === "string" && !msg.includes(filter.system)) return false;
|
|
3610
|
+
if (filter.system instanceof RegExp && !filter.system.test(msg)) return false;
|
|
3611
|
+
}
|
|
3612
|
+
if (filter.user) {
|
|
3613
|
+
const msg = body?.messages?.find((m) => m.role === "user")?.content ?? "";
|
|
3614
|
+
if (typeof filter.user === "string" && !msg.includes(filter.user)) return false;
|
|
3615
|
+
if (filter.user instanceof RegExp && !filter.user.test(msg)) return false;
|
|
3616
|
+
}
|
|
3617
|
+
if (filter.tools) {
|
|
3618
|
+
const names = body?.tools?.map((t) => t.function?.name).filter(Boolean) ?? [];
|
|
3619
|
+
if (!filter.tools.every((t) => names.includes(t))) return false;
|
|
3620
|
+
}
|
|
3621
|
+
if (filter.temperature !== void 0 && body?.temperature !== filter.temperature) return false;
|
|
3622
|
+
return true;
|
|
3623
|
+
}
|
|
3624
|
+
function matchesResponsesFilter(body, filter) {
|
|
3625
|
+
if (filter.model) {
|
|
3626
|
+
const model = body?.model;
|
|
3627
|
+
if (typeof filter.model === "string" && model !== filter.model) return false;
|
|
3628
|
+
if (filter.model instanceof RegExp && !filter.model.test(model ?? "")) return false;
|
|
3629
|
+
}
|
|
3630
|
+
if (filter.system) {
|
|
3631
|
+
const instructions = body?.instructions ?? "";
|
|
3632
|
+
const systemInput = body?.input?.find?.((m) => m.role === "system")?.content ?? "";
|
|
3633
|
+
const text = instructions || systemInput;
|
|
3634
|
+
if (typeof filter.system === "string" && !text.includes(filter.system)) return false;
|
|
3635
|
+
if (filter.system instanceof RegExp && !filter.system.test(text)) return false;
|
|
3636
|
+
}
|
|
3637
|
+
if (filter.user) {
|
|
3638
|
+
const msgs = (body?.input ?? []).filter((m) => m.role === "user").map((m) => typeof m.content === "string" ? m.content : JSON.stringify(m.content)).join(" ");
|
|
3639
|
+
if (typeof filter.user === "string" && !msgs.includes(filter.user)) return false;
|
|
3640
|
+
if (filter.user instanceof RegExp && !filter.user.test(msgs)) return false;
|
|
3641
|
+
}
|
|
3642
|
+
if (filter.tools) {
|
|
3643
|
+
const names = body?.tools?.map((t) => t.name ?? t.function?.name).filter(Boolean) ?? [];
|
|
3644
|
+
if (!filter.tools.every((t) => names.includes(t))) return false;
|
|
3645
|
+
}
|
|
3646
|
+
return true;
|
|
3647
|
+
}
|
|
3648
|
+
function buildChatReply(data) {
|
|
3649
|
+
const content = typeof data === "string" ? data : JSON.stringify(data);
|
|
1692
3650
|
return {
|
|
1693
|
-
|
|
1694
|
-
|
|
3651
|
+
status: 200,
|
|
3652
|
+
body: {
|
|
3653
|
+
id: "chatcmpl-test",
|
|
3654
|
+
object: "chat.completion",
|
|
3655
|
+
created: Math.floor(Date.now() / 1e3),
|
|
3656
|
+
model: "gpt-4o-test",
|
|
3657
|
+
choices: [{
|
|
3658
|
+
index: 0,
|
|
3659
|
+
message: {
|
|
3660
|
+
role: "assistant",
|
|
3661
|
+
content
|
|
3662
|
+
},
|
|
3663
|
+
finish_reason: "stop"
|
|
3664
|
+
}],
|
|
3665
|
+
usage: {
|
|
3666
|
+
prompt_tokens: 10,
|
|
3667
|
+
completion_tokens: 10,
|
|
3668
|
+
total_tokens: 20
|
|
3669
|
+
}
|
|
3670
|
+
}
|
|
1695
3671
|
};
|
|
1696
3672
|
}
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
database,
|
|
1723
|
-
databases,
|
|
1724
|
-
fixturesRoot: root
|
|
1725
|
-
});
|
|
1726
|
-
runner.cleanup = async () => {
|
|
1727
|
-
if (orchestrator) await orchestrator.stop();
|
|
3673
|
+
function buildResponsesReply(data) {
|
|
3674
|
+
const text = typeof data === "string" ? data : JSON.stringify(data);
|
|
3675
|
+
return {
|
|
3676
|
+
status: 200,
|
|
3677
|
+
body: {
|
|
3678
|
+
id: "resp-test",
|
|
3679
|
+
object: "response",
|
|
3680
|
+
created_at: Math.floor(Date.now() / 1e3),
|
|
3681
|
+
model: "gpt-4o-test",
|
|
3682
|
+
output: [{
|
|
3683
|
+
type: "message",
|
|
3684
|
+
id: "msg-test",
|
|
3685
|
+
role: "assistant",
|
|
3686
|
+
content: [{
|
|
3687
|
+
type: "output_text",
|
|
3688
|
+
text,
|
|
3689
|
+
annotations: []
|
|
3690
|
+
}]
|
|
3691
|
+
}],
|
|
3692
|
+
usage: {
|
|
3693
|
+
input_tokens: 10,
|
|
3694
|
+
output_tokens: 10,
|
|
3695
|
+
total_tokens: 20
|
|
3696
|
+
}
|
|
3697
|
+
}
|
|
1728
3698
|
};
|
|
1729
|
-
runner.orchestrator = orchestrator;
|
|
1730
|
-
return runner;
|
|
1731
3699
|
}
|
|
3700
|
+
/**
|
|
3701
|
+
* OpenAI API intercept helpers.
|
|
3702
|
+
*/
|
|
3703
|
+
const openai = {
|
|
3704
|
+
/**
|
|
3705
|
+
* Trigger: match Chat Completions API requests.
|
|
3706
|
+
*
|
|
3707
|
+
* @example
|
|
3708
|
+
* openai.chat() // any chat call
|
|
3709
|
+
* openai.chat({ model: 'gpt-4o' }) // specific model
|
|
3710
|
+
* openai.chat({ system: /classify/ }) // system prompt match
|
|
3711
|
+
*/
|
|
3712
|
+
chat(filter) {
|
|
3713
|
+
return {
|
|
3714
|
+
adapter: "openai",
|
|
3715
|
+
method: "POST",
|
|
3716
|
+
url: OPENAI_CHAT_URL,
|
|
3717
|
+
match: filter ? ({ body }) => matchesChatFilter(body, filter) : void 0,
|
|
3718
|
+
wrap: buildChatReply
|
|
3719
|
+
};
|
|
3720
|
+
},
|
|
3721
|
+
/**
|
|
3722
|
+
* Trigger: match Responses API requests (AI SDK v5+) with auto-wrapping.
|
|
3723
|
+
* When used with a JSON file, the data is automatically wrapped in the
|
|
3724
|
+
* Responses API envelope.
|
|
3725
|
+
*
|
|
3726
|
+
* @param filter - Optional body filters.
|
|
3727
|
+
* @param url - Custom gateway URL (default: api.openai.com).
|
|
3728
|
+
*
|
|
3729
|
+
* @example
|
|
3730
|
+
* openai.responses({ user: /Report Ingestion/ }, GATEWAY)
|
|
3731
|
+
*/
|
|
3732
|
+
responses(filter, url) {
|
|
3733
|
+
return {
|
|
3734
|
+
adapter: "openai",
|
|
3735
|
+
method: "POST",
|
|
3736
|
+
url: url ?? OPENAI_RESPONSES_URL,
|
|
3737
|
+
match: filter ? ({ body }) => matchesResponsesFilter(body, filter) : void 0,
|
|
3738
|
+
wrap: buildResponsesReply
|
|
3739
|
+
};
|
|
3740
|
+
},
|
|
3741
|
+
/**
|
|
3742
|
+
* Response: wrap data in Chat Completions format.
|
|
3743
|
+
*
|
|
3744
|
+
* @example
|
|
3745
|
+
* openai.reply({ categories: ['TECH'] })
|
|
3746
|
+
*/
|
|
3747
|
+
reply: buildChatReply,
|
|
3748
|
+
/** Response: return an OpenAI error. */
|
|
3749
|
+
error(status, message) {
|
|
3750
|
+
return {
|
|
3751
|
+
status,
|
|
3752
|
+
body: { error: {
|
|
3753
|
+
message: message ?? `OpenAI error (${status})`,
|
|
3754
|
+
type: status === 429 ? "rate_limit_error" : "api_error",
|
|
3755
|
+
code: status === 429 ? "rate_limit_exceeded" : null
|
|
3756
|
+
} }
|
|
3757
|
+
};
|
|
3758
|
+
},
|
|
3759
|
+
/** Response: return malformed content. */
|
|
3760
|
+
malformed(content) {
|
|
3761
|
+
return buildChatReply(content);
|
|
3762
|
+
},
|
|
3763
|
+
/** Response: simulate a timeout. */
|
|
3764
|
+
timeout() {
|
|
3765
|
+
return {
|
|
3766
|
+
status: 200,
|
|
3767
|
+
body: {},
|
|
3768
|
+
delay: 3e4
|
|
3769
|
+
};
|
|
3770
|
+
}
|
|
3771
|
+
};
|
|
1732
3772
|
//#endregion
|
|
1733
|
-
//#region src/
|
|
3773
|
+
//#region src/core/contracts/contract.ts
|
|
1734
3774
|
/**
|
|
1735
|
-
*
|
|
1736
|
-
*
|
|
3775
|
+
* Declare an intercept contract. Identity function — its value is the
|
|
3776
|
+
* enforced shape and the naming convention:
|
|
3777
|
+
*
|
|
3778
|
+
* @example
|
|
3779
|
+
* // specs/api/reports/contracts/classify-article.openai.ts
|
|
3780
|
+
* import { defineContract, openai } from '@jterrazz/test';
|
|
3781
|
+
*
|
|
3782
|
+
* export default defineContract({
|
|
3783
|
+
* trigger: openai.responses({ user: /Report Ingestion/, tools: ['classify'] }),
|
|
3784
|
+
* response: openai.reply({ categories: ['TECH'] }),
|
|
3785
|
+
* });
|
|
3786
|
+
*
|
|
3787
|
+
* // Dynamic — the response is computed from the observed request:
|
|
3788
|
+
* export default defineContract({
|
|
3789
|
+
* trigger: http.post('https://api.example.com/echo'),
|
|
3790
|
+
* response: (request) => http.json({ received: request.body }),
|
|
3791
|
+
* });
|
|
3792
|
+
*
|
|
3793
|
+
* // specs/api/reports/reports.test.ts
|
|
3794
|
+
* import classifyArticle from '../../spec/intercept/contracts/classify-article.openai.js';
|
|
3795
|
+
*
|
|
3796
|
+
* const result = await jobs.intercept(classifyArticle).trigger('report-ingestion');
|
|
1737
3797
|
*/
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
mode: "e2e",
|
|
1741
|
-
root: resolveProjectRoot(options.root),
|
|
1742
|
-
services: []
|
|
1743
|
-
});
|
|
1744
|
-
await orchestrator.startCompose();
|
|
1745
|
-
const appUrl = orchestrator.getAppUrl();
|
|
1746
|
-
if (!appUrl) throw new Error("E2E: could not detect app URL from compose. Ensure an app service with ports is defined.");
|
|
1747
|
-
const database = orchestrator.getDatabase() ?? void 0;
|
|
1748
|
-
const databases = orchestrator.getDatabases();
|
|
1749
|
-
const runner = createSpecificationRunner({
|
|
1750
|
-
database,
|
|
1751
|
-
databases: databases.size > 0 ? databases : void 0,
|
|
1752
|
-
server: new FetchAdapter(appUrl)
|
|
1753
|
-
});
|
|
1754
|
-
runner.cleanup = () => orchestrator.stopCompose();
|
|
1755
|
-
runner.orchestrator = orchestrator;
|
|
1756
|
-
return runner;
|
|
3798
|
+
function defineContract(contract) {
|
|
3799
|
+
return contract;
|
|
1757
3800
|
}
|
|
1758
3801
|
//#endregion
|
|
1759
|
-
//#region src/
|
|
3802
|
+
//#region src/vitest/mock-of.ts
|
|
1760
3803
|
/**
|
|
1761
|
-
* Create
|
|
1762
|
-
*
|
|
3804
|
+
* Create a deep mock proxy for a given type.
|
|
3805
|
+
* Wraps `vitest-mock-extended`'s `mockDeep` for convenient port mocking.
|
|
1763
3806
|
*/
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
}
|
|
3807
|
+
const mockOf = mockDeep;
|
|
3808
|
+
//#endregion
|
|
3809
|
+
//#region src/vitest/mock-of-date.ts
|
|
3810
|
+
/**
|
|
3811
|
+
* Freeze or reset the global Date for deterministic time-dependent tests.
|
|
3812
|
+
* Wraps the `mockdate` package.
|
|
3813
|
+
*/
|
|
3814
|
+
const mockOfDate = MockDatePackage;
|
|
3815
|
+
//#endregion
|
|
3816
|
+
//#region src/index.ts
|
|
3817
|
+
registerContainerIntegrations({
|
|
3818
|
+
createComposeStack: (composeFile, projectName) => new ComposeStackAdapter(composeFile, projectName),
|
|
3819
|
+
createContainer: (options) => new TestcontainersAdapter(options),
|
|
3820
|
+
parseComposeFile
|
|
3821
|
+
});
|
|
3822
|
+
registerComposeServiceFactory("postgres", (service) => postgres({
|
|
3823
|
+
composeService: service.name,
|
|
3824
|
+
env: service.environment
|
|
3825
|
+
}));
|
|
3826
|
+
registerComposeServiceFactory("redis", (service) => redis({ composeService: service.name }));
|
|
1783
3827
|
//#endregion
|
|
1784
|
-
export {
|
|
1785
|
-
|
|
1786
|
-
//# sourceMappingURL=index.js.map
|
|
3828
|
+
export { BaseResult, CliResult, ContainerAccessor, DirectoryAccessor, FilesystemAccessor, HttpResult, JsonAccessor, Matcher, Orchestrator, ResponseAccessor, TableAccessor, TextAccessor, anthropic, defineContract, findContainersByLabel, http, inspectContainer, match, mockOf, mockOfDate, openai, postgres, redis, removeContainers, specification, sqlite, text };
|