slicetest 0.6.1 → 0.8.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 +177 -24
- package/dist/cli.js +66 -2
- package/dist/config.d.ts +18 -0
- package/dist/config.js +13 -0
- package/dist/connection.d.ts +5 -0
- package/dist/connection.js +31 -0
- package/dist/db.d.ts +12 -1
- package/dist/db.js +33 -6
- package/dist/diagram.d.ts +13 -0
- package/dist/diagram.js +89 -0
- package/dist/form.d.ts +7 -2
- package/dist/form.js +72 -7
- package/dist/global-setup.d.ts +5 -0
- package/dist/global-setup.js +74 -9
- package/dist/graphql.d.ts +34 -0
- package/dist/graphql.js +56 -0
- package/dist/har.d.ts +49 -0
- package/dist/har.js +107 -0
- package/dist/http.d.ts +21 -3
- package/dist/http.js +47 -10
- package/dist/index.d.ts +5 -3
- package/dist/init.js +184 -15
- package/dist/list.d.ts +26 -0
- package/dist/list.js +71 -0
- package/dist/matchers.d.ts +16 -0
- package/dist/matchers.js +81 -1
- package/dist/openapi.d.ts +21 -0
- package/dist/openapi.js +32 -1
- package/dist/provided.d.ts +1 -0
- package/dist/record.d.ts +1 -1
- package/dist/record.js +3 -1
- package/dist/recording.d.ts +1 -1
- package/dist/recording.js +2 -2
- package/dist/runtime.d.ts +12 -5
- package/dist/runtime.js +78 -22
- package/dist/scenario.d.ts +14 -4
- package/dist/scenario.js +36 -17
- package/dist/schema.d.ts +5 -0
- package/dist/schema.js +73 -0
- package/dist/stub.d.ts +63 -0
- package/dist/stub.js +292 -5
- package/dist/timeline.d.ts +9 -0
- package/dist/timeline.js +6 -0
- package/dist/webhook.d.ts +16 -7
- package/dist/webhook.js +47 -7
- package/dist/yaml-runtime.d.ts +3 -2
- package/dist/yaml-runtime.js +308 -51
- package/dist/yaml.d.ts +82 -6
- package/dist/yaml.js +187 -21
- package/package.json +2 -1
- package/schema/scenario.schema.json +640 -14
package/dist/yaml-runtime.js
CHANGED
|
@@ -5,6 +5,11 @@ import { formatChanges } from "./db.js";
|
|
|
5
5
|
import "./matchers.js";
|
|
6
6
|
import { scenario } from "./scenario.js";
|
|
7
7
|
import { signWebhook, webhookBody } from "./webhook.js";
|
|
8
|
+
import { readFile } from "node:fs/promises";
|
|
9
|
+
import { parse as parseYaml } from "yaml";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { graphqlErrors } from "./graphql.js";
|
|
12
|
+
import { schemaProblems } from "./schema.js";
|
|
8
13
|
import { sse } from "./stub.js";
|
|
9
14
|
/** Registers the scenarios of one parsed YAML file. Called from the module the plugin generates for it. */
|
|
10
15
|
export function defineYamlScenarios(doc) {
|
|
@@ -13,24 +18,38 @@ export function defineYamlScenarios(doc) {
|
|
|
13
18
|
const rows = sc.each ?? [undefined];
|
|
14
19
|
rows.forEach((row, i) => {
|
|
15
20
|
const title = row ? interpolateTitle(sc.name, row, i) : sc.name;
|
|
16
|
-
register(title, (ctx) =>
|
|
21
|
+
register(title, (ctx) => {
|
|
22
|
+
seq = 0;
|
|
23
|
+
return runSteps(doc, sc, [...doc.setup, ...sc.steps], ctx, { ...row });
|
|
24
|
+
}, { timeout: sc.timeout, tags: sc.tags });
|
|
17
25
|
});
|
|
18
26
|
}
|
|
19
27
|
}
|
|
20
|
-
async function runSteps(doc, sc, steps, ctx, vars) {
|
|
28
|
+
async function runSteps(doc, sc, steps, ctx, vars, trail = []) {
|
|
21
29
|
for (const [i, step] of steps.entries()) {
|
|
30
|
+
if ("use" in step) {
|
|
31
|
+
// The definition sees the scenario's variables plus `with`; what it captures is kept afterwards.
|
|
32
|
+
const args = interpolate(step.with ?? {}, vars) ?? {};
|
|
33
|
+
const inner = { ...vars, ...args };
|
|
34
|
+
await runSteps(doc, sc, doc.define[step.use].steps, ctx, inner, [...trail, `step ${i + 1}: use ${step.use}`]);
|
|
35
|
+
for (const [k, v] of Object.entries(inner))
|
|
36
|
+
if (!(k in args))
|
|
37
|
+
vars[k] = v;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
22
40
|
try {
|
|
23
41
|
// A log step waits by itself; `within` is its timeout.
|
|
24
42
|
// Log and mail steps wait by themselves; `within` is their timeout.
|
|
25
|
-
|
|
43
|
+
// Requests poll the app (`every` ms, default 200): a job answered 202 until it's done.
|
|
44
|
+
await retry("within" in step && !("log" in step) && !("mail" in step) ? step.within : undefined, () => runStep(step, ctx, vars, doc.path ? path.dirname(doc.path) : undefined), "request" in step ? (step.every ?? 200) : 50);
|
|
26
45
|
}
|
|
27
46
|
catch (e) {
|
|
28
47
|
const label = step.name ?? describeStep(step);
|
|
29
|
-
const where = `${doc.file}:${step.line} (${sc.name}, step ${i + 1}: ${label})`;
|
|
48
|
+
const where = `${doc.file}:${step.line} (${sc.name}, ${[...trail, `step ${i + 1}: ${label}`].join(" → ")})`;
|
|
30
49
|
const ciDir = inject("slicetestDb")?.ciDir;
|
|
31
50
|
if (ciDir && doc.path) {
|
|
32
51
|
const message = (e instanceof Error ? e.message : String(e)).replace(/\x1b\[[0-9;]*m/g, "");
|
|
33
|
-
await recordYamlFailure(ciDir, { file: doc.path, line: step.line, scenario: sc.name, step: `step ${i + 1}: ${label}
|
|
52
|
+
await recordYamlFailure(ciDir, { file: doc.path, line: step.line, scenario: sc.name, step: [...trail, `step ${i + 1}: ${label}`].join(" → "), message }).catch(() => { });
|
|
34
53
|
}
|
|
35
54
|
if (e instanceof Error) {
|
|
36
55
|
e.message = `${where}\n${e.message}`;
|
|
@@ -41,14 +60,16 @@ async function runSteps(doc, sc, steps, ctx, vars) {
|
|
|
41
60
|
}
|
|
42
61
|
}
|
|
43
62
|
function describeStep(step) {
|
|
63
|
+
if ("use" in step)
|
|
64
|
+
return `use ${step.use}`;
|
|
44
65
|
if ("submit" in step)
|
|
45
66
|
return step.submit === true ? "submit" : `submit ${JSON.stringify(step.submit)}`;
|
|
46
67
|
if ("request" in step)
|
|
47
68
|
return step.concurrency ? `${step.request} ×${step.concurrency}` : step.request;
|
|
48
69
|
if ("stub" in step)
|
|
49
|
-
return `stub ${step.stub} ${step.on}`;
|
|
70
|
+
return `stub ${step.stub} ${step.on ?? `GraphQL ${step.graphql}`}`;
|
|
50
71
|
if ("received" in step)
|
|
51
|
-
return `received ${step.received}${step.call ? ` ${step.call}` : ""}`;
|
|
72
|
+
return `received ${step.received}${step.call ? ` ${step.call}` : step.graphql ? ` GraphQL ${step.graphql}` : ""}`;
|
|
52
73
|
if ("insert" in step)
|
|
53
74
|
return `insert ${step.insert}`;
|
|
54
75
|
if ("make" in step)
|
|
@@ -61,6 +82,10 @@ function describeStep(step) {
|
|
|
61
82
|
return "changes";
|
|
62
83
|
if ("checkpoint" in step)
|
|
63
84
|
return "checkpoint";
|
|
85
|
+
if ("set" in step)
|
|
86
|
+
return `set ${Object.keys(step.set).join(", ")}`;
|
|
87
|
+
if ("order" in step)
|
|
88
|
+
return `order ${step.order.map((o) => (typeof o === "string" ? o : `${o.stub} ${o.call}`)).join(" → ")}`;
|
|
64
89
|
if ("snapshot" in step)
|
|
65
90
|
return "snapshot";
|
|
66
91
|
if ("mail" in step)
|
|
@@ -70,7 +95,7 @@ function describeStep(step) {
|
|
|
70
95
|
return "sql";
|
|
71
96
|
}
|
|
72
97
|
/** Run `fn` until it passes or `within` ms have passed, then rethrow its last error. */
|
|
73
|
-
async function retry(within, fn) {
|
|
98
|
+
async function retry(within, fn, every = 50) {
|
|
74
99
|
if (!within)
|
|
75
100
|
return fn();
|
|
76
101
|
const deadline = Date.now() + within;
|
|
@@ -84,11 +109,27 @@ async function retry(within, fn) {
|
|
|
84
109
|
e.message = `${e.message}\n(still failing after retrying for ${within}ms)`;
|
|
85
110
|
throw e;
|
|
86
111
|
}
|
|
87
|
-
await new Promise((r) => setTimeout(r,
|
|
112
|
+
await new Promise((r) => setTimeout(r, every));
|
|
88
113
|
}
|
|
89
114
|
}
|
|
90
115
|
}
|
|
91
|
-
async function runStep(step, ctx, vars) {
|
|
116
|
+
async function runStep(step, ctx, vars, base) {
|
|
117
|
+
if ("use" in step)
|
|
118
|
+
throw new Error("slicetest: use steps are expanded by runSteps");
|
|
119
|
+
if ("order" in step) {
|
|
120
|
+
const calls = step.order.map((o) => {
|
|
121
|
+
const [name, call, when] = typeof o === "string" ? [o.slice(0, o.search(/\s/)), o.slice(o.search(/\s/)).trim(), undefined] : [o.stub, o.call, o.when];
|
|
122
|
+
const [method, p] = splitCall(interpolate(call, vars));
|
|
123
|
+
return [name, method, p, conditions(when, vars)];
|
|
124
|
+
});
|
|
125
|
+
expect(ctx.stub).toHaveReceivedInOrder(calls);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if ("set" in step) {
|
|
129
|
+
for (const [k, v] of Object.entries(step.set))
|
|
130
|
+
vars[k] = interpolate(v, vars);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
92
133
|
if ("checkpoint" in step) {
|
|
93
134
|
await ctx.db.checkpoint();
|
|
94
135
|
return;
|
|
@@ -121,7 +162,7 @@ async function runStep(step, ctx, vars) {
|
|
|
121
162
|
return;
|
|
122
163
|
}
|
|
123
164
|
if ("changes" in step) {
|
|
124
|
-
const actual = await ctx.db.changes();
|
|
165
|
+
const actual = await ctx.db.changes({ ignore: step.ignore });
|
|
125
166
|
const expected = interpolate(step.changes, vars);
|
|
126
167
|
const unexpected = Object.keys(actual).filter((t) => !(t in expected));
|
|
127
168
|
if (unexpected.length) {
|
|
@@ -145,26 +186,41 @@ async function runStep(step, ctx, vars) {
|
|
|
145
186
|
return;
|
|
146
187
|
}
|
|
147
188
|
if ("stub" in step) {
|
|
148
|
-
const
|
|
149
|
-
let route
|
|
189
|
+
const stub = ctx.stub(step.stub);
|
|
190
|
+
let route;
|
|
191
|
+
if (step.graphql !== undefined) {
|
|
192
|
+
const { variables, ...when } = conditions(step.when, vars);
|
|
193
|
+
route = stub.graphql(interpolate(step.graphql, vars), { ...when, variables });
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
const [method, path] = splitCall(step.on);
|
|
197
|
+
route = stub.on(method, interpolate(path, vars), conditions(step.when, vars));
|
|
198
|
+
}
|
|
150
199
|
if (step.times !== undefined)
|
|
151
200
|
route = route.times(step.times);
|
|
152
201
|
if (step.delay !== undefined)
|
|
153
202
|
route = route.delay(step.delay);
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
203
|
+
if (step.optional)
|
|
204
|
+
route = route.optional();
|
|
205
|
+
// Replies are interpolated when a call arrives, so they can echo it: {{call.params.id}}, {{call.json.name}}, {{call.variables.id}}.
|
|
206
|
+
const answer = (prepared) => (call) => {
|
|
207
|
+
const { sse: events, data, errors, ...response } = interpolate(prepared.template, { ...vars, call: callVars(call) });
|
|
208
|
+
if (prepared.bytes)
|
|
209
|
+
return { ...response, body: prepared.bytes };
|
|
210
|
+
if (data !== undefined || errors !== undefined) {
|
|
211
|
+
return { status: 200, ...response, body: { ...(errors ? { errors: graphqlErrors(errors) } : {}), ...(data !== undefined ? { data } : {}) } };
|
|
212
|
+
}
|
|
157
213
|
return events ? sse(events, response) : response;
|
|
158
214
|
};
|
|
159
215
|
if (step.networkError)
|
|
160
216
|
route.networkError();
|
|
161
217
|
else if (step.sequence) {
|
|
162
218
|
let n = 0;
|
|
163
|
-
const seq = step.sequence;
|
|
219
|
+
const seq = await Promise.all(step.sequence.map((r) => prepareReply(r, vars, base)));
|
|
164
220
|
route.reply((call) => answer(seq[Math.min(n++, seq.length - 1)])(call));
|
|
165
221
|
}
|
|
166
222
|
else
|
|
167
|
-
route.reply(answer(step.reply ?? {}));
|
|
223
|
+
route.reply(answer(await prepareReply(step.reply ?? {}, vars, base)));
|
|
168
224
|
return;
|
|
169
225
|
}
|
|
170
226
|
if ("submit" in step) {
|
|
@@ -174,11 +230,11 @@ async function runStep(step, ctx, vars) {
|
|
|
174
230
|
const res = await ctx.http.submit(page, {
|
|
175
231
|
button: step.submit === true ? undefined : interpolate(step.submit, vars),
|
|
176
232
|
form: interpolate(step.form, vars),
|
|
177
|
-
fields: interpolate(step.fields, vars),
|
|
233
|
+
fields: await withFiles(interpolate(step.fields, vars), base),
|
|
178
234
|
headers: interpolate(step.headers, vars),
|
|
179
235
|
follow: step.follow,
|
|
180
236
|
});
|
|
181
|
-
verifyResponse(res, step.expect, vars);
|
|
237
|
+
verifyResponse(res, step.expect, vars, base);
|
|
182
238
|
capture(step.capture, { status: res.status, json: res.json, text: res.text, headers: Object.fromEntries(res.headers) }, vars);
|
|
183
239
|
return;
|
|
184
240
|
}
|
|
@@ -190,19 +246,35 @@ async function runStep(step, ctx, vars) {
|
|
|
190
246
|
headers: step.auth ? { ...ctx.auth.header(step.auth === true ? {} : interpolate(step.auth, vars)), ...headers } : headers,
|
|
191
247
|
query: interpolate(step.query, vars),
|
|
192
248
|
follow: step.follow,
|
|
249
|
+
...(step.timeout !== undefined ? { timeout: step.timeout } : {}),
|
|
193
250
|
};
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
251
|
+
const gql = step.graphql === undefined ? undefined : interpolate(typeof step.graphql === "string" ? { query: step.graphql } : step.graphql, vars);
|
|
252
|
+
let body = gql !== undefined
|
|
253
|
+
? gql
|
|
254
|
+
: step.json !== undefined
|
|
255
|
+
? interpolate(step.json, vars)
|
|
256
|
+
: step.form !== undefined
|
|
257
|
+
? ctx.http.form(interpolate(step.form, vars))
|
|
258
|
+
: step.multipart !== undefined
|
|
259
|
+
? await multipart(interpolate(step.multipart, vars), base)
|
|
260
|
+
: interpolate(step.body, vars);
|
|
199
261
|
if (step.webhook) {
|
|
200
|
-
const
|
|
262
|
+
const hook = interpolate(step.webhook, vars);
|
|
263
|
+
const signed = webhookBody(body, hook.provider);
|
|
201
264
|
body = signed.body;
|
|
202
|
-
|
|
265
|
+
const url = new URL(path, ctx.http.baseUrl);
|
|
266
|
+
for (const [k, v] of Object.entries(opts.query ?? {}))
|
|
267
|
+
url.searchParams.set(k, String(v));
|
|
268
|
+
opts.headers = { "content-type": signed.type, ...signWebhook(signed.body, { url: url.href, ...hook }), ...opts.headers };
|
|
203
269
|
}
|
|
204
270
|
const e = step.expect;
|
|
205
|
-
const verify = (res) =>
|
|
271
|
+
const verify = (res) => {
|
|
272
|
+
// GraphQL servers answer errors with 200, so a status check alone would pass them.
|
|
273
|
+
const expectsErrors = !!e?.json && typeof e.json === "object" && "errors" in e.json;
|
|
274
|
+
if (gql && !expectsErrors && Array.isArray(res.json?.errors) && res.json.errors.length)
|
|
275
|
+
expect(res).toHaveGraphQLData();
|
|
276
|
+
verifyResponse(res, e, vars, base);
|
|
277
|
+
};
|
|
206
278
|
if (step.concurrency !== undefined) {
|
|
207
279
|
// Each request gets its own body: a URLSearchParams body can only be read once.
|
|
208
280
|
const all = await ctx.http.concurrently(step.concurrency, () => ctx.http.request(method, path, body instanceof URLSearchParams ? new URLSearchParams(body) : body, opts));
|
|
@@ -263,7 +335,9 @@ async function runStep(step, ctx, vars) {
|
|
|
263
335
|
// received
|
|
264
336
|
const [method, path] = step.call ? splitCall(step.call) : [undefined, undefined];
|
|
265
337
|
const stub = ctx.stub(step.received);
|
|
266
|
-
const match = conditions(step.when, vars);
|
|
338
|
+
const { variables, ...match } = conditions(step.when, vars);
|
|
339
|
+
if (step.graphql !== undefined)
|
|
340
|
+
match.graphql = { operation: interpolate(step.graphql, vars), variables };
|
|
267
341
|
const p = path && interpolate(path, vars);
|
|
268
342
|
if (step.times === undefined)
|
|
269
343
|
expect(stub).toHaveReceived(method ?? "*", p ?? /.*/, match);
|
|
@@ -310,9 +384,82 @@ function callVars(call) {
|
|
|
310
384
|
query: Object.fromEntries(call.query),
|
|
311
385
|
headers: call.headers,
|
|
312
386
|
json: call.json,
|
|
387
|
+
form: call.form,
|
|
313
388
|
body: call.body,
|
|
389
|
+
variables: call.graphql?.variables,
|
|
314
390
|
};
|
|
315
391
|
}
|
|
392
|
+
const TEXT_FILE = /^(text\/|application\/(xml|javascript)|image\/svg)/;
|
|
393
|
+
/** A reply with `file:` read once: JSON and YAML become the body (still templated), other text as a string, the rest as bytes. */
|
|
394
|
+
async function prepareReply(reply, vars, base = process.cwd()) {
|
|
395
|
+
if (typeof reply.file !== "string")
|
|
396
|
+
return { template: reply };
|
|
397
|
+
const { file: name, ...rest } = reply;
|
|
398
|
+
const file = path.resolve(base, interpolate(name, vars));
|
|
399
|
+
const bytes = await readFile(file).catch(() => {
|
|
400
|
+
throw new Error(`reply file: can't read ${file}`);
|
|
401
|
+
});
|
|
402
|
+
const ext = path.extname(file).toLowerCase();
|
|
403
|
+
const headers = { ...rest.headers };
|
|
404
|
+
const hasType = Object.keys(headers).some((h) => h.toLowerCase() === "content-type");
|
|
405
|
+
if (ext === ".json" || ext === ".yaml" || ext === ".yml") {
|
|
406
|
+
let body;
|
|
407
|
+
try {
|
|
408
|
+
body = ext === ".json" ? JSON.parse(bytes.toString("utf8")) : parseYaml(bytes.toString("utf8"));
|
|
409
|
+
}
|
|
410
|
+
catch (e) {
|
|
411
|
+
throw new Error(`reply file ${file}: ${e.message}`);
|
|
412
|
+
}
|
|
413
|
+
return { template: { ...rest, headers, body } };
|
|
414
|
+
}
|
|
415
|
+
const type = MIME[ext] ?? "application/octet-stream";
|
|
416
|
+
if (!hasType)
|
|
417
|
+
headers["content-type"] = type;
|
|
418
|
+
if (TEXT_FILE.test(type))
|
|
419
|
+
return { template: { ...rest, headers, body: bytes.toString("utf8") } };
|
|
420
|
+
return { template: { ...rest, headers }, bytes: new Uint8Array(bytes) };
|
|
421
|
+
}
|
|
422
|
+
const MIME = {
|
|
423
|
+
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml",
|
|
424
|
+
".pdf": "application/pdf", ".json": "application/json", ".csv": "text/csv", ".txt": "text/plain", ".xml": "application/xml",
|
|
425
|
+
".zip": "application/zip", ".html": "text/html", ".md": "text/markdown", ".yaml": "text/yaml", ".yml": "text/yaml",
|
|
426
|
+
};
|
|
427
|
+
/** A `{ file }` / `{ content }` part as a File: files are read relative to the scenario file. */
|
|
428
|
+
async function toFile(name, p, base = process.cwd()) {
|
|
429
|
+
if (p.file !== undefined) {
|
|
430
|
+
const file = path.resolve(base, p.file);
|
|
431
|
+
const bytes = await readFile(file).catch(() => {
|
|
432
|
+
throw new Error(`field "${name}": can't read ${file}`);
|
|
433
|
+
});
|
|
434
|
+
const type = p.type ?? MIME[path.extname(file).toLowerCase()] ?? "application/octet-stream";
|
|
435
|
+
return new File([new Uint8Array(bytes)], p.filename ?? path.basename(file), { type });
|
|
436
|
+
}
|
|
437
|
+
const content = typeof p.content === "string" ? p.content : JSON.stringify(p.content);
|
|
438
|
+
return new File([content], p.filename ?? name, { type: p.type ?? (typeof p.content === "string" ? "text/plain" : "application/json") });
|
|
439
|
+
}
|
|
440
|
+
/** A YAML `multipart:` mapping as FormData. */
|
|
441
|
+
async function multipart(fields, base) {
|
|
442
|
+
const data = new FormData();
|
|
443
|
+
for (const [name, value] of Object.entries(fields)) {
|
|
444
|
+
for (const part of Array.isArray(value) ? value : [value]) {
|
|
445
|
+
if (part && typeof part === "object")
|
|
446
|
+
data.append(name, await toFile(name, part, base));
|
|
447
|
+
else
|
|
448
|
+
data.append(name, String(part));
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return data;
|
|
452
|
+
}
|
|
453
|
+
/** Submit fields with `{ file }` / `{ content }` values turned into Files. */
|
|
454
|
+
async function withFiles(fields, base) {
|
|
455
|
+
if (!fields)
|
|
456
|
+
return undefined;
|
|
457
|
+
const out = {};
|
|
458
|
+
for (const [name, v] of Object.entries(fields)) {
|
|
459
|
+
out[name] = v && typeof v === "object" && !Array.isArray(v) ? await toFile(name, v, base) : v;
|
|
460
|
+
}
|
|
461
|
+
return out;
|
|
462
|
+
}
|
|
316
463
|
function splitCall(s) {
|
|
317
464
|
const i = s.search(/\s/);
|
|
318
465
|
return [s.slice(0, i).toUpperCase(), s.slice(i).trim()];
|
|
@@ -348,7 +495,40 @@ export function interpolate(value, vars) {
|
|
|
348
495
|
}
|
|
349
496
|
return value;
|
|
350
497
|
}
|
|
498
|
+
/** `{{$seq}}`: 1, 2, 3… within a scenario, so generated values are unique and the same on every run. */
|
|
499
|
+
let seq = 0;
|
|
500
|
+
const OFFSET = /^\$(now|today|timestamp|timestampMs)\s*(?:([+-])\s*(\d+)\s*(ms|s|m|h|d))?$/;
|
|
501
|
+
const UNIT_MS = { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 };
|
|
502
|
+
/** Built-in values: `$uuid`, `$seq`, `$now`, `$today`, `$timestamp`, `$timestampMs` (the last four with `+1d`, `-30m`, …). */
|
|
503
|
+
function builtin(name) {
|
|
504
|
+
if (name === "$uuid")
|
|
505
|
+
return crypto.randomUUID();
|
|
506
|
+
if (name === "$seq")
|
|
507
|
+
return ++seq;
|
|
508
|
+
const m = OFFSET.exec(name);
|
|
509
|
+
if (!m)
|
|
510
|
+
throw new Error(`unknown built-in {{${name}}} (available: $uuid, $seq, $now, $today, $timestamp, $timestampMs; the time ones take an offset such as $now+1d or $timestamp-30m)`);
|
|
511
|
+
const t = Date.now() + (m[2] ? (m[2] === "-" ? -1 : 1) * Number(m[3]) * UNIT_MS[m[4]] : 0);
|
|
512
|
+
switch (m[1]) {
|
|
513
|
+
case "now":
|
|
514
|
+
return new Date(t).toISOString();
|
|
515
|
+
case "today":
|
|
516
|
+
return new Date(t).toISOString().slice(0, 10);
|
|
517
|
+
case "timestamp":
|
|
518
|
+
return Math.floor(t / 1000);
|
|
519
|
+
default:
|
|
520
|
+
return t;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
351
523
|
function resolve(name, vars) {
|
|
524
|
+
if (name.startsWith("$"))
|
|
525
|
+
return builtin(name);
|
|
526
|
+
if (name.startsWith("env.") && !("env" in vars)) {
|
|
527
|
+
const v = process.env[name.slice(4)];
|
|
528
|
+
if (v === undefined)
|
|
529
|
+
throw new Error(`{{${name}}}: the environment variable ${name.slice(4)} isn't set`);
|
|
530
|
+
return v;
|
|
531
|
+
}
|
|
352
532
|
const v = lookup(vars, name);
|
|
353
533
|
if (v === undefined) {
|
|
354
534
|
const known = Object.keys(vars).filter((k) => k !== "call");
|
|
@@ -363,9 +543,95 @@ const TYPES = {
|
|
|
363
543
|
array: expect.any(Array),
|
|
364
544
|
object: expect.any(Object),
|
|
365
545
|
};
|
|
546
|
+
const FORMATS = {
|
|
547
|
+
uuid: /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
|
|
548
|
+
email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
|
549
|
+
date: /^\d{4}-\d{2}-\d{2}$/,
|
|
550
|
+
"date-time": /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/i,
|
|
551
|
+
uri: /^[a-z][a-z0-9+.-]*:\/\/\S+$/i,
|
|
552
|
+
integer: /^-?\d+$/,
|
|
553
|
+
};
|
|
554
|
+
const MATCHERS = ["$type", "$regex", "$contains", "$any", "$gt", "$gte", "$lt", "$lte", "$len", "$not", "$oneOf", "$format"];
|
|
555
|
+
/** An asymmetric matcher Vitest's `toEqual` / `toMatchObject` call, with a readable name in diffs. */
|
|
556
|
+
function matcher(name, test) {
|
|
557
|
+
return { asymmetricMatch: test, toAsymmetricMatcher: () => name, toString: () => name };
|
|
558
|
+
}
|
|
559
|
+
/** Numbers compare as numbers; strings (ISO dates, versions) compare as strings; anything else never matches. */
|
|
560
|
+
function compare(op, arg, holds) {
|
|
561
|
+
if (typeof arg !== "number" && typeof arg !== "string")
|
|
562
|
+
throw new Error(`${op} takes a number or a string, got ${JSON.stringify(arg)}`);
|
|
563
|
+
return matcher(`${op} ${JSON.stringify(arg)}`, (v) => {
|
|
564
|
+
if (typeof arg === "number")
|
|
565
|
+
return typeof v === "number" && holds(v - arg);
|
|
566
|
+
return typeof v === "string" && holds(v < arg ? -1 : v > arg ? 1 : 0);
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
function equalsMatcher(expected, actual) {
|
|
570
|
+
return isAsymmetric(expected) ? expected.asymmetricMatch(actual) : subsetEquals(expected, actual);
|
|
571
|
+
}
|
|
572
|
+
/** `expected` contained in `actual`, with matchers anywhere: the rules of expected JSON. */
|
|
573
|
+
function subsetEquals(expected, actual) {
|
|
574
|
+
if (isAsymmetric(expected))
|
|
575
|
+
return expected.asymmetricMatch(actual);
|
|
576
|
+
if (Array.isArray(expected))
|
|
577
|
+
return Array.isArray(actual) && actual.length === expected.length && expected.every((e, i) => subsetEquals(e, actual[i]));
|
|
578
|
+
if (expected && typeof expected === "object") {
|
|
579
|
+
return !!actual && typeof actual === "object" && Object.entries(expected).every(([k, v]) => k in actual && subsetEquals(v, actual[k]));
|
|
580
|
+
}
|
|
581
|
+
return Object.is(expected, actual) || expected === actual;
|
|
582
|
+
}
|
|
583
|
+
function single(key, arg) {
|
|
584
|
+
switch (key) {
|
|
585
|
+
case "$type":
|
|
586
|
+
if (arg === "null")
|
|
587
|
+
return null;
|
|
588
|
+
if (arg === "integer")
|
|
589
|
+
return matcher("$type integer", (v) => Number.isInteger(v));
|
|
590
|
+
if (typeof arg === "string" && arg in TYPES)
|
|
591
|
+
return TYPES[arg];
|
|
592
|
+
throw new Error(`$type must be one of ${Object.keys(TYPES).join(", ")}, integer, null`);
|
|
593
|
+
case "$regex":
|
|
594
|
+
return expect.stringMatching(new RegExp(String(arg)));
|
|
595
|
+
case "$contains":
|
|
596
|
+
return matcher(`$contains ${JSON.stringify(arg)}`, (v) => typeof v === "string" ? v.includes(String(arg)) : Array.isArray(v) ? v.some((item) => equalsMatcher(toMatchers(arg), item)) : false);
|
|
597
|
+
case "$any":
|
|
598
|
+
return expect.anything();
|
|
599
|
+
case "$gt":
|
|
600
|
+
return compare(key, arg, (c) => c > 0);
|
|
601
|
+
case "$gte":
|
|
602
|
+
return compare(key, arg, (c) => c >= 0);
|
|
603
|
+
case "$lt":
|
|
604
|
+
return compare(key, arg, (c) => c < 0);
|
|
605
|
+
case "$lte":
|
|
606
|
+
return compare(key, arg, (c) => c <= 0);
|
|
607
|
+
case "$len": {
|
|
608
|
+
const want = toMatchers(arg);
|
|
609
|
+
return matcher(`$len ${JSON.stringify(arg)}`, (v) => (typeof v === "string" || Array.isArray(v)) && equalsMatcher(want, v.length));
|
|
610
|
+
}
|
|
611
|
+
case "$not": {
|
|
612
|
+
const want = toMatchers(arg);
|
|
613
|
+
return matcher(`$not ${JSON.stringify(arg)}`, (v) => !equalsMatcher(want, v));
|
|
614
|
+
}
|
|
615
|
+
case "$oneOf": {
|
|
616
|
+
if (!Array.isArray(arg))
|
|
617
|
+
throw new Error(`$oneOf takes a list, got ${JSON.stringify(arg)}`);
|
|
618
|
+
const options = arg.map(toMatchers);
|
|
619
|
+
return matcher(`$oneOf ${JSON.stringify(arg)}`, (v) => options.some((o) => equalsMatcher(o, v)));
|
|
620
|
+
}
|
|
621
|
+
case "$format": {
|
|
622
|
+
const re = FORMATS[String(arg)];
|
|
623
|
+
if (!re)
|
|
624
|
+
throw new Error(`$format must be one of ${Object.keys(FORMATS).join(", ")}`);
|
|
625
|
+
return matcher(`$format ${arg}`, (v) => typeof v === "string" && re.test(v));
|
|
626
|
+
}
|
|
627
|
+
default:
|
|
628
|
+
throw new Error(`unknown matcher ${key} (expected one of ${MATCHERS.join(", ")})`);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
366
631
|
/**
|
|
367
|
-
* `{ $type: number }`, `{ $regex: "^ch_" }`, `{ $contains: "x" }`
|
|
368
|
-
* `{ $
|
|
632
|
+
* `{ $type: number }`, `{ $regex: "^ch_" }`, `{ $contains: "x" }`, `{ $any: true }`,
|
|
633
|
+
* `{ $gte: 1, $lt: 10 }`, `{ $len: 3 }`, `{ $not: … }`, `{ $oneOf: [...] }` and
|
|
634
|
+
* `{ $format: uuid }` become Vitest asymmetric matchers. Several `$` keys must all hold.
|
|
369
635
|
*/
|
|
370
636
|
export function toMatchers(value) {
|
|
371
637
|
if (Array.isArray(value))
|
|
@@ -373,25 +639,11 @@ export function toMatchers(value) {
|
|
|
373
639
|
if (!value || typeof value !== "object" || isAsymmetric(value))
|
|
374
640
|
return value;
|
|
375
641
|
const keys = Object.keys(value);
|
|
376
|
-
if (keys.length
|
|
377
|
-
const
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
if (arg === "null")
|
|
382
|
-
return null;
|
|
383
|
-
if (typeof arg === "string" && arg in TYPES)
|
|
384
|
-
return TYPES[arg];
|
|
385
|
-
throw new Error(`$type must be one of ${Object.keys(TYPES).join(", ")}, null`);
|
|
386
|
-
case "$regex":
|
|
387
|
-
return expect.stringMatching(new RegExp(String(arg)));
|
|
388
|
-
case "$contains":
|
|
389
|
-
return expect.stringContaining(String(arg));
|
|
390
|
-
case "$any":
|
|
391
|
-
return expect.anything();
|
|
392
|
-
default:
|
|
393
|
-
throw new Error(`unknown matcher ${key} (expected $type, $regex, $contains, $any)`);
|
|
394
|
-
}
|
|
642
|
+
if (keys.length > 0 && keys.every((k) => k.startsWith("$"))) {
|
|
643
|
+
const parts = keys.map((k) => single(k, value[k]));
|
|
644
|
+
if (parts.length === 1)
|
|
645
|
+
return parts[0];
|
|
646
|
+
return matcher(keys.map((k) => `${k} ${JSON.stringify(value[k])}`).join(", "), (v) => parts.every((p) => equalsMatcher(p, v)));
|
|
395
647
|
}
|
|
396
648
|
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, toMatchers(v)]));
|
|
397
649
|
}
|
|
@@ -406,7 +658,7 @@ function interpolateTitle(name, row, index) {
|
|
|
406
658
|
return v === undefined ? m : typeof v === "object" ? JSON.stringify(v) : String(v);
|
|
407
659
|
});
|
|
408
660
|
}
|
|
409
|
-
function verifyResponse(res, e, vars) {
|
|
661
|
+
function verifyResponse(res, e, vars, base) {
|
|
410
662
|
if (e?.status !== undefined)
|
|
411
663
|
expect(res).toHaveStatus(interpolate(e.status, vars));
|
|
412
664
|
if (e?.headers !== undefined) {
|
|
@@ -417,4 +669,9 @@ function verifyResponse(res, e, vars) {
|
|
|
417
669
|
check(res.json, e.json, vars, "response JSON");
|
|
418
670
|
if (e?.text !== undefined)
|
|
419
671
|
check(res.text, e.text, vars, "response text");
|
|
672
|
+
if (e?.schema !== undefined) {
|
|
673
|
+
const problems = schemaProblems(e.schema, res.json, base);
|
|
674
|
+
if (problems.length)
|
|
675
|
+
throw new Error(`${res.method} ${res.url}: the response JSON doesn't match ${typeof e.schema === "string" ? e.schema : "the schema"}:\n${problems.map((p) => ` ${p}`).join("\n")}`);
|
|
676
|
+
}
|
|
420
677
|
}
|