slicetest 0.8.0 → 0.10.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 (63) hide show
  1. package/README.md +131 -35
  2. package/dist/app.js +10 -2
  3. package/dist/auth.js +2 -1
  4. package/dist/cli-run.d.ts +53 -0
  5. package/dist/cli-run.js +80 -0
  6. package/dist/cli.js +27 -29
  7. package/dist/config.d.ts +33 -2
  8. package/dist/config.js +121 -2
  9. package/dist/container-runtime.d.ts +4 -0
  10. package/dist/container-runtime.js +16 -0
  11. package/dist/containers.js +4 -3
  12. package/dist/db.d.ts +3 -1
  13. package/dist/db.js +45 -8
  14. package/dist/deadline.d.ts +7 -0
  15. package/dist/deadline.js +24 -0
  16. package/dist/doctor.js +27 -5
  17. package/dist/drivers/driver.d.ts +6 -0
  18. package/dist/drivers/mysql.js +5 -1
  19. package/dist/drivers/postgres.d.ts +2 -0
  20. package/dist/drivers/postgres.js +20 -1
  21. package/dist/exit.d.ts +1 -0
  22. package/dist/exit.js +28 -0
  23. package/dist/form.d.ts +2 -0
  24. package/dist/form.js +62 -9
  25. package/dist/global-setup.js +3 -6
  26. package/dist/har.js +4 -4
  27. package/dist/http.d.ts +38 -4
  28. package/dist/http.js +135 -10
  29. package/dist/init.js +64 -10
  30. package/dist/list.d.ts +1 -0
  31. package/dist/list.js +16 -3
  32. package/dist/mail.d.ts +1 -1
  33. package/dist/mail.js +7 -7
  34. package/dist/matchers.d.ts +18 -6
  35. package/dist/matchers.js +75 -8
  36. package/dist/openapi.d.ts +2 -5
  37. package/dist/openapi.js +146 -20
  38. package/dist/record-cli.js +2 -1
  39. package/dist/record.js +2 -1
  40. package/dist/recording.d.ts +11 -1
  41. package/dist/recording.js +51 -6
  42. package/dist/request-url.d.ts +1 -0
  43. package/dist/request-url.js +5 -0
  44. package/dist/runtime.d.ts +3 -3
  45. package/dist/runtime.js +144 -18
  46. package/dist/scenario.js +11 -2
  47. package/dist/sql-migrations.d.ts +11 -0
  48. package/dist/sql-migrations.js +38 -0
  49. package/dist/stub.d.ts +10 -3
  50. package/dist/stub.js +64 -16
  51. package/dist/vitest.d.ts +4 -0
  52. package/dist/vitest.js +6 -2
  53. package/dist/webhook.d.ts +3 -3
  54. package/dist/webhook.js +10 -2
  55. package/dist/yaml-runtime.d.ts +4 -4
  56. package/dist/yaml-runtime.js +141 -38
  57. package/dist/yaml.d.ts +12 -2
  58. package/dist/yaml.js +47 -4
  59. package/docs/guarantees.md +92 -0
  60. package/docs/state-isolation.md +94 -0
  61. package/package.json +4 -1
  62. package/schema/config.schema.json +675 -0
  63. package/schema/scenario.schema.json +97 -7
package/dist/webhook.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createHmac, randomUUID } from "node:crypto";
2
2
  import { encodeForm } from "./form.js";
3
3
  /** The providers `signWebhook` knows by name. */
4
- export const WEBHOOK_PROVIDERS = ["stripe", "github", "slack", "shopify", "standard", "line", "paddle", "linear", "gitlab", "zoom", "twitch", "twilio"];
4
+ export const WEBHOOK_PROVIDERS = ["stripe", "github", "slack", "shopify", "standard", "line", "paddle", "linear", "gitlab", "zoom", "twitch", "twilio", "typeform", "notion", "bitbucket", "intercom"];
5
5
  const hmac = (algorithm, key, data) => createHmac(algorithm, key).update(data);
6
6
  /**
7
7
  * The headers a provider sends with `body`, signed with `secret` the way its
@@ -9,7 +9,7 @@ const hmac = (algorithm, key, data) => createHmac(algorithm, key).update(data);
9
9
  * `X-Hub-Signature-256`, Slack's `v0` signature, Shopify's base64 HMAC,
10
10
  * Standard Webhooks (Svix, Resend, Clerk, …), LINE's `X-Line-Signature`,
11
11
  * Paddle Billing's `Paddle-Signature`, Linear, GitLab's token, Zoom's `v0`
12
- * signature and Twitch EventSub.
12
+ * signature, Twitch EventSub, Typeform, Notion, Bitbucket and Intercom.
13
13
  */
14
14
  export function signWebhook(body, opts) {
15
15
  const now = Math.floor(Date.now() / 1000);
@@ -67,6 +67,14 @@ export function signWebhook(body, opts) {
67
67
  "twitch-eventsub-message-signature": `sha256=${hmac("sha256", secret, `${id}${at}${body}`).digest("hex")}`,
68
68
  };
69
69
  }
