slicetest 0.7.0 → 0.9.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.
@@ -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) => 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 });
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
- 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));
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, 50));
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 = (template) => (call) => {
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,12 +230,12 @@ 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
  });
211
237
  verifyResponse(res, step.expect, vars, base);
212
- capture(step.capture, { status: res.status, json: res.json, text: res.text, headers: Object.fromEntries(res.headers) }, vars);
238
+ capture(step.capture, { status: res.status, json: res.json, text: res.text, headers: Object.fromEntries(res.headers), events: res.events }, vars);
213
239
  return;
214
240
  }
215
241
  if ("request" in step) {
@@ -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
- : interpolate(step.body, vars);
258
+ : step.multipart !== undefined
259
+ ? await multipart(interpolate(step.multipart, vars), base)
260
+ : interpolate(step.body, vars);
232
261
  if (step.webhook) {
233
- const signed = webhookBody(body);
262
+ const hook = interpolate(step.webhook, vars);
263
+ const signed = webhookBody(body, hook.provider);
234
264
  body = signed.body;
235
- 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 };
236
269
  }
237
270
  const e = step.expect;
238
271
  const verify = (res) => {
@@ -263,7 +296,7 @@ async function runStep(step, ctx, vars, base) {
263
296
  }
264
297
  }
265
298
  verify(res);
266
- capture(step.capture, { status: res.status, json: res.json, text: res.text, headers: Object.fromEntries(res.headers) }, vars);
299
+ capture(step.capture, { status: res.status, json: res.json, text: res.text, headers: Object.fromEntries(res.headers), events: res.events }, vars);
267
300
  return;
268
301
  }
269
302
  if ("insert" in step) {
@@ -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,107 @@ 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", "$closeTo", "$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
+ /** A decimal as Postgres `numeric` and MySQL `DECIMAL` columns come back, to keep their precision: `"12.50"`. */
560
+ const DECIMAL = /^-?\d+(\.\d+)?$/;
561
+ /**
562
+ * Numbers compare as numbers, also with decimal strings from `numeric` / `DECIMAL` columns; strings
563
+ * (ISO dates, versions) compare as strings; anything else never matches.
564
+ */
565
+ function compare(op, arg, holds) {
566
+ if (typeof arg !== "number" && typeof arg !== "string")
567
+ throw new Error(`${op} takes a number or a string, got ${JSON.stringify(arg)}`);
568
+ return matcher(`${op} ${JSON.stringify(arg)}`, (v) => {
569
+ if (typeof arg === "number")
570
+ return (typeof v === "number" || (typeof v === "string" && DECIMAL.test(v))) && holds(Number(v) - arg);
571
+ return typeof v === "string" && holds(v < arg ? -1 : v > arg ? 1 : 0);
572
+ });
573
+ }
574
+ function equalsMatcher(expected, actual) {
575
+ return isAsymmetric(expected) ? expected.asymmetricMatch(actual) : subsetEquals(expected, actual);
576
+ }
577
+ /** `expected` contained in `actual`, with matchers anywhere: the rules of expected JSON. */
578
+ function subsetEquals(expected, actual) {
579
+ if (isAsymmetric(expected))
580
+ return expected.asymmetricMatch(actual);
581
+ if (Array.isArray(expected))
582
+ return Array.isArray(actual) && actual.length === expected.length && expected.every((e, i) => subsetEquals(e, actual[i]));
583
+ if (expected && typeof expected === "object") {
584
+ return !!actual && typeof actual === "object" && Object.entries(expected).every(([k, v]) => k in actual && subsetEquals(v, actual[k]));
585
+ }
586
+ return Object.is(expected, actual) || expected === actual;
587
+ }
588
+ function single(key, arg) {
589
+ switch (key) {
590
+ case "$type":
591
+ if (arg === "null")
592
+ return null;
593
+ if (arg === "integer")
594
+ return matcher("$type integer", (v) => Number.isInteger(v));
595
+ if (typeof arg === "string" && arg in TYPES)
596
+ return TYPES[arg];
597
+ throw new Error(`$type must be one of ${Object.keys(TYPES).join(", ")}, integer, null`);
598
+ case "$regex":
599
+ return expect.stringMatching(new RegExp(String(arg)));
600
+ case "$contains":
601
+ 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);
602
+ case "$any":
603
+ return expect.anything();
604
+ case "$gt":
605
+ return compare(key, arg, (c) => c > 0);
606
+ case "$gte":
607
+ return compare(key, arg, (c) => c >= 0);
608
+ case "$lt":
609
+ return compare(key, arg, (c) => c < 0);
610
+ case "$lte":
611
+ return compare(key, arg, (c) => c <= 0);
612
+ case "$closeTo": {
613
+ // `$closeTo: 9.99` allows ±0.005 (two decimals, like toBeCloseTo); `$closeTo: [9.99, 0.1]` sets the tolerance.
614
+ const [target, tolerance = 0.005] = Array.isArray(arg) ? arg : [arg];
615
+ if (typeof target !== "number" || typeof tolerance !== "number" || !(tolerance >= 0))
616
+ throw new Error(`$closeTo takes a number or [number, tolerance], got ${JSON.stringify(arg)}`);
617
+ return matcher(`$closeTo ${JSON.stringify(arg)}`, (v) => (typeof v === "number" || (typeof v === "string" && DECIMAL.test(v))) && Math.abs(Number(v) - target) <= tolerance + Number.EPSILON);
618
+ }
619
+ case "$len": {
620
+ const want = toMatchers(arg);
621
+ return matcher(`$len ${JSON.stringify(arg)}`, (v) => (typeof v === "string" || Array.isArray(v)) && equalsMatcher(want, v.length));
622
+ }
623
+ case "$not": {
624
+ const want = toMatchers(arg);
625
+ return matcher(`$not ${JSON.stringify(arg)}`, (v) => !equalsMatcher(want, v));
626
+ }
627
+ case "$oneOf": {
628
+ if (!Array.isArray(arg))
629
+ throw new Error(`$oneOf takes a list, got ${JSON.stringify(arg)}`);
630
+ const options = arg.map(toMatchers);
631
+ return matcher(`$oneOf ${JSON.stringify(arg)}`, (v) => options.some((o) => equalsMatcher(o, v)));
632
+ }
633
+ case "$format": {
634
+ const re = FORMATS[String(arg)];
635
+ if (!re)
636
+ throw new Error(`$format must be one of ${Object.keys(FORMATS).join(", ")}`);
637
+ return matcher(`$format ${arg}`, (v) => typeof v === "string" && re.test(v));
638
+ }
639
+ default:
640
+ throw new Error(`unknown matcher ${key} (expected one of ${MATCHERS.join(", ")})`);
641
+ }
642
+ }
408
643
  /**
409
- * `{ $type: number }`, `{ $regex: "^ch_" }`, `{ $contains: "x" }` and
410
- * `{ $any: true }` become Vitest asymmetric matchers.
644
+ * `{ $type: number }`, `{ $regex: "^ch_" }`, `{ $contains: "x" }`, `{ $any: true }`,
645
+ * `{ $gte: 1, $lt: 10 }`, `{ $len: 3 }`, `{ $not: … }`, `{ $oneOf: [...] }` and
646
+ * `{ $format: uuid }` become Vitest asymmetric matchers. Several `$` keys must all hold.
411
647
  */
