slicetest 0.1.0 → 0.3.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 +237 -14
- package/dist/app.d.ts +26 -3
- package/dist/app.js +80 -18
- package/dist/cli.js +44 -6
- package/dist/config.d.ts +114 -11
- package/dist/config.js +97 -22
- package/dist/containers.d.ts +22 -0
- package/dist/containers.js +59 -0
- package/dist/db.d.ts +37 -5
- package/dist/db.js +149 -81
- package/dist/drivers/driver.d.ts +65 -0
- package/dist/drivers/driver.js +6 -0
- package/dist/drivers/index.d.ts +5 -0
- package/dist/drivers/index.js +14 -0
- package/dist/drivers/mysql.d.ts +29 -0
- package/dist/drivers/mysql.js +270 -0
- package/dist/drivers/postgres.d.ts +19 -0
- package/dist/drivers/postgres.js +164 -0
- package/dist/gen.d.ts +24 -0
- package/dist/gen.js +116 -0
- package/dist/global-setup.d.ts +7 -1
- package/dist/global-setup.js +159 -30
- package/dist/http.d.ts +3 -0
- package/dist/http.js +7 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +1 -0
- package/dist/init.d.ts +20 -0
- package/dist/init.js +236 -0
- package/dist/openapi.d.ts +74 -0
- package/dist/openapi.js +337 -0
- package/dist/provided.d.ts +2 -0
- package/dist/recording.d.ts +43 -0
- package/dist/recording.js +148 -0
- package/dist/runtime.d.ts +20 -1
- package/dist/runtime.js +207 -28
- package/dist/scenario.js +2 -2
- package/dist/stub.d.ts +14 -0
- package/dist/stub.js +45 -9
- package/dist/trace.d.ts +35 -0
- package/dist/trace.js +57 -0
- package/dist/yaml-runtime.js +67 -1
- package/dist/yaml.d.ts +36 -1
- package/dist/yaml.js +46 -4
- package/package.json +22 -5
- package/schema/scenario.schema.json +156 -1
package/dist/runtime.js
CHANGED
|
@@ -1,50 +1,128 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { writeFile } from "node:fs/promises";
|
|
1
3
|
import path from "node:path";
|
|
2
|
-
import pg from "pg";
|
|
3
4
|
import { App } from "./app.js";
|
|
4
|
-
import {
|
|
5
|
+
import { Dependency } from "./containers.js";
|
|
6
|
+
import { Db, formatChanges } from "./db.js";
|
|
7
|
+
import { engineFor } from "./drivers/index.js";
|
|
5
8
|
import { formatHistory, HttpClient } from "./http.js";
|
|
9
|
+
import { OpenApiSpec } from "./openapi.js";
|
|
10
|
+
import { Recorder } from "./recording.js";
|
|
6
11
|
import { Stub } from "./stub.js";
|
|
12
|
+
import { buildTrace, mask } from "./trace.js";
|
|
7
13
|
/** Everything one test file needs: its own database, stub servers and app process. */
|
|
8
14
|
export class Runtime {
|
|
9
15
|
app;
|
|
16
|
+
services;
|
|
10
17
|
db;
|
|
11
18
|
stubs;
|
|
12
19
|
opts;
|
|
13
20
|
vars;
|
|
14
|
-
|
|
21
|
+
specs;
|
|
22
|
+
coverageDir;
|
|
23
|
+
recorders;
|
|
24
|
+
recordDir;
|
|
25
|
+
containers;
|
|
15
26
|
#http;
|
|
16
|
-
|
|
27
|
+
/** Responses from the app that don't match its OpenAPI spec, this scenario. */
|
|
28
|
+
#contract = [];
|
|
29
|
+
/** Documented responses seen in this file, for the run's coverage report. */
|
|
30
|
+
#covered = new Set();
|
|
31
|
+
constructor(app, services, db, stubs, opts, vars, specs, coverageDir, recorders = new Map(), recordDir, containers = new Map()) {
|
|
17
32
|
this.app = app;
|
|
33
|
+
this.services = services;
|
|
18
34
|
this.db = db;
|
|
19
35
|
this.stubs = stubs;
|
|
20
36
|
this.opts = opts;
|
|
21
37
|
this.vars = vars;
|
|
22
|
-
this
|
|
38
|
+
this.specs = specs;
|
|
39
|
+
this.coverageDir = coverageDir;
|
|
40
|
+
this.recorders = recorders;
|
|
41
|
+
this.recordDir = recordDir;
|
|
42
|
+
this.containers = containers;
|
|
43
|
+
this.#http = this.#client();
|
|
44
|
+
}
|
|
45
|
+
#client() {
|
|
46
|
+
const http = new HttpClient(this.app.url, this.opts.http);
|
|
47
|
+
const spec = this.specs.app;
|
|
48
|
+
if (spec) {
|
|
49
|
+
http.onResponse((res) => {
|
|
50
|
+
try {
|
|
51
|
+
const path = res.url.split("?")[0];
|
|
52
|
+
const key = spec.responseKey(res.method, path, res.status);
|
|
53
|
+
if (key)
|
|
54
|
+
this.#covered.add(key);
|
|
55
|
+
this.#contract.push(...spec.checkResponse(res.method, path, message(res)));
|
|
56
|
+
}
|
|
57
|
+
catch (e) {
|
|
58
|
+
this.#contract.push(`${res.method} ${res.url}: couldn't check against ${spec.file}: ${e.message}`);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return http;
|
|
23
63
|
}
|
|
24
64
|
get http() {
|
|
25
65
|
return this.#http;
|
|
26
66
|
}
|
|
27
67
|
static async start(opts, shared) {
|
|
28
|
-
const
|
|
68
|
+
const engine = await engineFor(opts);
|
|
69
|
+
const url = await ensureWorkerDatabase(engine, shared.adminUrl, shared.template, shared.prefix);
|
|
29
70
|
const stubs = new Map();
|
|
71
|
+
const recorders = new Map();
|
|
72
|
+
const containers = new Map();
|
|
73
|
+
const services = new Map();
|
|
30
74
|
let db;
|
|
31
75
|
try {
|
|
76
|
+
// Loaded first: a broken spec should fail before anything is started.
|
|
77
|
+
const specs = {
|
|
78
|
+
app: opts.openapi.app ? await OpenApiSpec.load(path.resolve(opts.root, opts.openapi.app), opts.openapi.app) : undefined,
|
|
79
|
+
stubs: new Map(),
|
|
80
|
+
};
|
|
81
|
+
for (const [name, file] of Object.entries(opts.openapi.stubs))
|
|
82
|
+
specs.stubs.set(name, await OpenApiSpec.load(path.resolve(opts.root, file), file));
|
|
32
83
|
for (const name of opts.stubs)
|
|
33
84
|
stubs.set(name, await Stub.start(name));
|
|
34
|
-
|
|
85
|
+
for (const [name, r] of Object.entries(opts.recordings))
|
|
86
|
+
recorders.set(name, await Recorder.load(name, path.resolve(opts.root, r.file), r.upstream, r.record));
|
|
87
|
+
// A registered route wins, then a recording of the real service, then an answer made up from its spec.
|
|
88
|
+
for (const [name, stub] of stubs) {
|
|
89
|
+
const recorder = recorders.get(name);
|
|
90
|
+
const spec = opts.openapi.autoReply.includes(name) ? specs.stubs.get(name) : undefined;
|
|
91
|
+
if (!recorder && !spec)
|
|
92
|
+
continue;
|
|
93
|
+
stub.fallback(async (call) => (await recorder?.answer(call)) ?? spec?.exampleResponse(call.method, call.path), recorder?.hint());
|
|
94
|
+
}
|
|
95
|
+
db = await Db.connect(await engine.driver(url), url, {
|
|
35
96
|
schemas: opts.db.schemas,
|
|
36
97
|
keep: opts.db.keep,
|
|
37
98
|
seedFile: opts.db.seed && path.resolve(opts.root, opts.db.seed),
|
|
38
99
|
});
|
|
100
|
+
const started = await Promise.allSettled(Object.entries(opts.containers).map(async ([name, c]) => containers.set(name, await Dependency.start(name, c))));
|
|
101
|
+
const failed = started.find((r) => r.status === "rejected");
|
|
102
|
+
if (failed)
|
|
103
|
+
throw failed.reason;
|
|
39
104
|
const vars = { "db.url": url };
|
|
105
|
+
for (const [name, c] of containers) {
|
|
106
|
+
vars[`container.${name}`] = c.address;
|
|
107
|
+
vars[`container.${name}.host`] = c.host;
|
|
108
|
+
vars[`container.${name}.port`] = String(c.port);
|
|
109
|
+
}
|
|
40
110
|
for (const [name, stub] of stubs)
|
|
41
111
|
vars[`stub.${name}`] = stub.url;
|
|
112
|
+
for (const [name, service] of Object.entries(opts.services)) {
|
|
113
|
+
const started = await App.start(service, opts.root, vars, `service.${name}`);
|
|
114
|
+
services.set(name, started);
|
|
115
|
+
vars[`service.${name}`] = started.url;
|
|
116
|
+
vars[`service.${name}.port`] = String(started.port);
|
|
117
|
+
}
|
|
42
118
|
const app = await App.start(opts.app, opts.root, vars);
|
|
43
|
-
return new Runtime(app, db, stubs, opts, vars);
|
|
119
|
+
return new Runtime(app, services, db, stubs, opts, vars, specs, shared.coverageDir, recorders, shared.recordDir, containers);
|
|
44
120
|
}
|
|
45
121
|
catch (e) {
|
|
122
|
+
await Promise.all([...services.values()].map((s) => s.stop()));
|
|
46
123
|
await db?.close();
|
|
47
124
|
await Promise.all([...stubs.values()].map((s) => s.close()));
|
|
125
|
+
await Promise.allSettled([...containers.values()].map((c) => c.stop()));
|
|
48
126
|
throw e;
|
|
49
127
|
}
|
|
50
128
|
}
|
|
@@ -60,29 +138,90 @@ export class Runtime {
|
|
|
60
138
|
}
|
|
61
139
|
return stub;
|
|
62
140
|
},
|
|
141
|
+
service: (name) => {
|
|
142
|
+
const service = this.services.get(name);
|
|
143
|
+
if (!service) {
|
|
144
|
+
throw new Error(`slicetest: unknown service "${name}". Declared services: ${[...this.services.keys()].join(", ") || "(none)"}`);
|
|
145
|
+
}
|
|
146
|
+
return service;
|
|
147
|
+
},
|
|
148
|
+
container: (name) => {
|
|
149
|
+
const c = this.containers.get(name);
|
|
150
|
+
if (!c)
|
|
151
|
+
throw new Error(`slicetest: unknown container "${name}". Declared containers: ${[...this.containers.keys()].join(", ") || "(none)"}`);
|
|
152
|
+
return c;
|
|
153
|
+
},
|
|
154
|
+
trace: async (opts) => mask(buildTrace(this.http.history, this.stubs.values(), await this.db.changesSinceStart()), opts),
|
|
63
155
|
};
|
|
64
156
|
}
|
|
157
|
+
/** The app and every service, for checks that apply to all of them. */
|
|
158
|
+
#processes() {
|
|
159
|
+
return [this.app, ...this.services.values()];
|
|
160
|
+
}
|
|
65
161
|
async beforeScenario() {
|
|
66
|
-
// A crash already failed the scenario that caused it; give the next one a fresh
|
|
162
|
+
// A crash already failed the scenario that caused it; give the next one a fresh process.
|
|
163
|
+
for (const [name, service] of this.services) {
|
|
164
|
+
if (!service.exited)
|
|
165
|
+
continue;
|
|
166
|
+
await service.stop();
|
|
167
|
+
this.services.set(name, await App.start(this.opts.services[name], this.opts.root, this.vars, `service.${name}`, service.port));
|
|
168
|
+
}
|
|
67
169
|
if (this.app.exited) {
|
|
68
170
|
await this.app.stop();
|
|
69
171
|
this.app = await App.start(this.opts.app, this.opts.root, this.vars);
|
|
70
|
-
this.#http =
|
|
172
|
+
this.#http = this.#client();
|
|
71
173
|
}
|
|
174
|
+
this.#contract = [];
|
|
72
175
|
await this.db.reset();
|
|
73
176
|
for (const stub of this.stubs.values())
|
|
74
177
|
stub.reset();
|
|
178
|
+
for (const recorder of this.recorders.values())
|
|
179
|
+
recorder.reset();
|
|
180
|
+
await Promise.all([...this.containers.values()].map((c) => c.reset()));
|
|
75
181
|
this.http.reset();
|
|
76
|
-
|
|
182
|
+
for (const p of this.#processes())
|
|
183
|
+
p.beginScenario();
|
|
77
184
|
}
|
|
78
185
|
/** Failures that the scenario body can't see on its own. */
|
|
79
186
|
async afterScenario() {
|
|
80
|
-
await this.
|
|
187
|
+
await Promise.all(this.#processes().map((p) => p.settle()));
|
|
81
188
|
this.#assertAlive();
|
|
82
189
|
const unmatched = this.#unmatched();
|
|
83
190
|
if (unmatched.length > 0) {
|
|
84
191
|
throw new Error(`slicetest: the app called stubbed services with no matching route:\n${unmatched.join("\n")}`);
|
|
85
192
|
}
|
|
193
|
+
const contract = this.#contractViolations();
|
|
194
|
+
if (contract.length > 0) {
|
|
195
|
+
throw new Error(`slicetest: traffic doesn't match the OpenAPI spec:\n${contract.map((c) => ` ${c}`).join("\n")}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/** The app's responses against its spec, and its calls to stubs (and the stubs' replies) against theirs. */
|
|
199
|
+
#contractViolations() {
|
|
200
|
+
const out = this.#contract.map((c) => `app: ${c}`);
|
|
201
|
+
for (const [name, spec] of this.specs.stubs) {
|
|
202
|
+
for (const call of this.stubs.get(name).calls()) {
|
|
203
|
+
try {
|
|
204
|
+
out.push(...this.#checkStubCall(name, spec, call));
|
|
205
|
+
}
|
|
206
|
+
catch (e) {
|
|
207
|
+
out.push(`app → ${name}: ${call.method} ${call.path}: couldn't check against ${spec.file}: ${e.message}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return [...new Set(out)];
|
|
212
|
+
}
|
|
213
|
+
#checkStubCall(name, spec, call) {
|
|
214
|
+
if (!call.matched)
|
|
215
|
+
return []; // already reported as an unmatched call
|
|
216
|
+
const req = { contentType: header(call.headers["content-type"]), body: call.json ?? call.body, query: call.query };
|
|
217
|
+
const out = spec.checkRequest(call.method, call.path, req).map((c) => `app → ${name}: ${c}`);
|
|
218
|
+
const res = call.response;
|
|
219
|
+
if (res) {
|
|
220
|
+
const body = res.body ? (parseJson(res.body) ?? res.body) : "";
|
|
221
|
+
const problems = spec.checkResponse(call.method, call.path, { status: res.status, contentType: res.headers["content-type"], body });
|
|
222
|
+
out.push(...problems.map((c) => `stub ${name} reply (the real service wouldn't answer this way): ${c}`));
|
|
223
|
+
}
|
|
224
|
+
return out;
|
|
86
225
|
}
|
|
87
226
|
#unmatched() {
|
|
88
227
|
return [...this.stubs.values()].flatMap((s) => {
|
|
@@ -93,53 +232,93 @@ export class Runtime {
|
|
|
93
232
|
return [
|
|
94
233
|
...calls.map((c) => ` ${s.name}: ${c.method} ${c.path}${c.query.size ? `?${c.query}` : ""}`),
|
|
95
234
|
` registered on ${s.name}: ${routes.length ? routes.join(", ") : "(none)"}`,
|
|
235
|
+
...(this.recorders.has(s.name) ? [` ${this.recorders.get(s.name).hint()}`] : []),
|
|
96
236
|
];
|
|
97
237
|
});
|
|
98
238
|
}
|
|
99
239
|
/** What happened during the current scenario, printed when it fails. */
|
|
100
|
-
diagnostics() {
|
|
240
|
+
async diagnostics() {
|
|
101
241
|
const sections = [];
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
242
|
+
for (const p of this.#processes()) {
|
|
243
|
+
const exit = p.exited;
|
|
244
|
+
if (exit)
|
|
245
|
+
sections.push(`the ${p.label} exited (code ${exit.code}, signal ${exit.signal}); it will be restarted for the next scenario`);
|
|
246
|
+
}
|
|
105
247
|
const unmatched = this.#unmatched();
|
|
106
248
|
if (unmatched.length > 0)
|
|
107
249
|
sections.push(`stub calls with no matching route:\n${unmatched.join("\n")}`);
|
|
250
|
+
const contract = this.#contractViolations();
|
|
251
|
+
if (contract.length > 0)
|
|
252
|
+
sections.push(`OpenAPI mismatches:\n${contract.map((c) => ` ${c}`).join("\n")}`);
|
|
108
253
|
if (this.http.history.length > 0)
|
|
109
254
|
sections.push(`requests to the app:\n${formatHistory(this.http.history)}`);
|
|
110
|
-
|
|
255
|
+
try {
|
|
256
|
+
const changes = formatChanges(await this.db.changesSinceStart());
|
|
257
|
+
sections.push(changes ? `database changes during this scenario:\n${changes}` : "database changes during this scenario: (none)");
|
|
258
|
+
}
|
|
259
|
+
catch (e) {
|
|
260
|
+
sections.push(`database changes during this scenario: unavailable (${e.message})`);
|
|
261
|
+
}
|
|
262
|
+
const logs = this.app.scenarioLogs();
|
|
111
263
|
sections.push(logs ? `app output during this scenario:\n${logs}` : "app output during this scenario: (none)");
|
|
264
|
+
for (const [name, service] of this.services) {
|
|
265
|
+
const out = service.scenarioLogs();
|
|
266
|
+
sections.push(out ? `service ${name} output during this scenario:\n${out}` : `service ${name} output during this scenario: (none)`);
|
|
267
|
+
}
|
|
112
268
|
return sections.join("\n\n");
|
|
113
269
|
}
|
|
114
270
|
async stop() {
|
|
271
|
+
if (this.coverageDir && this.#covered.size > 0) {
|
|
272
|
+
await writeFile(path.join(this.coverageDir, `${process.pid}-${randomUUID()}.json`), JSON.stringify([...this.#covered])).catch(() => { });
|
|
273
|
+
}
|
|
274
|
+
for (const [name, recorder] of this.recorders) {
|
|
275
|
+
if (this.recordDir && recorder.added().length > 0) {
|
|
276
|
+
await writeFile(path.join(this.recordDir, `${name}.${process.pid}-${randomUUID()}.json`), JSON.stringify(recorder.added())).catch(() => { });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
115
279
|
const results = await Promise.allSettled([
|
|
116
280
|
this.app.stop(),
|
|
281
|
+
...[...this.services.values()].map((s) => s.stop()),
|
|
117
282
|
this.db.close(),
|
|
118
283
|
...[...this.stubs.values()].map((s) => s.close()),
|
|
284
|
+
...[...this.containers.values()].map((c) => c.stop()),
|
|
119
285
|
]);
|
|
120
286
|
const failed = results.find((r) => r.status === "rejected");
|
|
121
287
|
if (failed)
|
|
122
288
|
throw failed.reason;
|
|
123
289
|
}
|
|
124
290
|
#assertAlive() {
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
291
|
+
for (const p of this.#processes()) {
|
|
292
|
+
const exit = p.exited;
|
|
293
|
+
if (exit)
|
|
294
|
+
throw new Error(`slicetest: ${p.label} process exited (code ${exit.code}, signal ${exit.signal})\n${p.logs()}`);
|
|
128
295
|
}
|
|
129
296
|
}
|
|
130
297
|
}
|
|
131
298
|
/** One database per vitest worker, cloned from the migrated template and reused across its test files. */
|
|
132
|
-
async function ensureWorkerDatabase(adminUrl, template, prefix) {
|
|
299
|
+
async function ensureWorkerDatabase(engine, adminUrl, template, prefix) {
|
|
133
300
|
const name = `${prefix}_w${process.env.VITEST_POOL_ID ?? process.pid}`;
|
|
134
|
-
const admin =
|
|
135
|
-
await admin.connect();
|
|
301
|
+
const admin = await engine.admin(adminUrl);
|
|
136
302
|
try {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
303
|
+
if (!(await admin.databases(name)).includes(name))
|
|
304
|
+
await admin.clone(template, name);
|
|
305
|
+
return admin.urlFor(name);
|
|
140
306
|
}
|
|
141
307
|
finally {
|
|
142
|
-
await admin.
|
|
308
|
+
await admin.close();
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
function message(res) {
|
|
312
|
+
return { status: res.status, contentType: res.headers.get("content-type") ?? undefined, body: res.json ?? res.text };
|
|
313
|
+
}
|
|
314
|
+
function header(v) {
|
|
315
|
+
return Array.isArray(v) ? v[0] : v;
|
|
316
|
+
}
|
|
317
|
+
function parseJson(text) {
|
|
318
|
+
try {
|
|
319
|
+
return JSON.parse(text);
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
return undefined;
|
|
143
323
|
}
|
|
144
|
-
return withDatabase(adminUrl, name);
|
|
145
324
|
}
|
package/dist/scenario.js
CHANGED
|
@@ -15,8 +15,8 @@ function define(register) {
|
|
|
15
15
|
if (task.concurrent) {
|
|
16
16
|
throw new Error("slicetest: scenarios share one app and database per file, so they can't run concurrently. Remove .concurrent / sequence.concurrent.");
|
|
17
17
|
}
|
|
18
|
-
onTestFailed(() => {
|
|
19
|
-
console.error(`--- slicetest ---\n${runtime.diagnostics()}\n-----------------`);
|
|
18
|
+
onTestFailed(async () => {
|
|
19
|
+
console.error(`--- slicetest ---\n${await runtime.diagnostics()}\n-----------------`);
|
|
20
20
|
});
|
|
21
21
|
await runtime.beforeScenario();
|
|
22
22
|
await body(runtime.context());
|
package/dist/stub.d.ts
CHANGED
|
@@ -11,6 +11,14 @@ export interface RecordedCall {
|
|
|
11
11
|
params: Record<string, string>;
|
|
12
12
|
/** Whether a registered route answered this call. */
|
|
13
13
|
matched: boolean;
|
|
14
|
+
/** What the stub answered, once it has. */
|
|
15
|
+
response?: {
|
|
16
|
+
status: number;
|
|
17
|
+
headers: Record<string, string>;
|
|
18
|
+
body: string;
|
|
19
|
+
};
|
|
20
|
+
/** Answered by the fallback (e.g. an example from the provider's OpenAPI spec), not a registered route. */
|
|
21
|
+
fallback?: boolean;
|
|
14
22
|
}
|
|
15
23
|
export interface StubResponse {
|
|
16
24
|
status?: number;
|
|
@@ -68,6 +76,12 @@ export declare class Stub {
|
|
|
68
76
|
/** Human-readable list of registered routes, for diagnostics. */
|
|
69
77
|
describeRoutes(): string[];
|
|
70
78
|
reset(): void;
|
|
79
|
+
/**
|
|
80
|
+
* Answer calls that no registered route matches, instead of failing with 501.
|
|
81
|
+
* Return undefined to leave a call unanswered (it then fails the scenario as usual).
|
|
82
|
+
* Kept across scenarios, unlike routes.
|
|
83
|
+
*/
|
|
84
|
+
fallback(respond: ((call: RecordedCall) => StubResponse | undefined | Promise<StubResponse | undefined>) | undefined, hint?: string): this;
|
|
71
85
|
close(): Promise<void>;
|
|
72
86
|
}
|
|
73
87
|
/** `expected` is contained in `actual`: objects compare key by key, arrays element-wise. */
|
package/dist/stub.js
CHANGED
|
@@ -9,6 +9,9 @@ export class Stub {
|
|
|
9
9
|
#server;
|
|
10
10
|
#routes = [];
|
|
11
11
|
#calls = [];
|
|
12
|
+
#fallback;
|
|
13
|
+
/** Appended to the 501 answer for a call nothing could answer. */
|
|
14
|
+
#hint;
|
|
12
15
|
url = "";
|
|
13
16
|
constructor(name) {
|
|
14
17
|
this.name = name;
|
|
@@ -80,6 +83,16 @@ export class Stub {
|
|
|
80
83
|
this.#routes = [];
|
|
81
84
|
this.#calls = [];
|
|
82
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Answer calls that no registered route matches, instead of failing with 501.
|
|
88
|
+
* Return undefined to leave a call unanswered (it then fails the scenario as usual).
|
|
89
|
+
* Kept across scenarios, unlike routes.
|
|
90
|
+
*/
|
|
91
|
+
fallback(respond, hint) {
|
|
92
|
+
this.#fallback = respond;
|
|
93
|
+
this.#hint = hint;
|
|
94
|
+
return this;
|
|
95
|
+
}
|
|
83
96
|
async close() {
|
|
84
97
|
this.#server.closeAllConnections();
|
|
85
98
|
await new Promise((resolve) => this.#server.close(resolve));
|
|
@@ -115,7 +128,22 @@ export class Stub {
|
|
|
115
128
|
break;
|
|
116
129
|
}
|
|
117
130
|
if (!route) {
|
|
118
|
-
|
|
131
|
+
let out;
|
|
132
|
+
try {
|
|
133
|
+
out = await this.#fallback?.(call);
|
|
134
|
+
}
|
|
135
|
+
catch (e) {
|
|
136
|
+
res.writeHead(502, { "content-type": "text/plain" }).end(e.message);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (!out) {
|
|
140
|
+
const hint = this.#hint ? ` (${this.#hint})` : "";
|
|
141
|
+
res.writeHead(501, { "content-type": "text/plain" }).end(`slicetest: no stub for ${call.method} ${call.path}${hint}`);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
call.matched = true;
|
|
145
|
+
call.fallback = true;
|
|
146
|
+
this.#send(call, res, out);
|
|
119
147
|
return;
|
|
120
148
|
}
|
|
121
149
|
call.matched = true;
|
|
@@ -128,19 +156,27 @@ export class Stub {
|
|
|
128
156
|
return;
|
|
129
157
|
}
|
|
130
158
|
try {
|
|
131
|
-
|
|
132
|
-
const raw = out.body === undefined || typeof out.body === "string" || out.body instanceof Uint8Array;
|
|
133
|
-
const headers = { ...out.headers };
|
|
134
|
-
if (!raw && !Object.keys(headers).some((h) => h.toLowerCase() === "content-type")) {
|
|
135
|
-
headers["content-type"] = "application/json";
|
|
136
|
-
}
|
|
137
|
-
res.writeHead(out.status ?? 200, headers);
|
|
138
|
-
res.end(raw ? out.body : JSON.stringify(out.body));
|
|
159
|
+
this.#send(call, res, typeof route.respond === "function" ? await route.respond(call) : route.respond);
|
|
139
160
|
}
|
|
140
161
|
catch (e) {
|
|
141
162
|
res.writeHead(500).end(`slicetest: stub handler threw: ${e}`);
|
|
142
163
|
}
|
|
143
164
|
}
|
|
165
|
+
#send(call, res, out) {
|
|
166
|
+
const raw = out.body === undefined || typeof out.body === "string" || out.body instanceof Uint8Array;
|
|
167
|
+
const headers = { ...out.headers };
|
|
168
|
+
if (!raw && !Object.keys(headers).some((h) => h.toLowerCase() === "content-type")) {
|
|
169
|
+
headers["content-type"] = "application/json";
|
|
170
|
+
}
|
|
171
|
+
const payload = raw ? out.body : JSON.stringify(out.body);
|
|
172
|
+
call.response = {
|
|
173
|
+
status: out.status ?? 200,
|
|
174
|
+
headers: Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v])),
|
|
175
|
+
body: payload === undefined ? "" : typeof payload === "string" ? payload : Buffer.from(payload).toString("utf8"),
|
|
176
|
+
};
|
|
177
|
+
res.writeHead(out.status ?? 200, headers);
|
|
178
|
+
res.end(payload);
|
|
179
|
+
}
|
|
144
180
|
}
|
|
145
181
|
function compilePath(path) {
|
|
146
182
|
if (typeof path !== "string" || !path.includes("/:"))
|
package/dist/trace.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Changes } from "./db.js";
|
|
2
|
+
import type { HttpResponse } from "./http.js";
|
|
3
|
+
import type { Stub } from "./stub.js";
|
|
4
|
+
/**
|
|
5
|
+
* Everything one scenario did at the three boundaries: the requests it made to
|
|
6
|
+
* the app, the calls the app made to stubs, and the rows it changed. Meant for
|
|
7
|
+
* `expect(await trace()).toMatchSnapshot()`: resets make ids deterministic,
|
|
8
|
+
* and `mask` replaces the values that aren't (timestamps, UUIDs).
|
|
9
|
+
*/
|
|
10
|
+
export interface Trace {
|
|
11
|
+
http: {
|
|
12
|
+
request: string;
|
|
13
|
+
status: number;
|
|
14
|
+
body?: unknown;
|
|
15
|
+
}[];
|
|
16
|
+
stubs: Record<string, {
|
|
17
|
+
request: string;
|
|
18
|
+
body?: unknown;
|
|
19
|
+
status?: number;
|
|
20
|
+
}[]>;
|
|
21
|
+
db: Changes;
|
|
22
|
+
}
|
|
23
|
+
export interface MaskOptions {
|
|
24
|
+
/** Keys whose values are always replaced, e.g. `["token", "password_hash"]`. Matched at any depth. */
|
|
25
|
+
keys?: string[];
|
|
26
|
+
/** Also mask strings matching these patterns, e.g. `[/^tok_\w+$/]`. */
|
|
27
|
+
patterns?: RegExp[];
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A copy of `value` with the parts that change from run to run replaced by
|
|
31
|
+
* placeholders: dates (`Date` objects and ISO strings) by `[date]`, UUIDs by
|
|
32
|
+
* `[uuid]`, and the values of `keys` / strings matching `patterns` by `[masked]`.
|
|
33
|
+
*/
|
|
34
|
+
export declare function mask<T>(value: T, opts?: MaskOptions): T;
|
|
35
|
+
export declare function buildTrace(history: readonly HttpResponse[], stubs: Iterable<Stub>, db: Changes): Trace;
|
package/dist/trace.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const ISO_DATE = /^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/;
|
|
2
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
3
|
+
/**
|
|
4
|
+
* A copy of `value` with the parts that change from run to run replaced by
|
|
5
|
+
* placeholders: dates (`Date` objects and ISO strings) by `[date]`, UUIDs by
|
|
6
|
+
* `[uuid]`, and the values of `keys` / strings matching `patterns` by `[masked]`.
|
|
7
|
+
*/
|
|
8
|
+
export function mask(value, opts = {}) {
|
|
9
|
+
const keys = new Set(opts.keys ?? []);
|
|
10
|
+
const walk = (v) => {
|
|
11
|
+
if (v instanceof Date)
|
|
12
|
+
return "[date]";
|
|
13
|
+
if (typeof v === "bigint")
|
|
14
|
+
return v.toString();
|
|
15
|
+
if (typeof v === "string") {
|
|
16
|
+
if (UUID.test(v))
|
|
17
|
+
return "[uuid]";
|
|
18
|
+
if (ISO_DATE.test(v))
|
|
19
|
+
return "[date]";
|
|
20
|
+
if (opts.patterns?.some((p) => ((p.lastIndex = 0), p.test(v))))
|
|
21
|
+
return "[masked]";
|
|
22
|
+
return v;
|
|
23
|
+
}
|
|
24
|
+
if (Array.isArray(v))
|
|
25
|
+
return v.map(walk);
|
|
26
|
+
if (v && typeof v === "object") {
|
|
27
|
+
return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, keys.has(k) && x != null ? "[masked]" : walk(x)]));
|
|
28
|
+
}
|
|
29
|
+
return v;
|
|
30
|
+
};
|
|
31
|
+
return walk(value);
|
|
32
|
+
}
|
|
33
|
+
export function buildTrace(history, stubs, db) {
|
|
34
|
+
const trace = {
|
|
35
|
+
http: history.map((r) => {
|
|
36
|
+
const url = new URL(r.url, "http://app");
|
|
37
|
+
const body = r.json !== undefined ? r.json : r.text || undefined;
|
|
38
|
+
return { request: `${r.method} ${url.pathname}${url.search}`, status: r.status, ...(body !== undefined ? { body } : {}) };
|
|
39
|
+
}),
|
|
40
|
+
stubs: {},
|
|
41
|
+
db,
|
|
42
|
+
};
|
|
43
|
+
for (const stub of stubs) {
|
|
44
|
+
const calls = stub.calls();
|
|
45
|
+
if (calls.length === 0)
|
|
46
|
+
continue;
|
|
47
|
+
trace.stubs[stub.name] = calls.map((c) => {
|
|
48
|
+
const body = c.json !== undefined ? c.json : c.body || undefined;
|
|
49
|
+
return {
|
|
50
|
+
request: `${c.method} ${c.path}${c.query.size ? `?${c.query}` : ""}`,
|
|
51
|
+
...(body !== undefined ? { body } : {}),
|
|
52
|
+
...(c.response ? { status: c.response.status } : {}),
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return trace;
|
|
57
|
+
}
|
package/dist/yaml-runtime.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { expect } from "vitest";
|
|
2
|
+
import { formatChanges } from "./db.js";
|
|
2
3
|
import "./matchers.js";
|
|
3
4
|
import { scenario } from "./scenario.js";
|
|
4
5
|
/** Registers the scenarios of one parsed YAML file. Called from the module the plugin generates for it. */
|
|
@@ -15,7 +16,8 @@ export function defineYamlScenarios(doc) {
|
|
|
15
16
|
async function runSteps(doc, sc, steps, ctx, vars) {
|
|
16
17
|
for (const [i, step] of steps.entries()) {
|
|
17
18
|
try {
|
|
18
|
-
|
|
19
|
+
// A log step waits by itself; `within` is its timeout.
|
|
20
|
+
await retry("within" in step && !("log" in step) ? step.within : undefined, () => runStep(step, ctx, vars));
|
|
19
21
|
}
|
|
20
22
|
catch (e) {
|
|
21
23
|
const label = step.name ?? describeStep(step);
|
|
@@ -39,9 +41,73 @@ function describeStep(step) {
|
|
|
39
41
|
return `insert ${step.insert}`;
|
|
40
42
|
if ("db" in step)
|
|
41
43
|
return `db ${step.db}`;
|
|
44
|
+
if ("changes" in step)
|
|
45
|
+
return "changes";
|
|
46
|
+
if ("checkpoint" in step)
|
|
47
|
+
return "checkpoint";
|
|
48
|
+
if ("snapshot" in step)
|
|
49
|
+
return "snapshot";
|
|
50
|
+
if ("log" in step)
|
|
51
|
+
return `log ${step.from ? `from ${step.from} ` : ""}/${step.log}/`;
|
|
42
52
|
return "sql";
|
|
43
53
|
}
|
|
54
|
+
/** Run `fn` until it passes or `within` ms have passed, then rethrow its last error. */
|
|
55
|
+
async function retry(within, fn) {
|
|
56
|
+
if (!within)
|
|
57
|
+
return fn();
|
|
58
|
+
const deadline = Date.now() + within;
|
|
59
|
+
for (;;) {
|
|
60
|
+
try {
|
|
61
|
+
return await fn();
|
|
62
|
+
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
if (Date.now() >= deadline) {
|
|
65
|
+
if (e instanceof Error)
|
|
66
|
+
e.message = `${e.message}\n(still failing after retrying for ${within}ms)`;
|
|
67
|
+
throw e;
|
|
68
|
+
}
|
|
69
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
44
73
|
async function runStep(step, ctx, vars) {
|
|
74
|
+
if ("checkpoint" in step) {
|
|
75
|
+
await ctx.db.checkpoint();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if ("snapshot" in step) {
|
|
79
|
+
expect(await ctx.trace({ keys: step.mask })).toMatchSnapshot();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if ("log" in step) {
|
|
83
|
+
const target = step.from ? ctx.service(step.from) : ctx.app;
|
|
84
|
+
await target.waitForLog(new RegExp(interpolate(step.log, vars)), step.within ?? 5000);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if ("changes" in step) {
|
|
88
|
+
const actual = await ctx.db.changes();
|
|
89
|
+
const expected = interpolate(step.changes, vars);
|
|
90
|
+
const unexpected = Object.keys(actual).filter((t) => !(t in expected));
|
|
91
|
+
if (unexpected.length) {
|
|
92
|
+
throw new Error(`unexpected changes in ${unexpected.join(", ")}:\n${formatChanges(Object.fromEntries(unexpected.map((t) => [t, actual[t]])))}`);
|
|
93
|
+
}
|
|
94
|
+
for (const [table, spec] of Object.entries(expected)) {
|
|
95
|
+
const got = actual[table] ?? { inserted: [], updated: [], deleted: [] };
|
|
96
|
+
for (const kind of ["inserted", "updated", "deleted"]) {
|
|
97
|
+
const want = spec[kind];
|
|
98
|
+
if (want === undefined)
|
|
99
|
+
continue;
|
|
100
|
+
const rows = kind === "updated" ? got.updated.map((u) => u.after) : got[kind];
|
|
101
|
+
if (typeof want === "number") {
|
|
102
|
+
if (rows.length !== want)
|
|
103
|
+
throw new Error(`expected ${want} row(s) ${kind} in ${table}, got ${rows.length}\n${formatChanges({ [table]: got }) || " (no changes)"}`);
|
|
104
|
+
}
|
|
105
|
+
else
|
|
106
|
+
check(rows, want, vars, `${kind} rows of ${table}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
45
111
|
if ("stub" in step) {
|
|
46
112
|
const [method, path] = splitCall(step.on);
|
|
47
113
|
let route = ctx.stub(step.stub).on(method, interpolate(path, vars), conditions(step.when, vars));
|