70
+ case "typeform":
71
+ return { "typeform-signature": `sha256=${hmac("sha256", secret, body).digest("base64")}` };
72
+ case "notion":
73
+ return { "x-notion-signature": `sha256=${hmac("sha256", secret, body).digest("hex")}` };
74
+ case "bitbucket":
75
+ return { "x-hub-signature": `sha256=${hmac("sha256", secret, body).digest("hex")}` };
76
+ case "intercom":
77
+ return { "x-hub-signature": `sha1=${hmac("sha1", secret, body).digest("hex")}` };
70
78
  case "twilio": {
71
79
  // Twilio's validateRequest: the URL, then each POST parameter's name and value, sorted by name; HMAC-SHA1, base64.
72
80
  if (!opts.url)
@@ -1,11 +1,12 @@
1
1
  import "./provided.js";
2
2
  import "./matchers.js";
3
- import type { YamlFile } from "./yaml.js";
3
+ import { lookup, type YamlFile } from "./yaml.js";
4
+ export { lookup };
4
5
  type Vars = Record<string, unknown>;
5
6
  /** Registers the scenarios of one parsed YAML file. Called from the module the plugin generates for it. */
6
7
  export declare function defineYamlScenarios(doc: YamlFile): void;
7
- /** `json.items[0].id` or `json.items.0.id` */
8
- export declare function lookup(obj: unknown, path: string): unknown;
8
+ /** `expected` (with matchers) contained in `actual`; keys set to `{ $absent: true }` must not be there. */
9
+ export declare function checkSubset(actual: unknown, expected: unknown, what: string): void;
9
10
  /**
10
11
  * Replace `{{name}}` in every string. A string that is only `{{name}}` takes
11
12
  * the variable's own type, so `id: "{{pollId}}"` stays a number.
@@ -17,4 +18,3 @@ export declare function interpolate(value: unknown, vars: Vars): unknown;
17
18
  * `{ $format: uuid }` become Vitest asymmetric matchers. Several `$` keys must all hold.
18
19
  */
19
20
  export declare function toMatchers(value: unknown): unknown;
20
- export {};
@@ -1,3 +1,4 @@
1
+ import { waitBudget } from "./deadline.js";
1
2
  import { expect, inject } from "vitest";
2
3
  import { recordYamlFailure } from "./ci.js";
3
4
  import "./provided.js";
@@ -8,16 +9,19 @@ import { signWebhook, webhookBody } from "./webhook.js";
8
9
  import { readFile } from "node:fs/promises";
9
10
  import { parse as parseYaml } from "yaml";
10
11
  import path from "node:path";
12
+ import { isIPv4, isIPv6 } from "node:net";
11
13
  import { graphqlErrors } from "./graphql.js";
12
14
  import { schemaProblems } from "./schema.js";
13
15
  import { sse } from "./stub.js";
16
+ import { lookup, scenarioTitle } from "./yaml.js";
17
+ export { lookup };
14
18
  /** Registers the scenarios of one parsed YAML file. Called from the module the plugin generates for it. */
15
19
  export function defineYamlScenarios(doc) {
16
20
  for (const sc of doc.scenarios) {
17
21
  const register = sc.only ? scenario.only : sc.skip ? scenario.skip : scenario;
18
22
  const rows = sc.each ?? [undefined];
19
23
  rows.forEach((row, i) => {
20
- const title = row ? interpolateTitle(sc.name, row, i) : sc.name;
24
+ const title = row ? scenarioTitle(sc.name, row, i) : sc.name;
21
25
  register(title, (ctx) => {
22
26
  seq = 0;
23
27
  return runSteps(doc, sc, [...doc.setup, ...sc.steps], ctx, { ...row });
@@ -95,9 +99,10 @@ function describeStep(step) {
95
99
  return "sql";
96
100
  }
97
101
  /** Run `fn` until it passes or `within` ms have passed, then rethrow its last error. */
98
- async function retry(within, fn, every = 50) {
99
- if (!within)
102
+ async function retry(wanted, fn, every = 50) {
103
+ if (!wanted)
100
104
  return fn();
105
+ const { ms: within, note } = waitBudget(wanted);
101
106
  const deadline = Date.now() + within;
102
107
  for (;;) {
103
108
  try {
@@ -106,7 +111,7 @@ async function retry(within, fn, every = 50) {
106
111
  catch (e) {
107
112
  if (Date.now() >= deadline) {
108
113
  if (e instanceof Error)
109
- e.message = `${e.message}\n(still failing after retrying for ${within}ms)`;
114
+ e.message = `${e.message}\n(still failing after retrying for ${within}ms${note})`;
110
115
  throw e;
111
116
  }
112
117
  await new Promise((r) => setTimeout(r, every));
@@ -145,8 +150,12 @@ async function runStep(step, ctx, vars, base) {
145
150
  }
146
151
  if ("mail" in step) {
147
152
  const filter = Object.fromEntries(Object.entries(interpolate(step.mail, vars)).map(([k, v]) => {
148
- const re = v && typeof v === "object" && "$regex" in v ? new RegExp(String(v.$regex)) : undefined;
149
- return [k, re ?? String(v)];
153
+ if (!v || typeof v !== "object")
154
+ return [k, String(v)];
155
+ const { $regex, $options, ...rest } = v;
156
+ if ($regex === undefined || Object.keys(rest).length)
157
+ throw new Error(`mail ${k}: takes a string or { $regex, $options }, got ${JSON.stringify(v)}`);
158
+ return [k, regexOf($regex, $options)];
150
159
  }));
151
160
  const box = ctx.mail;
152
161
  await retry(step.within ?? 5000, async () => {
@@ -235,7 +244,7 @@ async function runStep(step, ctx, vars, base) {
235
244
  follow: step.follow,
236
245
  });
237
246
  verifyResponse(res, step.expect, vars, base);
238
- capture(step.capture, { status: res.status, json: res.json, text: res.text, headers: Object.fromEntries(res.headers) }, vars);
247
+ capture(step.capture, { status: res.status, json: res.json, text: res.text, headers: Object.fromEntries(res.headers), events: res.events }, vars);
239
248
  return;
240
249
  }
241
250
  if ("request" in step) {
@@ -264,7 +273,9 @@ async function runStep(step, ctx, vars, base) {
264
273
  body = signed.body;
265
274
  const url = new URL(path, ctx.http.baseUrl);
266
275
  for (const [k, v] of Object.entries(opts.query ?? {}))
267
- url.searchParams.set(k, String(v));
276
+ for (const item of [v].flat())
277
+ if (item !== null && item !== undefined)
278
+ url.searchParams.append(k, String(item));
268
279
  opts.headers = { "content-type": signed.type, ...signWebhook(signed.body, { url: url.href, ...hook }), ...opts.headers };
269
280
  }
270
281
  const e = step.expect;
@@ -296,7 +307,7 @@ async function runStep(step, ctx, vars, base) {
296
307
  }
297
308
  }
298
309
  verify(res);
299
- capture(step.capture, { status: res.status, json: res.json, text: res.text, headers: Object.fromEntries(res.headers) }, vars);
310
+ capture(step.capture, { status: res.status, json: res.json, text: res.text, headers: Object.fromEntries(res.headers), events: res.events }, vars);
300
311
  return;
301
312
  }
302
313
  if ("insert" in step) {
@@ -356,11 +367,34 @@ function expectRows(e, rows, vars, table) {
356
367
  }
357
368
  /** Subset comparison with Vitest's diff: objects may have extra keys, arrays must have the same length. */
358
369
  function check(actual, expected, vars, what) {
359
- const want = toMatchers(interpolate(expected, vars));
370
+ checkSubset(actual, interpolate(expected, vars), what);
371
+ }
372
+ /** `expected` (with matchers) contained in `actual`; keys set to `{ $absent: true }` must not be there. */
373
+ export function checkSubset(actual, expected, what) {
374
+ const absent = [];
375
+ const want = withoutAbsent(toMatchers(expected), [], absent);
360
376
  if (want && typeof want === "object" && !isAsymmetric(want))
361
377
  expect(actual, what).toMatchObject(want);
362
378
  else
363
379
  expect(actual, what).toEqual(want);
380
+ for (const p of absent) {
381
+ const v = lookup(actual, p);
382
+ if (v !== undefined)
383
+ throw new Error(`${what}: expected no ${p}, got ${JSON.stringify(v)}`);
384
+ }
385
+ }
386
+ /** `toMatchObject` never calls a matcher for a key that is missing, so `$absent` keys are taken out and checked apart. */
387
+ function withoutAbsent(v, at, out) {
388
+ if (Array.isArray(v))
389
+ return v.map((x, i) => withoutAbsent(x, [...at, String(i)], out));
390
+ if (!v || typeof v !== "object" || isAsymmetric(v))
391
+ return v;
392
+ return Object.fromEntries(Object.entries(v).flatMap(([k, x]) => {
393
+ if (x !== ABSENT)
394
+ return [[k, withoutAbsent(x, [...at, k], out)]];
395
+ out.push([...at, k].join("."));
396
+ return [];
397
+ }));
364
398
  }
365
399
  function conditions(when, vars) {
366
400
  if (!when)
@@ -464,14 +498,6 @@ function splitCall(s) {
464
498
  const i = s.search(/\s/);
465
499
  return [s.slice(0, i).toUpperCase(), s.slice(i).trim()];
466
500
  }
467
- /** `json.items[0].id` or `json.items.0.id` */
468
- export function lookup(obj, path) {
469
- return path
470
- .replace(/\[(\d+)\]/g, ".$1")
471
- .split(".")
472
- .filter(Boolean)
473
- .reduce((cur, key) => (cur == null ? undefined : cur[key]), obj);
474
- }
475
501
  const WHOLE = /^\{\{\s*([^{}]+?)\s*\}\}$/;
476
502
  const PART = /\{\{\s*([^{}]+?)\s*\}\}/g;
477
503
  /**
@@ -548,21 +574,46 @@ const FORMATS = {
548
574
  email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
549
575
  date: /^\d{4}-\d{2}-\d{2}$/,
550
576
  "date-time": /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/i,
577
+ time: /^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/i,
551
578
  uri: /^[a-z][a-z0-9+.-]*:\/\/\S+$/i,
552
579
  integer: /^-?\d+$/,
580
+ ipv4: (v) => isIPv4(v),
581
+ ipv6: (v) => isIPv6(v),
582
+ hostname: /^(?=.{1,253}$)[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/i,
583
+ // Crockford's base32: no I, L, O or U.
584
+ ulid: /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/i,
585
+ // Three base64url parts whose first is a JSON header with `alg`; the signature isn't checked.
586
+ jwt: (v) => {
587
+ const parts = v.split(".");
588
+ if (parts.length !== 3 || !parts.slice(0, 2).every((p) => /^[A-Za-z0-9_-]+$/.test(p)) || !/^[A-Za-z0-9_-]*$/.test(parts[2]))
589
+ return false;
590
+ try {
591
+ return typeof JSON.parse(Buffer.from(parts[0], "base64url").toString()).alg === "string";
592
+ }
593
+ catch {
594
+ return false;
595
+ }
596
+ },
553
597
  };
554
- const MATCHERS = ["$type", "$regex", "$contains", "$any", "$gt", "$gte", "$lt", "$lte", "$len", "$not", "$oneOf", "$format"];
598
+ /** `{ $absent: true }`: the key isn't there (a value of `null` is there). */
599
+ const ABSENT = matcher("$absent", (v) => v === undefined);
600
+ const MATCHERS = ["$absent", "$type", "$regex", "$options", "$contains", "$any", "$gt", "$gte", "$lt", "$lte", "$closeTo", "$len", "$not", "$oneOf", "$format"];
555
601
  /** An asymmetric matcher Vitest's `toEqual` / `toMatchObject` call, with a readable name in diffs. */
556
602
  function matcher(name, test) {
557
603
  return { asymmetricMatch: test, toAsymmetricMatcher: () => name, toString: () => name };
558
604
  }
559
- /** Numbers compare as numbers; strings (ISO dates, versions) compare as strings; anything else never matches. */
605
+ /** A decimal as Postgres `numeric` and MySQL `DECIMAL` columns come back, to keep their precision: `"12.50"`. */
606
+ const DECIMAL = /^-?\d+(\.\d+)?$/;
607
+ /**
608
+ * Numbers compare as numbers, also with decimal strings from `numeric` / `DECIMAL` columns; strings
609
+ * (ISO dates, versions) compare as strings; anything else never matches.
610
+ */
560
611
  function compare(op, arg, holds) {
561
612
  if (typeof arg !== "number" && typeof arg !== "string")
562
613
  throw new Error(`${op} takes a number or a string, got ${JSON.stringify(arg)}`);
563
614
  return matcher(`${op} ${JSON.stringify(arg)}`, (v) => {
564
615
  if (typeof arg === "number")
565
- return typeof v === "number" && holds(v - arg);
616
+ return (typeof v === "number" || (typeof v === "string" && DECIMAL.test(v))) && holds(Number(v) - arg);
566
617
  return typeof v === "string" && holds(v < arg ? -1 : v > arg ? 1 : 0);
567
618
  });
568
619
  }
@@ -576,11 +627,20 @@ function subsetEquals(expected, actual) {
576
627
  if (Array.isArray(expected))
577
628
  return Array.isArray(actual) && actual.length === expected.length && expected.every((e, i) => subsetEquals(e, actual[i]));
578
629
  if (expected && typeof expected === "object") {
579
- return !!actual && typeof actual === "object" && Object.entries(expected).every(([k, v]) => k in actual && subsetEquals(v, actual[k]));
630
+ return !!actual && typeof actual === "object" && Object.entries(expected).every(([k, v]) => (v === ABSENT ? actual[k] === undefined : k in actual && subsetEquals(v, actual[k])));
580
631
  }
581
632
  return Object.is(expected, actual) || expected === actual;
582
633
  }
583
- function single(key, arg) {
634
+ function regexOf(pattern, flags) {
635
+ try {
636
+ return new RegExp(String(pattern), flags === undefined ? undefined : String(flags));
637
+ }
638
+ catch (e) {
639
+ const bad = flags !== undefined && /flags/i.test(e.message);
640
+ throw new Error(bad ? `$options ${JSON.stringify(flags)} has a flag JavaScript doesn't know (use i, m, s, u, g...)` : `$regex ${JSON.stringify(pattern)} isn't a valid regular expression: ${e.message}`);
641
+ }
642
+ }
643
+ function single(key, arg, options) {
584
644
  switch (key) {
585
645
  case "$type":
586
646
  if (arg === "null")
@@ -591,11 +651,15 @@ function single(key, arg) {
591
651
  return TYPES[arg];
592
652
  throw new Error(`$type must be one of ${Object.keys(TYPES).join(", ")}, integer, null`);
593
653
  case "$regex":
594
- return expect.stringMatching(new RegExp(String(arg)));
654
+ return expect.stringMatching(regexOf(arg, options));
595
655
  case "$contains":
596
656
  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
657
  case "$any":
598
658
  return expect.anything();
659
+ case "$absent":
660
+ if (arg !== true)
661
+ throw new Error(`$absent takes true, got ${JSON.stringify(arg)}`);
662
+ return ABSENT;
599
663
  case "$gt":
600
664
  return compare(key, arg, (c) => c > 0);
601
665
  case "$gte":
@@ -604,6 +668,13 @@ function single(key, arg) {
604
668
  return compare(key, arg, (c) => c < 0);
605
669
  case "$lte":
606
670
  return compare(key, arg, (c) => c <= 0);
671
+ case "$closeTo": {
672
+ // `$closeTo: 9.99` allows ±0.005 (two decimals, like toBeCloseTo); `$closeTo: [9.99, 0.1]` sets the tolerance.
673
+ const [target, tolerance = 0.005] = Array.isArray(arg) ? arg : [arg];
674
+ if (typeof target !== "number" || typeof tolerance !== "number" || !(tolerance >= 0))
675
+ throw new Error(`$closeTo takes a number or [number, tolerance], got ${JSON.stringify(arg)}`);
676
+ return matcher(`$closeTo ${JSON.stringify(arg)}`, (v) => (typeof v === "number" || (typeof v === "string" && DECIMAL.test(v))) && Math.abs(Number(v) - target) <= tolerance + Number.EPSILON);
677
+ }
607
678
  case "$len": {
608
679
  const want = toMatchers(arg);
609
680
  return matcher(`$len ${JSON.stringify(arg)}`, (v) => (typeof v === "string" || Array.isArray(v)) && equalsMatcher(want, v.length));
@@ -619,10 +690,10 @@ function single(key, arg) {
619
690
  return matcher(`$oneOf ${JSON.stringify(arg)}`, (v) => options.some((o) => equalsMatcher(o, v)));
620
691
  }
621
692
  case "$format": {
622
- const re = FORMATS[String(arg)];
623
- if (!re)
693
+ const f = Object.hasOwn(FORMATS, String(arg)) ? FORMATS[String(arg)] : undefined;
694
+ if (!f)
624
695
  throw new Error(`$format must be one of ${Object.keys(FORMATS).join(", ")}`);
625
- return matcher(`$format ${arg}`, (v) => typeof v === "string" && re.test(v));
696
+ return matcher(`$format ${arg}`, (v) => typeof v === "string" && (typeof f === "function" ? f(v) : f.test(v)));
626
697
  }
627
698
  default:
628
699
  throw new Error(`unknown matcher ${key} (expected one of ${MATCHERS.join(", ")})`);
@@ -640,7 +711,12 @@ export function toMatchers(value) {
640
711
  return value;
641
712
  const keys = Object.keys(value);
642
713
  if (keys.length > 0 && keys.every((k) => k.startsWith("$"))) {
643
- const parts = keys.map((k) => single(k, value[k]));
714
+ const given = value;
715
+ // `$options` is the flags of the `$regex` next to it, not a matcher of its own.
716
+ if ("$options" in given && !("$regex" in given))
717
+ throw new Error("$options belongs with $regex, e.g. { $regex: \"^ab\", $options: \"i\" }");
718
+ const own = keys.filter((k) => k !== "$options");
719
+ const parts = own.map((k) => single(k, given[k], given.$options));
644
720
  if (parts.length === 1)
645
721
  return parts[0];
646
722
  return matcher(keys.map((k) => `${k} ${JSON.stringify(value[k])}`).join(", "), (v) => parts.every((p) => equalsMatcher(p, v)));
@@ -650,25 +726,52 @@ export function toMatchers(value) {
650
726
  function isAsymmetric(v) {
651
727
  return !!v && typeof v === "object" && typeof v.asymmetricMatch === "function";
652
728
  }
653
- function interpolateTitle(name, row, index) {
654
- return name.replace(PART, (m, key) => {
655
- if (key.trim() === "#")
656
- return String(index);
657
- const v = lookup(row, key);
658
- return v === undefined ? m : typeof v === "object" ? JSON.stringify(v) : String(v);
659
- });
660
- }
661
729
  function verifyResponse(res, e, vars, base) {
662
730
  if (e?.status !== undefined)
663
731
  expect(res).toHaveStatus(interpolate(e.status, vars));
664
732
  if (e?.headers !== undefined) {
665
- const expected = Object.fromEntries(Object.entries(e.headers).map(([k, v]) => [k.toLowerCase(), v]));
666
- check(Object.fromEntries(res.headers), expected, vars, "response headers");
733
+ // `null`: the response must not send that header (`x-powered-by: null`), as in `cookies`.
734
+ const entries = Object.entries(e.headers).map(([k, v]) => [k.toLowerCase(), v]);
735
+ for (const [name] of entries.filter(([, v]) => v === null)) {
736
+ const sent = res.headers.get(name);
737
+ if (sent !== null)
738
+ throw new Error(`${res.method} ${res.url}: expected no ${name} header, got ${JSON.stringify(sent)}`);
739
+ }
740
+ check(Object.fromEntries(res.headers), Object.fromEntries(entries.filter(([, v]) => v !== null)), vars, "response headers");
667
741
  }
668
742
  if (e?.json !== undefined)
669
743
  check(res.json, e.json, vars, "response JSON");
670
744
  if (e?.text !== undefined)
671
745
  check(res.text, e.text, vars, "response text");
746
+ if (e?.events !== undefined) {
747
+ if (!res.events)
748
+ 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`);
749
+ const wanted = toMatchers(interpolate(e.events, vars));
750
+ let at = 0;
751
+ for (const [i, want] of wanted.entries()) {
752
+ const found = res.events.findIndex((ev, j) => j >= at && subsetEquals(want, ev));
753
+ if (found < 0) {
754
+ const shown = res.events.slice(0, 20).map((ev, j) => ` ${j}: ${JSON.stringify(ev).slice(0, 200)}`).join("\n");
755
+ 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 …" : ""}`);
756
+ }
757
+ at = found + 1;
758
+ }
759
+ }
760
+ if (e?.cookies !== undefined) {
761
+ for (const [name, attrs] of Object.entries(e.cookies)) {
762
+ if (attrs === null)
763
+ expect(res).not.toSetCookie(name);
764
+ else
765
+ expect(res).toSetCookie(name, toMatchers(interpolate(attrs, vars)));
766
+ }
767
+ }
768
+ if (e?.duration !== undefined) {
769
+ const want = interpolate(e.duration, vars);
770
+ if (typeof want === "number")
771
+ expect(res).toRespondWithin(want);
772
+ else
773
+ check(res.durationMs, want, vars, `${res.method} ${res.url} response time (${res.durationMs}ms)`);
774
+ }
672
775
  if (e?.schema !== undefined) {
673
776
  const problems = schemaProblems(e.schema, res.json, base);
674
777
  if (problems.length)
package/dist/yaml.d.ts CHANGED
@@ -4,6 +4,10 @@
4
4
  * and validates a file (in the Vitest main process, where line numbers are
5
5
  * known); yaml-runtime.ts runs the result inside a worker.
6
6
  */
7
+ /** `json.items[0].id` or `json.items.0.id` */
8
+ export declare function lookup(obj: unknown, path: string): unknown;
9
+ /** The title a row of `each` runs under: `{{field}}` from the row, `{{#}}` its index. */
10
+ export declare function scenarioTitle(name: string, row: Record<string, unknown>, index: number): string;
7
11
  export interface YamlFile {
8
12
  file: string;
9
13
  /** Absolute path, for CI annotations. */
@@ -132,7 +136,10 @@ export interface RequestStep {
132
136
  /** `queries`: at most this many SQL statements (not counting BEGIN/COMMIT), with `db.queries` on. */
133
137
  /** `schema`: a JSON Schema for the response JSON, inline or `file#/pointer` relative to the scenario file. */
134
138
  expect?: {
135
- status?: number;
139
+ events?: Record<string, unknown>[];
140
+ cookies?: Record<string, Record<string, unknown> | null>;
141
+ duration?: number | Record<string, unknown>;
142
+ status?: number | string | (number | string)[];
136
143
  statuses?: Record<string, number>;
137
144
  headers?: Record<string, unknown>;
138
145
  json?: unknown;
@@ -156,7 +163,10 @@ export interface SubmitStep {
156
163
  headers?: Record<string, string>;
157
164
  follow?: boolean;
158
165
  expect?: {
159
- status?: number;
166
+ events?: Record<string, unknown>[];
167
+ cookies?: Record<string, Record<string, unknown> | null>;
168
+ duration?: number | Record<string, unknown>;
169
+ status?: number | string | (number | string)[];
160
170
  headers?: Record<string, unknown>;
161
171
  json?: unknown;
162
172
  text?: unknown;
package/dist/yaml.js CHANGED
@@ -1,5 +1,28 @@
1
1
  import { isMap, isSeq, LineCounter, parseDocument } from "yaml";
2
2
  import { WEBHOOK_PROVIDERS as PROVIDERS } from "./webhook.js";
3
+ /**
4
+ * YAML scenarios: the same things a TypeScript scenario can do, written as
5
+ * data so teams that don't use JavaScript can write them. This module parses
6
+ * and validates a file (in the Vitest main process, where line numbers are
7
+ * known); yaml-runtime.ts runs the result inside a worker.
8
+ */
9
+ /** `json.items[0].id` or `json.items.0.id` */
10
+ export function lookup(obj, path) {
11
+ return path
12
+ .replace(/\[(\d+)\]/g, ".$1")
13
+ .split(".")
14
+ .filter(Boolean)
15
+ .reduce((cur, key) => (cur == null ? undefined : cur[key]), obj);
16
+ }
17
+ /** The title a row of `each` runs under: `{{field}}` from the row, `{{#}}` its index. */
18
+ export function scenarioTitle(name, row, index) {
19
+ return name.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, (m, key) => {
20
+ if (key.trim() === "#")
21
+ return String(index);
22
+ const v = lookup(row, key);
23
+ return v === undefined ? m : typeof v === "object" ? JSON.stringify(v) : String(v);
24
+ });
25
+ }
3
26
  const KINDS = {
4
27
  stub: ["on", "graphql", "when", "reply", "sequence", "networkError", "times", "delay", "optional"],
5
28
  request: ["headers", "query", "json", "form", "multipart", "body", "graphql", "follow", "auth", "webhook", "concurrency", "expect", "capture", "within", "every", "timeout"],
@@ -20,8 +43,8 @@ const KINDS = {
20
43
  use: ["with"],
21
44
  };
22
45
  const EXPECT_KEYS = {
23
- request: ["status", "statuses", "headers", "json", "text", "queries", "schema"],
24
- submit: ["status", "headers", "json", "text", "schema"],
46
+ request: ["status", "statuses", "headers", "json", "text", "queries", "schema", "duration", "cookies", "events"],
47
+ submit: ["status", "headers", "json", "text", "schema", "duration", "cookies", "events"],
25
48
  sql: ["rows", "count"],
26
49
  db: ["rows", "count"],
27
50
  };
@@ -275,6 +298,18 @@ function parseStep(node, fail, lineOf) {
275
298
  };
276
299
  if (raw.within !== undefined && (typeof raw.within !== "number" || raw.within <= 0))
277
300
  fail(at("within"), "`within` must be a positive number of milliseconds");
301
+ const events = raw.expect?.events;
302
+ if (events !== undefined && !(Array.isArray(events) && events.every((e) => e && typeof e === "object" && !Array.isArray(e)))) {
303
+ fail(at("expect"), "`expect.events` is a list of events, each a subset of { event, data, id }: [{ event: message_stop }]");
304
+ }
305
+ const cookies = raw.expect?.cookies;
306
+ if (cookies !== undefined && (!cookies || typeof cookies !== "object" || Array.isArray(cookies) || Object.values(cookies).some((c) => c !== null && (typeof c !== "object" || Array.isArray(c))))) {
307
+ fail(at("expect"), "`expect.cookies` maps cookie names to attributes ({ sid: { httpOnly: true } }), {} for any value, or null for a cookie that must not be set");
308
+ }
309
+ const duration = raw.expect?.duration;
310
+ if (duration !== undefined && !((typeof duration === "number" && duration > 0) || (duration && typeof duration === "object" && !Array.isArray(duration)))) {
311
+ fail(at("expect"), "`expect.duration` is a number of milliseconds (at most), or a matcher such as { $lt: 200 }");
312
+ }
278
313
  const number = (key) => {
279
314
  if (raw[key] !== undefined && (typeof raw[key] !== "number" || raw[key] < 0))
280
315
  fail(at(key), `\`${key}\` must be a non-negative number`);
@@ -404,6 +439,14 @@ function parseStep(node, fail, lineOf) {
404
439
  if (q !== undefined && raw.concurrency !== undefined)
405
440
  fail(at("expect"), "`expect.queries` can't be used with `concurrency`");
406
441
  }
442
+ {
443
+ const status = raw.expect?.status;
444
+ // A template (`{{status}}` from `each`) is only known when the step runs.
445
+ const ok = (s) => (typeof s === "number" && Number.isInteger(s)) || (typeof s === "string" && (s.includes("{{") || /^(\d{3}|[1-5]xx)$/.test(s)));
446
+ if (status !== undefined && !(Array.isArray(status) ? status.length > 0 && status.every(ok) : ok(status))) {
447
+ fail(at("expect"), `\`expect.status\` is a code (201), a class (2xx) or a list of them, got ${JSON.stringify(status)}`);
448
+ }
449
+ }
407
450
  if (raw.concurrency !== undefined && raw.capture !== undefined)
408
451
  fail(at("capture"), "`capture` can't be used with `concurrency`: there is more than one response");
409
452
  {
@@ -411,8 +454,8 @@ function parseStep(node, fail, lineOf) {
411
454
  if (statuses !== undefined) {
412
455
  if (raw.concurrency === undefined)
413
456
  fail(at("expect"), "`expect.statuses` needs `concurrency`");
414
- if (!statuses || typeof statuses !== "object" || Object.entries(statuses).some(([k, v]) => !/^\d{3}$/.test(k) || !Number.isInteger(v))) {
415
- fail(at("expect"), "`expect.statuses` maps status codes to counts, e.g. { 201: 1, 409: 9 }");
457
+ if (!statuses || typeof statuses !== "object" || Object.entries(statuses).some(([k, v]) => !/^(\d{3}|[1-5]xx)$/i.test(k) || !Number.isInteger(v))) {
458
+ fail(at("expect"), "`expect.statuses` maps status codes or classes to counts, e.g. { 201: 1, 409: 9 } or { 2xx: 1, 4xx: 9 }");
416
459
  }
417
460
  }
418
461
  }
@@ -0,0 +1,92 @@
1
+ # What slicetest's checks do and don't guarantee
2
+
3
+ Each check below catches a defined kind of mistake. None of them proves the app is correct. For every one: what it catches, an example, and what it can't catch, with an example. This is read from the implementation (`src/openapi.ts`, `src/recording.ts`, `src/db.ts`, `src/trace.ts`) and its tests, not measured on real projects.
4
+
5
+ ## OpenAPI: your app's responses
6
+
7
+ With `openapi: openapi.yaml`, every response the app gives to `http` during a scenario is checked against the spec.
8
+
9
+ | Catches | Can't catch |
10
+ |---|---|
11
+ | A status the spec doesn't document for that operation (`GET /polls/{id} responded 500, which openapi.yaml doesn't document`). | A response with the right shape and the wrong value: `{ "total": 100 }` where it should be `120` passes if `total` is a number. |
12
+ | A JSON body that breaks the schema: wrong type, missing required property, enum mismatch (`/id must be integer`). | Extra fields, unless the schema says `additionalProperties: false`: JSON Schema allows them by default. |
13
+ | A content type the response doesn't list. | Response headers. Only the body and its content type are checked. |
14
+ | A path or method that isn't in the spec at all. | A body that isn't JSON (HTML, CSV, files): only its content type is compared. A response without a content type isn't held to a body schema. |
15
+ | | A spec that is wrong or incomplete. The check compares the app with the spec; if both are wrong the same way, it passes. A spec the app generates for itself (`openapi.fromApp`) often misses error responses and nullable fields, which shows up as failures, but it can also describe a bug as the contract. |
16
+
17
+ ## OpenAPI: the services you stub
18
+
19
+ With `stubs: [{ name, openapi }]`, the app's calls to the stub and the stub's replies are checked against the provider's spec. What this gives is narrower than a stub that can't lie: **a reply you wrote can't contradict what the spec documents**.
20
+
21
+ | Catches | Can't catch |
22
+ |---|---|
23
+ | A stub reply with a status or body the spec doesn't document (`200 { ok: true }` where the spec documents `202 { messageId }`). | The real provider behaving differently from its published spec: undocumented errors, outdated spec, fields it stopped sending. |
24
+ | A required query parameter the app didn't send; a missing required body; a body that breaks the request schema; a wrong request content type. | Required request headers (authorization), path and cookie parameters: only query parameters and the body are checked. |
25
+ | An operation that isn't in the spec (the call fails the scenario). | Whether the values make sense to the provider: a well-formed `customer: "cus_nope"` that doesn't exist, a currency it doesn't support, rate limits, idempotency. |
26
+ | | A stub reply without a content type: its body isn't compared. |
27
+
28
+ `autoReply` answers calls from the spec's examples or values built from its schemas. Those are valid per the spec, not necessarily what your code needs: register routes for the replies a scenario depends on.
29
+
30
+ ## Recording and replay
31
+
32
+ A recorded stub replays what the real service answered **at the time of recording**, for requests identical in method, path, query and body. Credential-named values (`api_key`, `password`, …) are redacted before the comparison, so requests that differ only in those match.
33
+
34
+ | Catches | Can't catch |
35
+ |---|---|
36
+ | The app's request changed: no recording matches, the call gets a 501 (unless `autoReply` answers from the spec instead) and the scenario fails with a hint to record again. | The real service changing after the recording: a renamed field, a new error, a removed endpoint. Replays are offline, so nothing notices. |
37
+ | Replies that contradict the provider's spec, when the stub also has one. | Recordings that were never right: recorded with a wrong account, a sandbox that behaves unlike production. |
38
+ | | Secrets stored in places the redaction doesn't look at. Request headers are never stored and well-known credential names in the query or body become `[redacted]`; everything else is stored as sent. Review the file before committing it. |
39
+
40
+ To keep recordings honest, re-record on a schedule (delete the entries, run once with `SLICETEST_RECORD`) and review the diff, or check the real service with a separate contract test. slicetest doesn't do either on its own.
41
+
42
+ ## `trace()` and snapshots
43
+
44
+ `expect(await trace()).toMatchSnapshot()` pins the requests to the app and their responses, the calls to stubs with the replies' statuses, the mail, and the database changes, with dates and UUIDs masked (the exact fields are below).
45
+
46
+ | Catches | Can't catch |
47
+ |---|---|
48
+ | A change in what the trace holds, as a diff in review: a new outbound call, a different status or response body, an extra row, a column written differently. The trace has, per request to the app: method, path and query, status and response body; per stub call: method, path and query, request body and the reply's status; the mail; the database changes. | Whether the pinned behavior is right. A snapshot records what the app does today, bugs included; the first run and every `-u` accept whatever it shows. |
49
+ | | Anything not in the trace: request bodies and headers sent to the app, response headers (a cookie attribute), reply bodies and headers of a stub, logs, files, other stores. Only the last 20 requests to the app are kept. Masked values (dates, UUIDs) aren't checked. |
50
+
51
+ A snapshot update is a change to what the tests expect, so review it like a change to an assertion. The same goes for scenarios produced by `slicetest gen` and `slicetest record`: they start from the spec or from what the app did, so a misunderstanding in either is copied into the expected values.
52
+
53
+ ## `db.changes()`
54
+
55
+ A diff of the database between the start of the scenario (after reset and seed, or the last `checkpoint()`) and now, matched by primary key.
56
+
57
+ | Catches | Can't catch |
58
+ |---|---|
59
+ | A write to a table the test didn't list, when asserted with `toEqual`; which columns an update changed. | A row written and deleted again inside the scenario: it's a net diff, only the end state counts. |
60
+ | An insert that should have happened and didn't. | Tables outside `db.schemas` (default `public`), tables in `db.keep`, migration bookkeeping tables, and columns in `db.ignoreChanges` / `ignore`. |
61
+ | | A table the app creates while a scenario runs: it is emptied from the next reset on, but `db.changes()` of the scenario that created it doesn't list it (the list is read at each reset). |
62
+ | | Writes to anything but the database: Redis, files, a queue. |
63
+ | | Reads. A query that returns the wrong rows changes nothing. |
64
+
65
+ A table without a primary key reports an update as one deleted row plus one inserted row.
66
+
67
+ ## OpenAPI coverage
68
+
69
+ The report counts **documented responses of your app's spec**: each `METHOD /path` + status key the spec lists (`200`, `404`, `4XX`, `default`) is covered when a scenario's `http` request got a response that falls under it, crediting only the most specific key: the exact status, else `4XX`, else `default`. A 404 on an operation that documents both `404` and `default` covers `404` only. The percentage is covered keys over all keys in the spec.
70
+
71
+ | Counts | Doesn't count |
72
+ |---|---|
73
+ | A `404` on `GET /polls/{id}` that some scenario produced. | Lines or branches of your code, or business rules. 100% means every documented response was seen once, not that the behavior behind each was tested. |
74
+ | | How many different inputs reached a response, or whether the scenario that produced it asserted anything about it. |
75
+ | | Responses to requests that didn't go through the scenario's `http` client, and undocumented responses (those fail the scenario instead). |
76
+
77
+ A run filtered by file, `-t`, `--tag` or a shard prints its coverage marked as partial and doesn't enforce `minCoverage`.
78
+
79
+ ## Unexpected stub calls and unused routes
80
+
81
+ | Catches | Can't catch |
82
+ |---|---|
83
+ | A call to a stub that no registered route (or recording, or `autoReply`) answers: the scenario fails with the closest route and why it differed. | A call the app should have made and didn't, unless a route is registered and `strictStubs` is on (or `toHaveReceived` is asserted). |
84
+ | With `strictStubs`, a registered route the app never called. | Calls to hosts no stub covers, unless `offline: true` refuses them. A client that ignores proxy settings goes around `hosts` and `offline`. |
85
+
86
+ ## Tests written by an AI agent
87
+
88
+ An agent that misreads a requirement tends to misread it in the code and in the test, and the test passes. Contract checks and snapshots don't catch that: they compare the app with the spec and with itself. Before merging generated scenarios:
89
+
90
+ - Review the expected values against the acceptance criteria, not against the app's output.
91
+ - Treat snapshot updates (`-u`) and `gen` / `record` output as code changes to be reviewed.
92
+ - Prefer assertions on what the requirement states (`db.changes()` with the exact rows, `toHaveReceived` with the exact payload) over `toMatchSnapshot` alone.