412
648
  export function toMatchers(value) {
413
649
  if (Array.isArray(value))
@@ -415,25 +651,11 @@ export function toMatchers(value) {
415
651
  if (!value || typeof value !== "object" || isAsymmetric(value))
416
652
  return value;
417
653
  const keys = Object.keys(value);
418
- if (keys.length === 1 && keys[0].startsWith("$")) {
419
- const [key] = keys;
420
- const arg = value[key];
421
- switch (key) {
422
- case "$type":
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
- }
654
+ if (keys.length > 0 && keys.every((k) => k.startsWith("$"))) {
655
+ const parts = keys.map((k) => single(k, value[k]));
656
+ if (parts.length === 1)
657
+ return parts[0];
658
+ return matcher(keys.map((k) => `${k} ${JSON.stringify(value[k])}`).join(", "), (v) => parts.every((p) => equalsMatcher(p, v)));
437
659
  }
438
660
  return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, toMatchers(v)]));
439
661
  }
@@ -459,6 +681,35 @@ function verifyResponse(res, e, vars, base) {
459
681
  check(res.json, e.json, vars, "response JSON");
460
682
  if (e?.text !== undefined)
461
683
  check(res.text, e.text, vars, "response text");
684
+ if (e?.events !== undefined) {
685
+ if (!res.events)
686
+ throw new Error(`${res.method} ${res.url}: expected server-sent events, but the response is ${res.headers.get("content-type") ?? "without a content type"}, not text/event-stream`);
687
+ const wanted = toMatchers(interpolate(e.events, vars));
688
+ let at = 0;
689
+ for (const [i, want] of wanted.entries()) {
690
+ const found = res.events.findIndex((ev, j) => j >= at && subsetEquals(want, ev));
691
+ if (found < 0) {
692
+ const shown = res.events.slice(0, 20).map((ev, j) => ` ${j}: ${JSON.stringify(ev).slice(0, 200)}`).join("\n");
693
+ throw new Error(`${res.method} ${res.url}: expected event ${i + 1} of ${wanted.length}, ${JSON.stringify(e.events[i])}, ${at > 0 ? `after event ${at - 1}` : "in the stream"}; ${res.events.length} event(s) arrived:\n${shown || " (none)"}${res.events.length > 20 ? "\n …" : ""}`);
694
+ }
695
+ at = found + 1;
696
+ }
697
+ }
698
+ if (e?.cookies !== undefined) {
699
+ for (const [name, attrs] of Object.entries(e.cookies)) {
700
+ if (attrs === null)
701
+ expect(res).not.toSetCookie(name);
702
+ else
703
+ expect(res).toSetCookie(name, toMatchers(interpolate(attrs, vars)));
704
+ }
705
+ }
706
+ if (e?.duration !== undefined) {
707
+ const want = interpolate(e.duration, vars);
708
+ if (typeof want === "number")
709
+ expect(res).toRespondWithin(want);
710
+ else
711
+ check(res.durationMs, want, vars, `${res.method} ${res.url} response time (${res.durationMs}ms)`);
712
+ }
462
713
  if (e?.schema !== undefined) {
463
714
  const problems = schemaProblems(e.schema, res.json, base);
464
715
  if (problems.length)
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,10 +123,19 @@ 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?: {
120
- status?: number;
135
+ events?: Record<string, unknown>[];
136
+ cookies?: Record<string, Record<string, unknown> | null>;
137
+ duration?: number | Record<string, unknown>;
138
+ status?: number | string | (number | string)[];
121
139
  statuses?: Record<string, number>;
122
140
  headers?: Record<string, unknown>;
123
141
  json?: unknown;
@@ -136,11 +154,15 @@ export interface SubmitStep {
136
154
  /** The form by id, name or 0-based position, when the page has several. */
137
155
  form?: string | number;
138
156
  /** Values typed into the form by field name; true / false / a list check checkboxes and radios. */
139
- fields?: Record<string, string | number | boolean | (string | number)[]>;
157
+ /** `{ file: path }` (relative to the scenario) or `{ content, filename, type }` for a file input. */
158
+ fields?: Record<string, string | number | boolean | (string | number)[] | FilePart>;
140
159
  headers?: Record<string, string>;
141
160
  follow?: boolean;
142
161
  expect?: {
143
- status?: number;
162
+ events?: Record<string, unknown>[];
163
+ cookies?: Record<string, Record<string, unknown> | null>;
164
+ duration?: number | Record<string, unknown>;
165
+ status?: number | string | (number | string)[];
144
166
  headers?: Record<string, unknown>;
145
167
  json?: unknown;
146
168
  text?: unknown;
@@ -216,6 +238,8 @@ export type ChangeSpec = {
216
238
  export interface ChangesStep {
217
239
  changes: Record<string, ChangeSpec>;
218
240
  within?: number;
241
+ /** Columns or tables to leave out: `updated_at`, `orders.synced_at`, `sessions.*`. */
242
+ ignore?: string[];
219
243
  }
220
244
  /** Wait until the app (or `from:` a service) prints a line matching `log` (a regex) during the scenario. */
221
245
  export interface LogStep {
@@ -224,6 +248,29 @@ export interface LogStep {
224
248
  /** Milliseconds to wait. Default 5000. */
225
249
  within?: number;
226
250
  }
251
+ /**
252
+ * Calls to stubs happened in this order (others may come in between):
253
+ * `order: ["stripe POST /v1/charges", { stub: mail, call: POST /send, when: { json: { to: a@b.test } } }]`.
254
+ */
255
+ export interface OrderStep {
256
+ order: (string | {
257
+ stub: string;
258
+ call: string;
259
+ when?: Conditions;
260
+ })[];
261
+ within?: number;
262
+ }
263
+ /** A file to upload: read from `file` (relative to the scenario file), or `content` given inline. */
264
+ export interface FilePart {
265
+ file?: string;
266
+ content?: unknown;
267
+ filename?: string;
268
+ type?: string;
269
+ }
270
+ /** Define variables for later steps: `set: { orderId: "{{$uuid}}" }`. */
271
+ export interface SetStep {
272
+ set: Record<string, unknown>;
273
+ }
227
274
  /** Later `changes` steps only see what happens after this step. */
228
275
  export interface CheckpointStep {
229
276
  checkpoint: true;