slicetest 0.7.0 → 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 +71 -13
- package/dist/cli.js +20 -2
- package/dist/config.d.ts +6 -0
- package/dist/config.js +3 -0
- package/dist/db.d.ts +12 -1
- package/dist/db.js +33 -6
- 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 +29 -5
- package/dist/http.d.ts +11 -3
- package/dist/http.js +32 -9
- package/dist/index.d.ts +4 -3
- package/dist/list.d.ts +26 -0
- package/dist/list.js +71 -0
- package/dist/matchers.d.ts +7 -0
- package/dist/matchers.js +34 -0
- package/dist/record.d.ts +1 -1
- package/dist/record.js +3 -1
- package/dist/runtime.js +1 -0
- package/dist/scenario.d.ts +14 -4
- package/dist/scenario.js +36 -20
- package/dist/stub.d.ts +22 -0
- package/dist/stub.js +105 -2
- 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 +244 -34
- package/dist/yaml.d.ts +43 -2
- package/dist/yaml.js +91 -12
- package/package.json +1 -1
- package/schema/scenario.schema.json +361 -2
package/dist/yaml-runtime.js
CHANGED
|
@@ -5,6 +5,8 @@ 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";
|
|
8
10
|
import path from "node:path";
|
|
9
11
|
import { graphqlErrors } from "./graphql.js";
|
|
10
12
|
import { schemaProblems } from "./schema.js";
|
|
@@ -16,7 +18,10 @@ export function defineYamlScenarios(doc) {
|
|
|
16
18
|
const rows = sc.each ?? [undefined];
|
|
17
19
|
rows.forEach((row, i) => {
|
|
18
20
|
const title = row ? interpolateTitle(sc.name, row, i) : sc.name;
|
|
19
|
-
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 });
|
|
20
25
|
});
|
|
21
26
|
}
|
|
22
27
|
}
|
|
@@ -35,7 +40,8 @@ async function runSteps(doc, sc, steps, ctx, vars, trail = []) {
|
|
|
35
40
|
try {
|
|
36
41
|
// A log step waits by itself; `within` is its timeout.
|
|
37
42
|
// Log and mail steps wait by themselves; `within` is their timeout.
|
|
38
|
-
|
|
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);
|
|
39
45
|
}
|
|
40
46
|
catch (e) {
|
|
41
47
|
const label = step.name ?? describeStep(step);
|
|
@@ -76,6 +82,10 @@ function describeStep(step) {
|
|
|
76
82
|
return "changes";
|
|
77
83
|
if ("checkpoint" in step)
|
|
78
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(" → ")}`;
|
|
79
89
|
if ("snapshot" in step)
|
|
80
90
|
return "snapshot";
|
|
81
91
|
if ("mail" in step)
|
|
@@ -85,7 +95,7 @@ function describeStep(step) {
|
|
|
85
95
|
return "sql";
|
|
86
96
|
}
|
|
87
97
|
/** Run `fn` until it passes or `within` ms have passed, then rethrow its last error. */
|
|
88
|
-
async function retry(within, fn) {
|
|
98
|
+
async function retry(within, fn, every = 50) {
|
|
89
99
|
if (!within)
|
|
90
100
|
return fn();
|
|
91
101
|
const deadline = Date.now() + within;
|
|
@@ -99,13 +109,27 @@ async function retry(within, fn) {
|
|
|
99
109
|
e.message = `${e.message}\n(still failing after retrying for ${within}ms)`;
|
|
100
110
|
throw e;
|
|
101
111
|
}
|
|
102
|
-
await new Promise((r) => setTimeout(r,
|
|
112
|
+
await new Promise((r) => setTimeout(r, every));
|
|
103
113
|
}
|
|
104
114
|
}
|
|
105
115
|
}
|
|
106
116
|
async function runStep(step, ctx, vars, base) {
|
|
107
117
|
if ("use" in step)
|
|
108
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
|
+
}
|
|
109
133
|
if ("checkpoint" in step) {
|
|
110
134
|
await ctx.db.checkpoint();
|
|
111
135
|
return;
|
|
@@ -138,7 +162,7 @@ async function runStep(step, ctx, vars, base) {
|
|
|
138
162
|
return;
|
|
139
163
|
}
|
|
140
164
|
if ("changes" in step) {
|
|
141
|
-
const actual = await ctx.db.changes();
|
|
165
|
+
const actual = await ctx.db.changes({ ignore: step.ignore });
|
|
142
166
|
const expected = interpolate(step.changes, vars);
|
|
143
167
|
const unexpected = Object.keys(actual).filter((t) => !(t in expected));
|
|
144
168
|
if (unexpected.length) {
|
|
@@ -179,8 +203,10 @@ async function runStep(step, ctx, vars, base) {
|
|
|
179
203
|
if (step.optional)
|
|
180
204
|
route = route.optional();
|
|
181
205
|
// Replies are interpolated when a call arrives, so they can echo it: {{call.params.id}}, {{call.json.name}}, {{call.variables.id}}.
|
|
182
|
-
const answer = (
|
|
183
|
-
const { sse: events, data, errors, ...response } = interpolate(template, { ...vars, call: callVars(call) });
|
|
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 };
|
|
184
210
|
if (data !== undefined || errors !== undefined) {
|
|
185
211
|
return { status: 200, ...response, body: { ...(errors ? { errors: graphqlErrors(errors) } : {}), ...(data !== undefined ? { data } : {}) } };
|
|
186
212
|
}
|
|
@@ -190,11 +216,11 @@ async function runStep(step, ctx, vars, base) {
|
|
|
190
216
|
route.networkError();
|
|
191
217
|
else if (step.sequence) {
|
|
192
218
|
let n = 0;
|
|
193
|
-
const seq = step.sequence;
|
|
219
|
+
const seq = await Promise.all(step.sequence.map((r) => prepareReply(r, vars, base)));
|
|
194
220
|
route.reply((call) => answer(seq[Math.min(n++, seq.length - 1)])(call));
|
|
195
221
|
}
|
|
196
222
|
else
|
|
197
|
-
route.reply(answer(step.reply ?? {}));
|
|
223
|
+
route.reply(answer(await prepareReply(step.reply ?? {}, vars, base)));
|
|
198
224
|
return;
|
|
199
225
|
}
|
|
200
226
|
if ("submit" in step) {
|
|
@@ -204,7 +230,7 @@ async function runStep(step, ctx, vars, base) {
|
|
|
204
230
|
const res = await ctx.http.submit(page, {
|
|
205
231
|
button: step.submit === true ? undefined : interpolate(step.submit, vars),
|
|
206
232
|
form: interpolate(step.form, vars),
|
|
207
|
-
fields: interpolate(step.fields, vars),
|
|
233
|
+
fields: await withFiles(interpolate(step.fields, vars), base),
|
|
208
234
|
headers: interpolate(step.headers, vars),
|
|
209
235
|
follow: step.follow,
|
|
210
236
|
});
|
|
@@ -220,6 +246,7 @@ async function runStep(step, ctx, vars, base) {
|
|
|
220
246
|
headers: step.auth ? { ...ctx.auth.header(step.auth === true ? {} : interpolate(step.auth, vars)), ...headers } : headers,
|
|
221
247
|
query: interpolate(step.query, vars),
|
|
222
248
|
follow: step.follow,
|
|
249
|
+
...(step.timeout !== undefined ? { timeout: step.timeout } : {}),
|
|
223
250
|
};
|
|
224
251
|
const gql = step.graphql === undefined ? undefined : interpolate(typeof step.graphql === "string" ? { query: step.graphql } : step.graphql, vars);
|
|
225
252
|
let body = gql !== undefined
|
|
@@ -228,11 +255,17 @@ async function runStep(step, ctx, vars, base) {
|
|
|
228
255
|
? interpolate(step.json, vars)
|
|
229
256
|
: step.form !== undefined
|
|
230
257
|
? ctx.http.form(interpolate(step.form, vars))
|
|
231
|
-
:
|
|
258
|
+
: step.multipart !== undefined
|
|
259
|
+
? await multipart(interpolate(step.multipart, vars), base)
|
|
260
|
+
: interpolate(step.body, vars);
|
|
232
261
|
if (step.webhook) {
|
|
233
|
-
const
|
|
262
|
+
const hook = interpolate(step.webhook, vars);
|
|
263
|
+
const signed = webhookBody(body, hook.provider);
|
|
234
264
|
body = signed.body;
|
|
235
|
-
|
|
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 };
|
|
236
269
|
}
|
|
237
270
|
const e = step.expect;
|
|
238
271
|
const verify = (res) => {
|
|
@@ -351,10 +384,82 @@ function callVars(call) {
|
|
|
351
384
|
query: Object.fromEntries(call.query),
|
|
352
385
|
headers: call.headers,
|
|
353
386
|
json: call.json,
|
|
387
|
+
form: call.form,
|
|
354
388
|
body: call.body,
|
|
355
389
|
variables: call.graphql?.variables,
|
|
356
390
|
};
|
|
357
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
|
+
}
|
|
358
463
|
function splitCall(s) {
|
|
359
464
|
const i = s.search(/\s/);
|
|
360
465
|
return [s.slice(0, i).toUpperCase(), s.slice(i).trim()];
|
|
@@ -390,7 +495,40 @@ export function interpolate(value, vars) {
|
|
|
390
495
|
}
|
|
391
496
|
return value;
|
|
392
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
|
+
}
|
|
393
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
|
+
}
|
|
394
532
|
const v = lookup(vars, name);
|
|
395
533
|
if (v === undefined) {
|
|
396
534
|
const known = Object.keys(vars).filter((k) => k !== "call");
|
|
@@ -405,9 +543,95 @@ const TYPES = {
|
|
|
405
543
|
array: expect.any(Array),
|
|
406
544
|
object: expect.any(Object),
|
|
407
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
|
+
}
|
|
408
631
|
/**
|
|
409
|
-
* `{ $type: number }`, `{ $regex: "^ch_" }`, `{ $contains: "x" }`
|
|
410
|
-
* `{ $
|
|
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.
|
|
411
635
|
*/
|
|
412
636
|
export function toMatchers(value) {
|
|
413
637
|
if (Array.isArray(value))
|
|
@@ -415,25 +639,11 @@ export function toMatchers(value) {
|
|
|
415
639
|
if (!value || typeof value !== "object" || isAsymmetric(value))
|
|
416
640
|
return value;
|
|
417
641
|
const keys = Object.keys(value);
|
|
418
|
-
if (keys.length
|
|
419
|
-
const
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
if (arg === "null")
|
|
424
|
-
return null;
|
|
425
|
-
if (typeof arg === "string" && arg in TYPES)
|
|
426
|
-
return TYPES[arg];
|
|
427
|
-
throw new Error(`$type must be one of ${Object.keys(TYPES).join(", ")}, null`);
|
|
428
|
-
case "$regex":
|
|
429
|
-
return expect.stringMatching(new RegExp(String(arg)));
|
|
430
|
-
case "$contains":
|
|
431
|
-
return expect.stringContaining(String(arg));
|
|
432
|
-
case "$any":
|
|
433
|
-
return expect.anything();
|
|
434
|
-
default:
|
|
435
|
-
throw new Error(`unknown matcher ${key} (expected $type, $regex, $contains, $any)`);
|
|
436
|
-
}
|
|
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)));
|
|
437
647
|
}
|
|
438
648
|
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, toMatchers(v)]));
|
|
439
649
|
}
|
package/dist/yaml.d.ts
CHANGED
|
@@ -24,9 +24,11 @@ export interface YamlScenario {
|
|
|
24
24
|
skip?: boolean;
|
|
25
25
|
only?: boolean;
|
|
26
26
|
timeout?: number;
|
|
27
|
+
/** Labels to select scenarios by (`npx slicetest --tag smoke`). */
|
|
28
|
+
tags?: string[];
|
|
27
29
|
steps: Step[];
|
|
28
30
|
}
|
|
29
|
-
export type Step = (StubStep | RequestStep | SubmitStep | InsertStep | MakeStep | ChaosStep | SqlStep | DbStep | ReceivedStep | ChangesStep | CheckpointStep | LogStep | SnapshotStep | MailStep | UseStep) & {
|
|
31
|
+
export type Step = (StubStep | RequestStep | SubmitStep | InsertStep | MakeStep | ChaosStep | SqlStep | DbStep | ReceivedStep | ChangesStep | CheckpointStep | SetStep | OrderStep | LogStep | SnapshotStep | MailStep | UseStep) & {
|
|
30
32
|
line: number;
|
|
31
33
|
name?: string;
|
|
32
34
|
};
|
|
@@ -49,10 +51,12 @@ export interface StubStep {
|
|
|
49
51
|
graphql?: string;
|
|
50
52
|
when?: Conditions;
|
|
51
53
|
/** `sse`: a streamed reply, a list of { event, data, id } (instead of `body`). */
|
|
54
|
+
/** `file`: the body from a file relative to the scenario (.json / .yaml as JSON, with `{{call.*}}`; others as they are). */
|
|
52
55
|
reply?: {
|
|
53
56
|
status?: number;
|
|
54
57
|
headers?: Record<string, string>;
|
|
55
58
|
body?: unknown;
|
|
59
|
+
file?: string;
|
|
56
60
|
sse?: {
|
|
57
61
|
event?: string;
|
|
58
62
|
data: unknown;
|
|
@@ -63,6 +67,7 @@ export interface StubStep {
|
|
|
63
67
|
status?: number;
|
|
64
68
|
headers?: Record<string, string>;
|
|
65
69
|
body?: unknown;
|
|
70
|
+
file?: string;
|
|
66
71
|
sse?: {
|
|
67
72
|
event?: string;
|
|
68
73
|
data: unknown;
|
|
@@ -79,6 +84,8 @@ export interface Conditions {
|
|
|
79
84
|
query?: Record<string, unknown>;
|
|
80
85
|
headers?: Record<string, unknown>;
|
|
81
86
|
json?: unknown;
|
|
87
|
+
/** Subset of a form-encoded body's fields, bracket keys nested (`metadata[order]` → `metadata: { order }`). */
|
|
88
|
+
form?: Record<string, unknown>;
|
|
82
89
|
body?: unknown;
|
|
83
90
|
/** Subset of a GraphQL request's variables (with `graphql`). */
|
|
84
91
|
variables?: unknown;
|
|
@@ -89,6 +96,8 @@ export interface RequestStep {
|
|
|
89
96
|
query?: Record<string, unknown>;
|
|
90
97
|
json?: unknown;
|
|
91
98
|
form?: Record<string, unknown>;
|
|
99
|
+
/** A `multipart/form-data` body: values are fields, `{ file, type, filename }` uploads a file relative to the scenario, `{ content, filename, type }` an inline one. */
|
|
100
|
+
multipart?: Record<string, unknown>;
|
|
92
101
|
body?: string;
|
|
93
102
|
/** A GraphQL operation sent as the JSON body. */
|
|
94
103
|
graphql?: string | {
|
|
@@ -114,6 +123,12 @@ export interface RequestStep {
|
|
|
114
123
|
};
|
|
115
124
|
/** Send the request this many times at once. `expect` applies to every response; `statuses` counts them. */
|
|
116
125
|
concurrency?: number;
|
|
126
|
+
/** Repeat the request until `expect` passes, for up to this many ms: polling a job the app finishes later. */
|
|
127
|
+
within?: number;
|
|
128
|
+
/** With `within`: ms between attempts. Default 200. */
|
|
129
|
+
every?: number;
|
|
130
|
+
/** Fail when the app hasn't answered within this many ms (overrides `http.timeout`). */
|
|
131
|
+
timeout?: number;
|
|
117
132
|
/** `queries`: at most this many SQL statements (not counting BEGIN/COMMIT), with `db.queries` on. */
|
|
118
133
|
/** `schema`: a JSON Schema for the response JSON, inline or `file#/pointer` relative to the scenario file. */
|
|
119
134
|
expect?: {
|
|
@@ -136,7 +151,8 @@ export interface SubmitStep {
|
|
|
136
151
|
/** The form by id, name or 0-based position, when the page has several. */
|
|
137
152
|
form?: string | number;
|
|
138
153
|
/** Values typed into the form by field name; true / false / a list check checkboxes and radios. */
|
|
139
|
-
|
|
154
|
+
/** `{ file: path }` (relative to the scenario) or `{ content, filename, type }` for a file input. */
|
|
155
|
+
fields?: Record<string, string | number | boolean | (string | number)[] | FilePart>;
|
|
140
156
|
headers?: Record<string, string>;
|
|
141
157
|
follow?: boolean;
|
|
142
158
|
expect?: {
|
|
@@ -216,6 +232,8 @@ export type ChangeSpec = {
|
|
|
216
232
|
export interface ChangesStep {
|
|
217
233
|
changes: Record<string, ChangeSpec>;
|
|
218
234
|
within?: number;
|
|
235
|
+
/** Columns or tables to leave out: `updated_at`, `orders.synced_at`, `sessions.*`. */
|
|
236
|
+
ignore?: string[];
|
|
219
237
|
}
|
|
220
238
|
/** Wait until the app (or `from:` a service) prints a line matching `log` (a regex) during the scenario. */
|
|
221
239
|
export interface LogStep {
|
|
@@ -224,6 +242,29 @@ export interface LogStep {
|
|
|
224
242
|
/** Milliseconds to wait. Default 5000. */
|
|
225
243
|
within?: number;
|
|
226
244
|
}
|
|
245
|
+
/**
|
|
246
|
+
* Calls to stubs happened in this order (others may come in between):
|
|
247
|
+
* `order: ["stripe POST /v1/charges", { stub: mail, call: POST /send, when: { json: { to: a@b.test } } }]`.
|
|
248
|
+
*/
|
|
249
|
+
export interface OrderStep {
|
|
250
|
+
order: (string | {
|
|
251
|
+
stub: string;
|
|
252
|
+
call: string;
|
|
253
|
+
when?: Conditions;
|
|
254
|
+
})[];
|
|
255
|
+
within?: number;
|
|
256
|
+
}
|
|
257
|
+
/** A file to upload: read from `file` (relative to the scenario file), or `content` given inline. */
|
|
258
|
+
export interface FilePart {
|
|
259
|
+
file?: string;
|
|
260
|
+
content?: unknown;
|
|
261
|
+
filename?: string;
|
|
262
|
+
type?: string;
|
|
263
|
+
}
|
|
264
|
+
/** Define variables for later steps: `set: { orderId: "{{$uuid}}" }`. */
|
|
265
|
+
export interface SetStep {
|
|
266
|
+
set: Record<string, unknown>;
|
|
267
|
+
}
|
|
227
268
|
/** Later `changes` steps only see what happens after this step. */
|
|
228
269
|
export interface CheckpointStep {
|
|
229
270
|
checkpoint: true;
|