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.
@@ -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,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
- : 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) => {
@@ -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" }` and
410
- * `{ $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.
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 === 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
- }
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
- fields?: Record<string, string | number | boolean | (string | number)[]>;
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;