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.
Files changed (51) hide show
  1. package/README.md +177 -24
  2. package/dist/cli.js +66 -2
  3. package/dist/config.d.ts +18 -0
  4. package/dist/config.js +13 -0
  5. package/dist/connection.d.ts +5 -0
  6. package/dist/connection.js +31 -0
  7. package/dist/db.d.ts +12 -1
  8. package/dist/db.js +33 -6
  9. package/dist/diagram.d.ts +13 -0
  10. package/dist/diagram.js +89 -0
  11. package/dist/form.d.ts +7 -2
  12. package/dist/form.js +72 -7
  13. package/dist/global-setup.d.ts +5 -0
  14. package/dist/global-setup.js +74 -9
  15. package/dist/graphql.d.ts +34 -0
  16. package/dist/graphql.js +56 -0
  17. package/dist/har.d.ts +49 -0
  18. package/dist/har.js +107 -0
  19. package/dist/http.d.ts +21 -3
  20. package/dist/http.js +47 -10
  21. package/dist/index.d.ts +5 -3
  22. package/dist/init.js +184 -15
  23. package/dist/list.d.ts +26 -0
  24. package/dist/list.js +71 -0
  25. package/dist/matchers.d.ts +16 -0
  26. package/dist/matchers.js +81 -1
  27. package/dist/openapi.d.ts +21 -0
  28. package/dist/openapi.js +32 -1
  29. package/dist/provided.d.ts +1 -0
  30. package/dist/record.d.ts +1 -1
  31. package/dist/record.js +3 -1
  32. package/dist/recording.d.ts +1 -1
  33. package/dist/recording.js +2 -2
  34. package/dist/runtime.d.ts +12 -5
  35. package/dist/runtime.js +78 -22
  36. package/dist/scenario.d.ts +14 -4
  37. package/dist/scenario.js +36 -17
  38. package/dist/schema.d.ts +5 -0
  39. package/dist/schema.js +73 -0
  40. package/dist/stub.d.ts +63 -0
  41. package/dist/stub.js +292 -5
  42. package/dist/timeline.d.ts +9 -0
  43. package/dist/timeline.js +6 -0
  44. package/dist/webhook.d.ts +16 -7
  45. package/dist/webhook.js +47 -7
  46. package/dist/yaml-runtime.d.ts +3 -2
  47. package/dist/yaml-runtime.js +308 -51
  48. package/dist/yaml.d.ts +82 -6
  49. package/dist/yaml.js +187 -21
  50. package/package.json +2 -1
  51. package/schema/scenario.schema.json +640 -14
@@ -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) => runSteps(doc, sc, [...doc.setup, ...sc.steps], ctx, { ...row }), sc.timeout);
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
- await retry("within" in step && !("log" in step) && !("mail" in step) ? step.within : undefined, () => runStep(step, ctx, vars));
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}`, message }).catch(() => { });
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, 50));
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 [method, path] = splitCall(step.on);
149
- let route = ctx.stub(step.stub).on(method, interpolate(path, vars), conditions(step.when, vars));
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
- // Replies are interpolated when a call arrives, so they can echo it: {{call.params.id}}, {{call.json.name}}.
155
- const answer = (template) => (call) => {
156
- const { sse: events, ...response } = interpolate(template, { ...vars, call: callVars(call) });
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
- let body = step.json !== undefined
195
- ? interpolate(step.json, vars)
196
- : step.form !== undefined
197
- ? ctx.http.form(interpolate(step.form, vars))
198
- : interpolate(step.body, vars);
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 signed = webhookBody(body);
262
+ const hook = interpolate(step.webhook, vars);
263
+ const signed = webhookBody(body, hook.provider);
201
264
  body = signed.body;
202
- opts.headers = { "content-type": signed.type, ...signWebhook(signed.body, interpolate(step.webhook, vars)), ...opts.headers };
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) => verifyResponse(res, e, vars);
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" }` and
368
- * `{ $any: true }` become Vitest asymmetric matchers.
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 === 1 && keys[0].startsWith("$")) {
377
- const [key] = keys;
378
- const arg = value[key];
379
- switch (key) {
380
- case "$type":
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
  }