slicetest 0.4.0 → 0.6.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 +293 -7
- package/dist/app.js +2 -2
- package/dist/auth.d.ts +55 -0
- package/dist/auth.js +140 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +18 -6
- package/dist/config.d.ts +72 -5
- package/dist/config.js +74 -6
- package/dist/containers.d.ts +2 -0
- package/dist/containers.js +20 -1
- package/dist/db.d.ts +28 -0
- package/dist/db.js +68 -0
- package/dist/doctor.js +17 -5
- package/dist/drivers/driver.d.ts +27 -0
- package/dist/drivers/mysql.d.ts +2 -1
- package/dist/drivers/mysql.js +38 -0
- package/dist/drivers/postgres.d.ts +2 -1
- package/dist/drivers/postgres.js +28 -0
- package/dist/drivers/sqlite.d.ts +2 -1
- package/dist/drivers/sqlite.js +52 -0
- package/dist/factory.d.ts +21 -0
- package/dist/factory.js +129 -0
- package/dist/form.d.ts +45 -0
- package/dist/form.js +226 -0
- package/dist/gen.d.ts +1 -0
- package/dist/gen.js +9 -4
- package/dist/global-setup.d.ts +1 -0
- package/dist/global-setup.js +90 -34
- package/dist/http.d.ts +24 -1
- package/dist/http.js +86 -9
- package/dist/index.d.ts +6 -1
- package/dist/index.js +3 -0
- package/dist/init.js +245 -24
- package/dist/intercept.d.ts +37 -0
- package/dist/intercept.js +199 -0
- package/dist/neon.d.ts +14 -0
- package/dist/neon.js +113 -0
- package/dist/openapi.d.ts +10 -0
- package/dist/openapi.js +22 -0
- package/dist/query-log.d.ts +49 -0
- package/dist/query-log.js +261 -0
- package/dist/runtime.d.ts +18 -0
- package/dist/runtime.js +163 -23
- package/dist/setup-file.js +19 -2
- package/dist/stub.d.ts +46 -0
- package/dist/stub.js +109 -0
- package/dist/vitest.d.ts +11 -1
- package/dist/vitest.js +27 -2
- package/dist/webhook.d.ts +40 -0
- package/dist/webhook.js +52 -0
- package/dist/x509.d.ts +37 -0
- package/dist/x509.js +150 -0
- package/dist/yaml-runtime.js +76 -16
- package/dist/yaml.d.ts +67 -1
- package/dist/yaml.js +87 -3
- package/package.json +12 -4
- package/preload/node-proxy.cjs +19 -0
- package/schema/scenario.schema.json +320 -0
package/dist/runtime.js
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { writeFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { App } from "./app.js";
|
|
6
|
+
import { Issuer } from "./auth.js";
|
|
5
7
|
import { Dependency } from "./containers.js";
|
|
6
|
-
import { Db, formatChanges } from "./db.js";
|
|
8
|
+
import { Db, formatChanges, noDatabase } from "./db.js";
|
|
7
9
|
import { engineFor } from "./drivers/index.js";
|
|
8
10
|
import { formatHistory, HttpClient } from "./http.js";
|
|
11
|
+
import { Interceptor } from "./intercept.js";
|
|
9
12
|
import { Mailbox } from "./mail.js";
|
|
10
|
-
import {
|
|
13
|
+
import { NEON_DOMAIN, NeonEndpoint, neonUrl } from "./neon.js";
|
|
14
|
+
import { appSpecFile, OpenApiSpec } from "./openapi.js";
|
|
15
|
+
import { QueryLog } from "./query-log.js";
|
|
11
16
|
import { Recorder } from "./recording.js";
|
|
12
17
|
import { Stub } from "./stub.js";
|
|
13
18
|
import { buildTrace, mask } from "./trace.js";
|
|
@@ -25,12 +30,16 @@ export class Runtime {
|
|
|
25
30
|
recordDir;
|
|
26
31
|
containers;
|
|
27
32
|
mailbox;
|
|
33
|
+
issuer;
|
|
34
|
+
queryLog;
|
|
35
|
+
interceptor;
|
|
36
|
+
neon;
|
|
28
37
|
#http;
|
|
29
38
|
/** Responses from the app that don't match its OpenAPI spec, this scenario. */
|
|
30
39
|
#contract = [];
|
|
31
40
|
/** Documented responses seen in this file, for the run's coverage report. */
|
|
32
41
|
#covered = new Set();
|
|
33
|
-
constructor(app, services, db, stubs, opts, vars, specs, coverageDir, recorders = new Map(), recordDir, containers = new Map(), mailbox) {
|
|
42
|
+
constructor(app, services, db, stubs, opts, vars, specs, coverageDir, recorders = new Map(), recordDir, containers = new Map(), mailbox, issuer, queryLog, interceptor, neon) {
|
|
34
43
|
this.app = app;
|
|
35
44
|
this.services = services;
|
|
36
45
|
this.db = db;
|
|
@@ -43,10 +52,16 @@ export class Runtime {
|
|
|
43
52
|
this.recordDir = recordDir;
|
|
44
53
|
this.containers = containers;
|
|
45
54
|
this.mailbox = mailbox;
|
|
55
|
+
this.issuer = issuer;
|
|
56
|
+
this.queryLog = queryLog;
|
|
57
|
+
this.interceptor = interceptor;
|
|
58
|
+
this.neon = neon;
|
|
46
59
|
this.#http = this.#client();
|
|
47
60
|
}
|
|
48
61
|
#client() {
|
|
49
62
|
const http = new HttpClient(this.app.url, this.opts.http);
|
|
63
|
+
for (const [host, name] of Object.entries(this.opts.intercept))
|
|
64
|
+
http.intercept(host, this.stubs.get(name).url);
|
|
50
65
|
const spec = this.specs.app;
|
|
51
66
|
if (spec) {
|
|
52
67
|
http.onResponse((res) => {
|
|
@@ -68,14 +83,18 @@ export class Runtime {
|
|
|
68
83
|
return this.#http;
|
|
69
84
|
}
|
|
70
85
|
static async start(opts, shared) {
|
|
71
|
-
const engine = await engineFor(opts);
|
|
72
|
-
const url = await ensureWorkerDatabase(engine, shared.adminUrl, shared.template, shared.prefix);
|
|
86
|
+
const engine = opts.db.none ? undefined : await engineFor(opts);
|
|
87
|
+
const url = engine ? await ensureWorkerDatabase(engine, shared.adminUrl, shared.template, shared.prefix) : "";
|
|
73
88
|
const stubs = new Map();
|
|
74
89
|
const recorders = new Map();
|
|
75
90
|
const containers = new Map();
|
|
76
91
|
const services = new Map();
|
|
77
92
|
let db;
|
|
78
93
|
let mailbox;
|
|
94
|
+
let issuer;
|
|
95
|
+
let queryLog;
|
|
96
|
+
let interceptor;
|
|
97
|
+
let neon;
|
|
79
98
|
try {
|
|
80
99
|
// Loaded first: a broken spec should fail before anything is started.
|
|
81
100
|
const specs = {
|
|
@@ -96,22 +115,41 @@ export class Runtime {
|
|
|
96
115
|
continue;
|
|
97
116
|
stub.fallback(async (call) => (await recorder?.answer(call)) ?? spec?.exampleResponse(call.method, call.path), recorder?.hint());
|
|
98
117
|
}
|
|
99
|
-
db =
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
118
|
+
db = engine
|
|
119
|
+
? await Db.connect(await engine.driver(url), url, {
|
|
120
|
+
schemas: opts.db.schemas,
|
|
121
|
+
keep: opts.db.keep,
|
|
122
|
+
seedFile: opts.db.seed && path.resolve(opts.root, opts.db.seed),
|
|
123
|
+
})
|
|
124
|
+
: noDatabase();
|
|
104
125
|
const started = await Promise.allSettled(Object.entries(opts.containers).map(async ([name, c]) => containers.set(name, await Dependency.start(name, c))));
|
|
105
126
|
const failed = started.find((r) => r.status === "rejected");
|
|
106
127
|
if (failed)
|
|
107
128
|
throw failed.reason;
|
|
108
|
-
const vars = { "db.url": url };
|
|
109
|
-
if (engine.name
|
|
129
|
+
const vars = engine ? { "db.url": url } : {};
|
|
130
|
+
if (engine && opts.db.queries && engine.name !== "sqlite") {
|
|
131
|
+
const u = new URL(url);
|
|
132
|
+
queryLog = await QueryLog.start(engine.name, { host: u.hostname, port: Number(u.port || (engine.name === "mysql" ? 3306 : 5432)) });
|
|
133
|
+
vars["db.url"] = queryLog.proxyUrl(url);
|
|
134
|
+
db.attachQueryLog(queryLog);
|
|
135
|
+
}
|
|
136
|
+
if (engine?.name === "sqlite")
|
|
110
137
|
vars["db.path"] = (await import("./drivers/sqlite.js")).sqlitePath(url);
|
|
138
|
+
if (engine)
|
|
139
|
+
Object.assign(vars, connectionVars(engine.name, vars["db.url"], vars["db.path"]));
|
|
140
|
+
if (opts.db.neon) {
|
|
141
|
+
// Queries the app sends to Neon's HTTP API run on the worker database (through the db.queries proxy, if on).
|
|
142
|
+
neon = await NeonEndpoint.start(vars["db.url"]);
|
|
143
|
+
vars["db.url"] = neonUrl(url);
|
|
144
|
+
}
|
|
111
145
|
if (opts.mail) {
|
|
112
146
|
mailbox = await Mailbox.start();
|
|
113
147
|
Object.assign(vars, { "mail.host": mailbox.host, "mail.port": String(mailbox.port), "mail.url": mailbox.url });
|
|
114
148
|
}
|
|
149
|
+
if (opts.auth) {
|
|
150
|
+
issuer = await Issuer.start(opts.auth);
|
|
151
|
+
Object.assign(vars, { "auth.issuer": issuer.url, "auth.jwks": issuer.jwksUrl, "auth.audience": issuer.audience, "auth.publicKey": issuer.publicKeyPem });
|
|
152
|
+
}
|
|
115
153
|
for (const [name, c] of containers) {
|
|
116
154
|
vars[`container.${name}`] = c.address;
|
|
117
155
|
vars[`container.${name}.host`] = c.host;
|
|
@@ -119,6 +157,22 @@ export class Runtime {
|
|
|
119
157
|
}
|
|
120
158
|
for (const [name, stub] of stubs)
|
|
121
159
|
vars[`stub.${name}`] = stub.url;
|
|
160
|
+
if (Object.keys(opts.intercept).length > 0 || opts.offline || neon) {
|
|
161
|
+
const routes = new Map(Object.entries(opts.intercept).map(([host, name]) => {
|
|
162
|
+
const stub = stubs.get(name);
|
|
163
|
+
return [host, { attach: (s) => stub.attach(s), port: stub.port }];
|
|
164
|
+
}));
|
|
165
|
+
if (neon) {
|
|
166
|
+
const endpoint = neon;
|
|
167
|
+
routes.set(`*.${NEON_DOMAIN}`, { attach: (s) => endpoint.attach(s), port: endpoint.port });
|
|
168
|
+
}
|
|
169
|
+
interceptor = await Interceptor.start(routes);
|
|
170
|
+
interceptor.offline = opts.offline;
|
|
171
|
+
Object.assign(vars, { "proxy.url": interceptor.url, "proxy.ca": interceptor.files.ca, "proxy.bundle": interceptor.files.bundle, "proxy.truststore": interceptor.files.trustStore });
|
|
172
|
+
// Every process gets the proxy settings; a key in its own `env` still wins.
|
|
173
|
+
const baseEnv = interceptor.env();
|
|
174
|
+
opts = { ...opts, app: { ...opts.app, baseEnv }, services: Object.fromEntries(Object.entries(opts.services).map(([n, s]) => [n, { ...s, baseEnv }])) };
|
|
175
|
+
}
|
|
122
176
|
for (const [name, service] of Object.entries(opts.services)) {
|
|
123
177
|
const started = await App.start(service, opts.root, vars, `service.${name}`);
|
|
124
178
|
services.set(name, started);
|
|
@@ -126,7 +180,9 @@ export class Runtime {
|
|
|
126
180
|
vars[`service.${name}.port`] = String(started.port);
|
|
127
181
|
}
|
|
128
182
|
const app = await App.start(opts.app, opts.root, vars);
|
|
129
|
-
|
|
183
|
+
if (opts.openapi.fromApp)
|
|
184
|
+
specs.app = await fetchAppSpec(app.url, opts.openapi.fromApp, shared.coverageDir);
|
|
185
|
+
return new Runtime(app, services, db, stubs, opts, vars, specs, shared.coverageDir, recorders, shared.recordDir, containers, mailbox, issuer, queryLog, interceptor, neon);
|
|
130
186
|
}
|
|
131
187
|
catch (e) {
|
|
132
188
|
await Promise.all([...services.values()].map((s) => s.stop()));
|
|
@@ -134,11 +190,18 @@ export class Runtime {
|
|
|
134
190
|
await Promise.all([...stubs.values()].map((s) => s.close()));
|
|
135
191
|
await Promise.allSettled([...containers.values()].map((c) => c.stop()));
|
|
136
192
|
await mailbox?.close();
|
|
193
|
+
await issuer?.close();
|
|
194
|
+
await queryLog?.close();
|
|
195
|
+
if (interceptor?.blocked.size && e instanceof Error)
|
|
196
|
+
e.message += `\n${blockedHint([...interceptor.blocked])}`;
|
|
197
|
+
await interceptor?.close();
|
|
198
|
+
await neon?.close();
|
|
137
199
|
throw e;
|
|
138
200
|
}
|
|
139
201
|
}
|
|
140
202
|
context() {
|
|
141
203
|
const mailbox = this.mailbox;
|
|
204
|
+
const issuer = this.issuer;
|
|
142
205
|
return {
|
|
143
206
|
http: this.http,
|
|
144
207
|
db: this.db,
|
|
@@ -169,6 +232,11 @@ export class Runtime {
|
|
|
169
232
|
throw new Error("slicetest: mail is off. Add `mail: true` to the config and point the app's SMTP settings at {{mail.host}} / {{mail.port}}.");
|
|
170
233
|
return mailbox;
|
|
171
234
|
},
|
|
235
|
+
get auth() {
|
|
236
|
+
if (!issuer)
|
|
237
|
+
throw new Error("slicetest: auth is off. Add `auth: true` to the config and point the app's JWT settings at {{auth.issuer}} / {{auth.jwks}}.");
|
|
238
|
+
return issuer;
|
|
239
|
+
},
|
|
172
240
|
};
|
|
173
241
|
}
|
|
174
242
|
/** The app and every service, for checks that apply to all of them. */
|
|
@@ -195,6 +263,10 @@ export class Runtime {
|
|
|
195
263
|
for (const recorder of this.recorders.values())
|
|
196
264
|
recorder.reset();
|
|
197
265
|
this.mailbox?.reset();
|
|
266
|
+
this.issuer?.reset();
|
|
267
|
+
this.queryLog?.reset();
|
|
268
|
+
this.interceptor?.passedThrough.clear();
|
|
269
|
+
this.interceptor?.blocked.clear();
|
|
198
270
|
await Promise.all([...this.containers.values()].map((c) => c.reset()));
|
|
199
271
|
this.http.reset();
|
|
200
272
|
for (const p of this.#processes())
|
|
@@ -204,6 +276,8 @@ export class Runtime {
|
|
|
204
276
|
async afterScenario() {
|
|
205
277
|
await Promise.all(this.#processes().map((p) => p.settle()));
|
|
206
278
|
this.#assertAlive();
|
|
279
|
+
if (this.interceptor?.blocked.size)
|
|
280
|
+
throw new Error(blockedHint([...this.interceptor.blocked]));
|
|
207
281
|
const unmatched = this.#unmatched();
|
|
208
282
|
if (unmatched.length > 0) {
|
|
209
283
|
throw new Error(`slicetest: the app called stubbed services with no matching route:\n${unmatched.join("\n")}`);
|
|
@@ -265,20 +339,32 @@ export class Runtime {
|
|
|
265
339
|
const unmatched = this.#unmatched();
|
|
266
340
|
if (unmatched.length > 0)
|
|
267
341
|
sections.push(`stub calls with no matching route:\n${unmatched.join("\n")}`);
|
|
342
|
+
const chaos = [...this.stubs.values()].map((s) => s.describeChaos()).filter((c) => c !== undefined);
|
|
343
|
+
if (chaos.length > 0)
|
|
344
|
+
sections.push(chaos.join("\n"));
|
|
268
345
|
const contract = this.#contractViolations();
|
|
269
346
|
if (contract.length > 0)
|
|
270
347
|
sections.push(`OpenAPI mismatches:\n${contract.map((c) => ` ${c}`).join("\n")}`);
|
|
271
348
|
if (this.http.history.length > 0)
|
|
272
349
|
sections.push(`requests to the app:\n${formatHistory(this.http.history)}`);
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
350
|
+
if (!this.opts.db.none)
|
|
351
|
+
try {
|
|
352
|
+
const changes = formatChanges(await this.db.changesSinceStart());
|
|
353
|
+
sections.push(changes ? `database changes during this scenario:\n${changes}` : "database changes during this scenario: (none)");
|
|
354
|
+
}
|
|
355
|
+
catch (e) {
|
|
356
|
+
sections.push(`database changes during this scenario: unavailable (${e.message})`);
|
|
357
|
+
}
|
|
280
358
|
if (this.mailbox)
|
|
281
359
|
sections.push(this.mailbox.describe());
|
|
360
|
+
if (this.interceptor?.passedThrough.size) {
|
|
361
|
+
sections.push(`outbound calls to hosts no stub intercepts (sent to the real host; add them to a stub's \`hosts\` to answer them):\n${[...this.interceptor.passedThrough].map((h) => ` ${h}`).join("\n")}`);
|
|
362
|
+
}
|
|
363
|
+
if (this.queryLog) {
|
|
364
|
+
const q = this.queryLog.queries();
|
|
365
|
+
const top = q.shapes().slice(0, 5).map((s) => ` ${s.count > 1 ? `×${s.count} ` : ""}${s.sql.length > 160 ? `${s.sql.slice(0, 157)}...` : s.sql}`);
|
|
366
|
+
sections.push(q.length ? `SQL the app ran during this scenario (${q.length} statements, most frequent first):\n${top.join("\n")}` : "SQL the app ran during this scenario: (none)");
|
|
367
|
+
}
|
|
282
368
|
const logs = this.app.scenarioLogs();
|
|
283
369
|
sections.push(logs ? `app output during this scenario:\n${logs}` : "app output during this scenario: (none)");
|
|
284
370
|
for (const [name, service] of this.services) {
|
|
@@ -287,15 +373,21 @@ export class Runtime {
|
|
|
287
373
|
}
|
|
288
374
|
return sections.join("\n\n");
|
|
289
375
|
}
|
|
290
|
-
|
|
376
|
+
/** Hand the coverage and recordings gathered so far to the run (merged when it ends). */
|
|
377
|
+
async flush() {
|
|
291
378
|
if (this.coverageDir && this.#covered.size > 0) {
|
|
292
379
|
await writeFile(path.join(this.coverageDir, `${process.pid}-${randomUUID()}.json`), JSON.stringify([...this.#covered])).catch(() => { });
|
|
380
|
+
this.#covered.clear();
|
|
293
381
|
}
|
|
294
382
|
for (const [name, recorder] of this.recorders) {
|
|
295
|
-
|
|
296
|
-
|
|
383
|
+
const added = recorder.added().splice(0);
|
|
384
|
+
if (this.recordDir && added.length > 0) {
|
|
385
|
+
await writeFile(path.join(this.recordDir, `${name}.${process.pid}-${randomUUID()}.json`), JSON.stringify(added)).catch(() => { });
|
|
297
386
|
}
|
|
298
387
|
}
|
|
388
|
+
}
|
|
389
|
+
async stop() {
|
|
390
|
+
await this.flush();
|
|
299
391
|
const results = await Promise.allSettled([
|
|
300
392
|
this.app.stop(),
|
|
301
393
|
...[...this.services.values()].map((s) => s.stop()),
|
|
@@ -303,6 +395,10 @@ export class Runtime {
|
|
|
303
395
|
...[...this.stubs.values()].map((s) => s.close()),
|
|
304
396
|
...[...this.containers.values()].map((c) => c.stop()),
|
|
305
397
|
this.mailbox?.close(),
|
|
398
|
+
this.issuer?.close(),
|
|
399
|
+
this.queryLog?.close(),
|
|
400
|
+
this.interceptor?.close(),
|
|
401
|
+
this.neon?.close(),
|
|
306
402
|
]);
|
|
307
403
|
const failed = results.find((r) => r.status === "rejected");
|
|
308
404
|
if (failed)
|
|
@@ -343,3 +439,47 @@ function parseJson(text) {
|
|
|
343
439
|
return undefined;
|
|
344
440
|
}
|
|
345
441
|
}
|
|
442
|
+
/**
|
|
443
|
+
* The parts of the database URL, for apps that don't take one URL: JDBC (Spring's
|
|
444
|
+
* `spring.datasource.url` plus username / password), or separate host / port / name settings.
|
|
445
|
+
*/
|
|
446
|
+
export function connectionVars(engine, url, sqlitePath) {
|
|
447
|
+
if (engine === "sqlite")
|
|
448
|
+
return { "db.jdbcUrl": `jdbc:sqlite:${sqlitePath}` };
|
|
449
|
+
const u = new URL(url);
|
|
450
|
+
const port = u.port || (engine === "mysql" ? "3306" : "5432");
|
|
451
|
+
const name = decodeURIComponent(u.pathname.replace(/^\//, ""));
|
|
452
|
+
return {
|
|
453
|
+
"db.host": u.hostname,
|
|
454
|
+
"db.port": port,
|
|
455
|
+
"db.name": name,
|
|
456
|
+
"db.user": decodeURIComponent(u.username),
|
|
457
|
+
"db.password": decodeURIComponent(u.password),
|
|
458
|
+
"db.jdbcUrl": `jdbc:${engine === "mysql" ? "mysql" : "postgresql"}://${u.hostname}:${port}/${encodeURIComponent(name)}`,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
/** The app's own spec, served at `route` (springdoc's /v3/api-docs, FastAPI's /openapi.json, …). */
|
|
462
|
+
async function fetchAppSpec(appUrl, route, coverageDir) {
|
|
463
|
+
let text;
|
|
464
|
+
try {
|
|
465
|
+
const res = await fetch(new URL(route, appUrl));
|
|
466
|
+
if (!res.ok)
|
|
467
|
+
throw new Error(`it answered ${res.status}`);
|
|
468
|
+
text = await res.text();
|
|
469
|
+
}
|
|
470
|
+
catch (e) {
|
|
471
|
+
throw new Error(`slicetest: openapi.fromApp: couldn't get the spec from GET ${route}: ${e.message}`);
|
|
472
|
+
}
|
|
473
|
+
const file = coverageDir ? appSpecFile(coverageDir) : path.join(os.tmpdir(), `slicetest-spec-${process.pid}.json`);
|
|
474
|
+
await writeFile(file, text);
|
|
475
|
+
return OpenApiSpec.load(file, `GET ${route}`);
|
|
476
|
+
}
|
|
477
|
+
/** Hosts package managers download from: a build tool fetching dependencies while it starts the app. */
|
|
478
|
+
const REGISTRIES = /(^|\.)(maven\.apache\.org|repo1\.maven\.org|plugins\.gradle\.org|services\.gradle\.org|registry\.npmjs\.org|registry\.yarnpkg\.com|proxy\.golang\.org|sum\.golang\.org|pypi\.org|files\.pythonhosted\.org|crates\.io|rubygems\.org)$/;
|
|
479
|
+
export function blockedHint(hosts) {
|
|
480
|
+
const message = `slicetest: offline: the app tried to reach ${hosts.join(", ")}, which no stub answers. Add ${hosts.length > 1 ? "them" : "it"} to a stub's \`hosts\` (or remove \`offline\`).`;
|
|
481
|
+
const registries = hosts.filter((h) => REGISTRIES.test(h));
|
|
482
|
+
if (registries.length === 0)
|
|
483
|
+
return message;
|
|
484
|
+
return `${message}\n${registries.join(", ")} ${registries.length > 1 ? "are package registries" : "is a package registry"}: the command that starts the app (gradle bootRun, mvn spring-boot:run, go run, …) is downloading dependencies, through slicetest's proxy. Download them in \`app.build\` (./gradlew bootJar, mvn package), or start a built artifact (java -jar).`;
|
|
485
|
+
}
|
package/dist/setup-file.js
CHANGED
|
@@ -4,11 +4,28 @@ import { Runtime } from "./runtime.js";
|
|
|
4
4
|
import "./provided.js";
|
|
5
5
|
import "./matchers.js";
|
|
6
6
|
let runtime;
|
|
7
|
+
// With app.scope "worker" the runtime outlives the file: Vitest runs this worker's next
|
|
8
|
+
// files in the same module state (isolate: false), and the processes go when it exits.
|
|
9
|
+
const shared = globalThis;
|
|
7
10
|
beforeAll(async () => {
|
|
8
|
-
|
|
11
|
+
const options = inject("slicetestOptions");
|
|
12
|
+
if (options.app.scope === "worker") {
|
|
13
|
+
const starting = (shared.__slicetestRuntime ??= Runtime.start(options, inject("slicetestDb")));
|
|
14
|
+
starting.catch(() => {
|
|
15
|
+
if (shared.__slicetestRuntime === starting)
|
|
16
|
+
shared.__slicetestRuntime = undefined;
|
|
17
|
+
});
|
|
18
|
+
runtime = await starting;
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
runtime = await Runtime.start(options, inject("slicetestDb"));
|
|
22
|
+
}
|
|
9
23
|
setRuntime(runtime);
|
|
10
24
|
});
|
|
11
25
|
afterAll(async () => {
|
|
12
26
|
setRuntime(undefined);
|
|
13
|
-
|
|
27
|
+
if (inject("slicetestOptions").app.scope === "worker")
|
|
28
|
+
await runtime?.flush();
|
|
29
|
+
else
|
|
30
|
+
await runtime?.stop();
|
|
14
31
|
});
|
package/dist/stub.d.ts
CHANGED
|
@@ -19,6 +19,27 @@ export interface RecordedCall {
|
|
|
19
19
|
};
|
|
20
20
|
/** Answered by the fallback (e.g. an example from the provider's OpenAPI spec), not a registered route. */
|
|
21
21
|
fallback?: boolean;
|
|
22
|
+
/** The fault `chaos()` injected instead of the normal answer: `503`, `reset`. */
|
|
23
|
+
fault?: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Faults injected into a stub's answers, to test the app's retries, timeouts and
|
|
27
|
+
* fallbacks. Random faults come from a seeded generator, so a failing run can be
|
|
28
|
+
* replayed with the seed printed in the failure output.
|
|
29
|
+
*/
|
|
30
|
+
export interface ChaosOptions {
|
|
31
|
+
/** Fail the first `n` calls, then answer normally: the shape of a retry test. */
|
|
32
|
+
failFirst?: number;
|
|
33
|
+
/** Share of calls (0–1) answered with one of `statuses`. */
|
|
34
|
+
errorRate?: number;
|
|
35
|
+
/** Error statuses to pick from. Default [500, 502, 503]; 429 and 503 come with `Retry-After: 1`. */
|
|
36
|
+
statuses?: number[];
|
|
37
|
+
/** Share of calls (0–1) whose connection is dropped without an answer. */
|
|
38
|
+
networkErrorRate?: number;
|
|
39
|
+
/** Extra delay for every call, in ms: a fixed value or a [min, max] range. */
|
|
40
|
+
latency?: number | [number, number];
|
|
41
|
+
/** Seed for the random choices. Default: `SLICETEST_CHAOS_SEED`, else random. */
|
|
42
|
+
seed?: number;
|
|
22
43
|
}
|
|
23
44
|
export interface StubResponse {
|
|
24
45
|
status?: number;
|
|
@@ -26,6 +47,17 @@ export interface StubResponse {
|
|
|
26
47
|
/** Objects are sent as JSON. */
|
|
27
48
|
body?: unknown;
|
|
28
49
|
}
|
|
50
|
+
/** One server-sent event: `[event, data]`, or `{ event, data, id }`. Data that isn't a string is sent as JSON. */
|
|
51
|
+
export type ServerSentEvent = [event: string, data: unknown] | {
|
|
52
|
+
event?: string;
|
|
53
|
+
data: unknown;
|
|
54
|
+
id?: string;
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* A streaming reply in Server-Sent Events format, as LLM APIs stream (Anthropic, OpenAI):
|
|
58
|
+
* `stub("anthropic").on("POST", "/v1/messages").reply(sse([["message_start", {...}], ...]))`.
|
|
59
|
+
*/
|
|
60
|
+
export declare function sse(events: ServerSentEvent[], init?: Omit<StubResponse, "body">): StubResponse;
|
|
29
61
|
export type Responder = StubResponse | ((call: RecordedCall) => StubResponse | Promise<StubResponse>);
|
|
30
62
|
/**
|
|
31
63
|
* Extra conditions a call must meet for a route to answer it. Plain values are
|
|
@@ -64,12 +96,26 @@ export declare class Stub {
|
|
|
64
96
|
readonly name: string;
|
|
65
97
|
url: string;
|
|
66
98
|
private constructor();
|
|
99
|
+
/** Serve a connection that arrived elsewhere, e.g. TLS the intercepting proxy terminated. */
|
|
100
|
+
attach(socket: import("node:net").Socket): void;
|
|
101
|
+
get port(): number;
|
|
67
102
|
static start(name: string): Promise<Stub>;
|
|
68
103
|
/**
|
|
69
104
|
* Answer `method path`. `path` may contain `:name` segments (captured into
|
|
70
105
|
* `call.params`) or be a RegExp; `method` may be `*`. Later routes win.
|
|
71
106
|
*/
|
|
72
107
|
on(method: string, path: string | RegExp, match?: MatchOptions): RouteBuilder;
|
|
108
|
+
/**
|
|
109
|
+
* Inject faults into this stub's answers for the rest of the scenario:
|
|
110
|
+
* `stub("payments").chaos({ failFirst: 2 })` to test a retry,
|
|
111
|
+
* `chaos({ errorRate: 0.3, latency: [50, 200] })` to test resilience.
|
|
112
|
+
* Calls that fault don't use up `once()` / `times()` routes.
|
|
113
|
+
*/
|
|
114
|
+
chaos(opts: ChaosOptions): this;
|
|
115
|
+
/** Calls `chaos()` answered with a fault. */
|
|
116
|
+
faults(): RecordedCall[];
|
|
117
|
+
/** The active `chaos()` settings and what they did, for failure output; undefined when off. */
|
|
118
|
+
describeChaos(): string | undefined;
|
|
73
119
|
/** Calls received so far, optionally filtered by method, path and conditions (same syntax as `on()`). */
|
|
74
120
|
calls(method?: string, path?: string | RegExp, match?: MatchOptions): RecordedCall[];
|
|
75
121
|
unmatched(): RecordedCall[];
|
package/dist/stub.js
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
import http from "node:http";
|
|
2
2
|
import { isDeepStrictEqual } from "node:util";
|
|
3
|
+
/**
|
|
4
|
+
* A streaming reply in Server-Sent Events format, as LLM APIs stream (Anthropic, OpenAI):
|
|
5
|
+
* `stub("anthropic").on("POST", "/v1/messages").reply(sse([["message_start", {...}], ...]))`.
|
|
6
|
+
*/
|
|
7
|
+
export function sse(events, init = {}) {
|
|
8
|
+
const body = events
|
|
9
|
+
.map((e) => {
|
|
10
|
+
const { event, data, id } = Array.isArray(e) ? { event: e[0], data: e[1], id: undefined } : e;
|
|
11
|
+
const text = typeof data === "string" ? data : JSON.stringify(data);
|
|
12
|
+
const lines = [...(id !== undefined ? [`id: ${id}`] : []), ...(event ? [`event: ${event}`] : []), ...text.split("\n").map((l) => `data: ${l}`)];
|
|
13
|
+
return `${lines.join("\n")}\n\n`;
|
|
14
|
+
})
|
|
15
|
+
.join("");
|
|
16
|
+
return { status: init.status ?? 200, headers: { "content-type": "text/event-stream", "cache-control": "no-cache", ...init.headers }, body };
|
|
17
|
+
}
|
|
3
18
|
/**
|
|
4
19
|
* A fake outbound service. The app is pointed at `url`; tests register routes
|
|
5
20
|
* with `on()` and inspect what the app sent with `calls()`.
|
|
@@ -12,6 +27,7 @@ export class Stub {
|
|
|
12
27
|
#fallback;
|
|
13
28
|
/** Appended to the 501 answer for a call nothing could answer. */
|
|
14
29
|
#hint;
|
|
30
|
+
#chaos;
|
|
15
31
|
url = "";
|
|
16
32
|
constructor(name) {
|
|
17
33
|
this.name = name;
|
|
@@ -20,6 +36,13 @@ export class Stub {
|
|
|
20
36
|
this.#handle(req, res).catch(() => res.destroy());
|
|
21
37
|
});
|
|
22
38
|
}
|
|
39
|
+
/** Serve a connection that arrived elsewhere, e.g. TLS the intercepting proxy terminated. */
|
|
40
|
+
attach(socket) {
|
|
41
|
+
this.#server.emit("connection", socket);
|
|
42
|
+
}
|
|
43
|
+
get port() {
|
|
44
|
+
return Number(new URL(this.url).port);
|
|
45
|
+
}
|
|
23
46
|
static async start(name) {
|
|
24
47
|
const stub = new Stub(name);
|
|
25
48
|
await new Promise((resolve) => stub.#server.listen(0, "127.0.0.1", resolve));
|
|
@@ -61,6 +84,74 @@ export class Stub {
|
|
|
61
84
|
};
|
|
62
85
|
return builder;
|
|
63
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Inject faults into this stub's answers for the rest of the scenario:
|
|
89
|
+
* `stub("payments").chaos({ failFirst: 2 })` to test a retry,
|
|
90
|
+
* `chaos({ errorRate: 0.3, latency: [50, 200] })` to test resilience.
|
|
91
|
+
* Calls that fault don't use up `once()` / `times()` routes.
|
|
92
|
+
*/
|
|
93
|
+
chaos(opts) {
|
|
94
|
+
for (const key of ["errorRate", "networkErrorRate"]) {
|
|
95
|
+
const v = opts[key];
|
|
96
|
+
if (v !== undefined && !(v >= 0 && v <= 1))
|
|
97
|
+
throw new Error(`slicetest: chaos ${key} must be between 0 and 1, got ${v}`);
|
|
98
|
+
}
|
|
99
|
+
if (opts.statuses && (opts.statuses.length === 0 || opts.statuses.some((s) => !Number.isInteger(s) || s < 400 || s > 599))) {
|
|
100
|
+
throw new Error(`slicetest: chaos statuses must be 4xx/5xx codes, got ${JSON.stringify(opts.statuses)}`);
|
|
101
|
+
}
|
|
102
|
+
const envSeed = Number(process.env.SLICETEST_CHAOS_SEED);
|
|
103
|
+
const seed = opts.seed ?? (Number.isInteger(envSeed) ? envSeed : Math.floor(Math.random() * 2 ** 31));
|
|
104
|
+
this.#chaos = { opts, seed, random: mulberry32(seed), calls: 0 };
|
|
105
|
+
return this;
|
|
106
|
+
}
|
|
107
|
+
/** Calls `chaos()` answered with a fault. */
|
|
108
|
+
faults() {
|
|
109
|
+
return this.#calls.filter((c) => c.fault !== undefined);
|
|
110
|
+
}
|
|
111
|
+
/** The active `chaos()` settings and what they did, for failure output; undefined when off. */
|
|
112
|
+
describeChaos() {
|
|
113
|
+
const c = this.#chaos;
|
|
114
|
+
if (!c)
|
|
115
|
+
return undefined;
|
|
116
|
+
const settings = Object.entries(c.opts)
|
|
117
|
+
.filter(([k]) => k !== "seed")
|
|
118
|
+
.map(([k, v]) => `${k} ${JSON.stringify(v)}`)
|
|
119
|
+
.join(", ");
|
|
120
|
+
return `chaos on ${this.name}: ${settings}; ${this.faults().length} of ${c.calls} calls faulted. Replay with SLICETEST_CHAOS_SEED=${c.seed}`;
|
|
121
|
+
}
|
|
122
|
+
/** Delay and fault for the next call, consuming the random sequence in a fixed order. */
|
|
123
|
+
async #injectFault() {
|
|
124
|
+
const c = this.#chaos;
|
|
125
|
+
if (!c)
|
|
126
|
+
return undefined;
|
|
127
|
+
const { opts, random } = c;
|
|
128
|
+
const n = ++c.calls;
|
|
129
|
+
const lat = opts.latency;
|
|
130
|
+
const ms = lat === undefined ? 0 : Array.isArray(lat) ? lat[0] + Math.floor(random() * (lat[1] - lat[0] + 1)) : lat;
|
|
131
|
+
if (ms > 0)
|
|
132
|
+
await new Promise((r) => setTimeout(r, ms));
|
|
133
|
+
const statuses = opts.statuses ?? [500, 502, 503];
|
|
134
|
+
const pick = () => statuses[Math.floor(random() * statuses.length)];
|
|
135
|
+
if (opts.failFirst !== undefined && n <= opts.failFirst)
|
|
136
|
+
return { status: pick() };
|
|
137
|
+
const roll = random();
|
|
138
|
+
if (roll < (opts.networkErrorRate ?? 0))
|
|
139
|
+
return "reset";
|
|
140
|
+
if (roll < (opts.networkErrorRate ?? 0) + (opts.errorRate ?? 0))
|
|
141
|
+
return { status: pick() };
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
#fail(call, req, res, fault) {
|
|
145
|
+
call.matched = true;
|
|
146
|
+
if (fault === "reset") {
|
|
147
|
+
call.fault = "reset";
|
|
148
|
+
req.socket.destroy();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
call.fault = String(fault.status);
|
|
152
|
+
const retry = fault.status === 429 || fault.status === 503 ? { "retry-after": "1" } : undefined;
|
|
153
|
+
this.#send(call, res, { status: fault.status, headers: retry, body: { error: "slicetest chaos", status: fault.status } });
|
|
154
|
+
}
|
|
64
155
|
/** Calls received so far, optionally filtered by method, path and conditions (same syntax as `on()`). */
|
|
65
156
|
calls(method, path, match = {}) {
|
|
66
157
|
const compiled = path === undefined ? undefined : compilePath(path);
|
|
@@ -82,6 +173,7 @@ export class Stub {
|
|
|
82
173
|
reset() {
|
|
83
174
|
this.#routes = [];
|
|
84
175
|
this.#calls = [];
|
|
176
|
+
this.#chaos = undefined;
|
|
85
177
|
}
|
|
86
178
|
/**
|
|
87
179
|
* Answer calls that no registered route matches, instead of failing with 501.
|
|
@@ -127,6 +219,12 @@ export class Stub {
|
|
|
127
219
|
route = r;
|
|
128
220
|
break;
|
|
129
221
|
}
|
|
222
|
+
// Faults only replace answers the stub would give: an unknown route still fails as unmatched.
|
|
223
|
+
if (route || this.#fallback) {
|
|
224
|
+
const fault = await this.#injectFault();
|
|
225
|
+
if (fault)
|
|
226
|
+
return this.#fail(call, req, res, fault);
|
|
227
|
+
}
|
|
130
228
|
if (!route) {
|
|
131
229
|
let out;
|
|
132
230
|
try {
|
|
@@ -262,3 +360,14 @@ function parseJson(body) {
|
|
|
262
360
|
return undefined;
|
|
263
361
|
}
|
|
264
362
|
}
|
|
363
|
+
/** A small seeded PRNG: the same seed gives the same faults on every run. */
|
|
364
|
+
function mulberry32(seed) {
|
|
365
|
+
let a = seed >>> 0;
|
|
366
|
+
return () => {
|
|
367
|
+
a = (a + 0x6d2b79f5) >>> 0;
|
|
368
|
+
let t = a;
|
|
369
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
370
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
371
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
372
|
+
};
|
|
373
|
+
}
|
package/dist/vitest.d.ts
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import type { Plugin } from "vite";
|
|
2
2
|
import { type SlicetestOptions } from "./config.js";
|
|
3
|
+
/**
|
|
4
|
+
* Reads a slicetest.config.yaml, so the CLI and a vitest.config share one file.
|
|
5
|
+
* `file` is relative to `root`; without it the first of CONFIG_NAMES in `root` is used.
|
|
6
|
+
* The CLI-only `include` is dropped.
|
|
7
|
+
*/
|
|
8
|
+
export declare function loadConfigFile(root: string, file?: string): SlicetestOptions;
|
|
3
9
|
/** YAML scenario files are picked up next to the regular test files. */
|
|
4
10
|
export declare const YAML_SCENARIOS = "**/*.scenario.{yaml,yml}";
|
|
5
|
-
|
|
11
|
+
/**
|
|
12
|
+
* The Vitest plugin. Pass the options, or the path of a slicetest.config.yaml (relative to the
|
|
13
|
+
* Vitest root), or nothing to read the slicetest.config.yaml next to the Vitest config.
|
|
14
|
+
*/
|
|
15
|
+
export declare function slicetest(options?: SlicetestOptions | string): Plugin;
|
|
6
16
|
export type { SlicetestOptions } from "./config.js";
|
package/dist/vitest.js
CHANGED
|
@@ -1,15 +1,34 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import { fileURLToPath } from "node:url";
|
|
3
4
|
import { configDefaults } from "vitest/config";
|
|
4
|
-
import { resolveOptions } from "./config.js";
|
|
5
|
+
import { CONFIG_NAMES, resolveOptions } from "./config.js";
|
|
5
6
|
import { SESSION_ENV } from "./record.js";
|
|
7
|
+
import { parse } from "yaml";
|
|
6
8
|
import { parseScenarioFile } from "./yaml.js";
|
|
9
|
+
/**
|
|
10
|
+
* Reads a slicetest.config.yaml, so the CLI and a vitest.config share one file.
|
|
11
|
+
* `file` is relative to `root`; without it the first of CONFIG_NAMES in `root` is used.
|
|
12
|
+
* The CLI-only `include` is dropped.
|
|
13
|
+
*/
|
|
14
|
+
export function loadConfigFile(root, file) {
|
|
15
|
+
const found = file ? path.resolve(root, file) : CONFIG_NAMES.map((n) => path.join(root, n)).find((p) => existsSync(p));
|
|
16
|
+
if (!found || !existsSync(found)) {
|
|
17
|
+
throw new Error(`slicetest: ${file ? `config file ${found} not found` : `no ${CONFIG_NAMES.join(" / ")} in ${root}; pass the options to slicetest({ ... }) or run "npx slicetest init"`}`);
|
|
18
|
+
}
|
|
19
|
+
const { include: _, ...options } = (parse(readFileSync(found, "utf8")) ?? {});
|
|
20
|
+
return options;
|
|
21
|
+
}
|
|
7
22
|
/** YAML scenario files are picked up next to the regular test files. */
|
|
8
23
|
export const YAML_SCENARIOS = "**/*.scenario.{yaml,yml}";
|
|
9
24
|
const YAML_ID = /\.scenario\.ya?ml$/;
|
|
10
25
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
11
26
|
// Resolve sibling modules with this file's own extension, so it works from both src (.ts) and dist (.js).
|
|
12
27
|
const ext = path.extname(fileURLToPath(import.meta.url));
|
|
28
|
+
/**
|
|
29
|
+
* The Vitest plugin. Pass the options, or the path of a slicetest.config.yaml (relative to the
|
|
30
|
+
* Vitest root), or nothing to read the slicetest.config.yaml next to the Vitest config.
|
|
31
|
+
*/
|
|
13
32
|
export function slicetest(options) {
|
|
14
33
|
let root;
|
|
15
34
|
return {
|
|
@@ -21,11 +40,17 @@ export function slicetest(options) {
|
|
|
21
40
|
// `slicetest record` runs only its session file.
|
|
22
41
|
if (!process.env[SESSION_ENV])
|
|
23
42
|
test.include = [...(test.include ?? configDefaults.include), YAML_SCENARIOS];
|
|
43
|
+
const resolved = resolveOptions(typeof options === "object" ? options : loadConfigFile(root, options), root);
|
|
24
44
|
return {
|
|
25
45
|
test: {
|
|
46
|
+
// One app per worker means module state (the running app) must survive from file to file.
|
|
47
|
+
...(resolved.app.scope === "worker" ? { isolate: false } : {}),
|
|
48
|
+
// Vitest runs projects with different maxWorkers in separate groups and wants a distinct
|
|
49
|
+
// groupOrder for each; one per worker count keeps slicetest projects out of each other's way.
|
|
50
|
+
...(resolved.workers ? { maxWorkers: resolved.workers, sequence: { groupOrder: 1000 + resolved.workers } } : {}),
|
|
26
51
|
globalSetup: [path.join(here, `global-setup${ext}`)],
|
|
27
52
|
setupFiles: [path.join(here, `setup-file${ext}`)],
|
|
28
|
-
provide: { slicetestOptions:
|
|
53
|
+
provide: { slicetestOptions: resolved },
|
|
29
54
|
hookTimeout: 120_000,
|
|
30
55
|
},
|
|
31
56
|
};
|