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.
- package/README.md +131 -35
- package/dist/app.js +10 -2
- package/dist/auth.js +2 -1
- package/dist/cli-run.d.ts +53 -0
- package/dist/cli-run.js +80 -0
- package/dist/cli.js +27 -29
- package/dist/config.d.ts +33 -2
- package/dist/config.js +121 -2
- package/dist/container-runtime.d.ts +4 -0
- package/dist/container-runtime.js +16 -0
- package/dist/containers.js +4 -3
- package/dist/db.d.ts +3 -1
- package/dist/db.js +45 -8
- package/dist/deadline.d.ts +7 -0
- package/dist/deadline.js +24 -0
- package/dist/doctor.js +27 -5
- package/dist/drivers/driver.d.ts +6 -0
- package/dist/drivers/mysql.js +5 -1
- package/dist/drivers/postgres.d.ts +2 -0
- package/dist/drivers/postgres.js +20 -1
- package/dist/exit.d.ts +1 -0
- package/dist/exit.js +28 -0
- package/dist/form.d.ts +2 -0
- package/dist/form.js +62 -9
- package/dist/global-setup.js +3 -6
- package/dist/har.js +4 -4
- package/dist/http.d.ts +38 -4
- package/dist/http.js +135 -10
- package/dist/init.js +64 -10
- package/dist/list.d.ts +1 -0
- package/dist/list.js +16 -3
- package/dist/mail.d.ts +1 -1
- package/dist/mail.js +7 -7
- package/dist/matchers.d.ts +18 -6
- package/dist/matchers.js +75 -8
- package/dist/openapi.d.ts +2 -5
- package/dist/openapi.js +146 -20
- package/dist/record-cli.js +2 -1
- package/dist/record.js +2 -1
- package/dist/recording.d.ts +11 -1
- package/dist/recording.js +51 -6
- package/dist/request-url.d.ts +1 -0
- package/dist/request-url.js +5 -0
- package/dist/runtime.d.ts +3 -3
- package/dist/runtime.js +144 -18
- package/dist/scenario.js +11 -2
- package/dist/sql-migrations.d.ts +11 -0
- package/dist/sql-migrations.js +38 -0
- package/dist/stub.d.ts +10 -3
- package/dist/stub.js +64 -16
- package/dist/vitest.d.ts +4 -0
- package/dist/vitest.js +6 -2
- package/dist/webhook.d.ts +3 -3
- package/dist/webhook.js +10 -2
- package/dist/yaml-runtime.d.ts +4 -4
- package/dist/yaml-runtime.js +141 -38
- package/dist/yaml.d.ts +12 -2
- package/dist/yaml.js +47 -4
- package/docs/guarantees.md +92 -0
- package/docs/state-isolation.md +94 -0
- package/package.json +4 -1
- package/schema/config.schema.json +675 -0
- 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
|
|
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)
|
package/dist/yaml-runtime.d.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import "./provided.js";
|
|
2
2
|
import "./matchers.js";
|
|
3
|
-
import type
|
|
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
|
-
/** `
|
|
8
|
-
export declare function
|
|
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 {};
|
package/dist/yaml-runtime.js
CHANGED
|
@@ -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 ?
|
|
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(
|
|
99
|
-
if (!
|
|
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
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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(
|
|
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
|
|
623
|
-
if (!
|
|
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" &&
|
|
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
|
|
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
|
-
|
|
666
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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]) =>
|
|
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.
|