slicetest 0.9.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 +118 -30
- package/dist/app.js +10 -2
- package/dist/auth.js +2 -1
- package/dist/cli-run.d.ts +40 -0
- package/dist/cli-run.js +65 -0
- package/dist/cli.js +20 -34
- package/dist/config.d.ts +33 -2
- package/dist/config.js +82 -8
- 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 +1 -0
- package/dist/form.js +50 -6
- package/dist/har.js +2 -2
- package/dist/http.d.ts +9 -3
- package/dist/http.js +40 -5
- package/dist/init.js +11 -2
- 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 +4 -3
- package/dist/matchers.js +18 -6
- package/dist/openapi.d.ts +2 -0
- package/dist/openapi.js +133 -14
- package/dist/record-cli.js +2 -1
- package/dist/record.js +2 -1
- package/dist/recording.d.ts +4 -1
- package/dist/recording.js +14 -3
- 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 +132 -18
- package/dist/scenario.js +11 -2
- package/dist/stub.js +17 -6
- 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 +96 -34
- package/dist/yaml.d.ts +4 -0
- package/dist/yaml.js +33 -2
- 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 +27 -3
package/dist/stub.js
CHANGED
|
@@ -2,6 +2,7 @@ import http from "node:http";
|
|
|
2
2
|
import { isDeepStrictEqual } from "node:util";
|
|
3
3
|
import { describeGraphQL, graphqlErrors, graphqlOf } from "./graphql.js";
|
|
4
4
|
import { timeline } from "./timeline.js";
|
|
5
|
+
import { requestUrl } from "./request-url.js";
|
|
5
6
|
/**
|
|
6
7
|
* A streaming reply in Server-Sent Events format, as LLM APIs stream (Anthropic, OpenAI):
|
|
7
8
|
* `stub("anthropic").on("POST", "/v1/messages").reply(sse([["message_start", {...}], ...]))`.
|
|
@@ -272,7 +273,7 @@ export class Stub {
|
|
|
272
273
|
const raw = Buffer.concat(chunks);
|
|
273
274
|
const body = raw.toString("utf8");
|
|
274
275
|
const contentType = req.headers["content-type"] ?? "";
|
|
275
|
-
const url =
|
|
276
|
+
const url = requestUrl(req.url, this.url);
|
|
276
277
|
const call = {
|
|
277
278
|
method: req.method ?? "GET",
|
|
278
279
|
path: url.pathname,
|
|
@@ -375,14 +376,19 @@ function splitQuery(path, match) {
|
|
|
375
376
|
return { path: bare || "/", match: { ...match, query: { ...query, ...match.query } } };
|
|
376
377
|
}
|
|
377
378
|
function compilePath(path) {
|
|
378
|
-
if (typeof path !== "string" ||
|
|
379
|
+
if (typeof path !== "string" || !/\/[:*]/.test(path))
|
|
379
380
|
return { pattern: undefined, paramNames: [] };
|
|
380
381
|
const paramNames = [];
|
|
381
382
|
const source = path
|
|
382
383
|
.split("/")
|
|
383
384
|
.map((seg) => {
|
|
385
|
+
// Like Express's `*`: the rest of the path, slashes included.
|
|
386
|
+
if (seg === "*") {
|
|
387
|
+
paramNames.push("*");
|
|
388
|
+
return "(.+)";
|
|
389
|
+
}
|
|
384
390
|
if (!seg.startsWith(":"))
|
|
385
|
-
return seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
391
|
+
return encodeLiteral(seg).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
386
392
|
paramNames.push(seg.slice(1));
|
|
387
393
|
return "([^/]+)";
|
|
388
394
|
})
|
|
@@ -392,7 +398,7 @@ function compilePath(path) {
|
|
|
392
398
|
/** Returns captured params on a match, undefined otherwise. */
|
|
393
399
|
function matchPath(path, pattern, actual, names = []) {
|
|
394
400
|
if (typeof path === "string" && !pattern)
|
|
395
|
-
return path === actual ? {} : undefined;
|
|
401
|
+
return path === actual || encodeLiteral(path) === actual ? {} : undefined;
|
|
396
402
|
const re = pattern ?? path;
|
|
397
403
|
re.lastIndex = 0;
|
|
398
404
|
const m = re.exec(actual);
|
|
@@ -402,6 +408,10 @@ function matchPath(path, pattern, actual, names = []) {
|
|
|
402
408
|
names.forEach((n, i) => (params[n] = decodeSegment(m[i + 1])));
|
|
403
409
|
return params;
|
|
404
410
|
}
|
|
411
|
+
/** A route written with a space or non-ASCII characters (`/検索`) is what the app sent percent-encoded (`/%E6%A4%9C…`). */
|
|
412
|
+
function encodeLiteral(path) {
|
|
413
|
+
return path.replace(/[^\x21-\x7e]+/gu, encodeURIComponent);
|
|
414
|
+
}
|
|
405
415
|
/** A path segment decoded, or as sent when it isn't valid percent-encoding (`%zz`), instead of dropping the call. */
|
|
406
416
|
function decodeSegment(segment) {
|
|
407
417
|
try {
|
|
@@ -669,8 +679,9 @@ async function parseMultipart(raw, contentType) {
|
|
|
669
679
|
size: value.size,
|
|
670
680
|
...(TEXTUAL.test(value.type) ? { text: await value.text() } : {}),
|
|
671
681
|
};
|
|
672
|
-
|
|
673
|
-
|
|
682
|
+
// `out[key]` would read Object's `constructor` and set the prototype for `__proto__`.
|
|
683
|
+
const prev = Object.hasOwn(out, key) ? out[key] : undefined;
|
|
684
|
+
Object.defineProperty(out, key, { value: prev === undefined ? v : Array.isArray(prev) ? [...prev, v] : [prev, v], enumerable: true, writable: true, configurable: true });
|
|
674
685
|
}
|
|
675
686
|
return out;
|
|
676
687
|
}
|
package/dist/vitest.d.ts
CHANGED
|
@@ -13,4 +13,8 @@ export declare const YAML_SCENARIOS = "**/*.scenario.{yaml,yml}";
|
|
|
13
13
|
* Vitest root), or nothing to read the slicetest.config.yaml next to the Vitest config.
|
|
14
14
|
*/
|
|
15
15
|
export declare function slicetest(options?: SlicetestOptions | string): Plugin;
|
|
16
|
+
/** @internal `include`: the CLI's test files, used as they are instead of extending Vitest's. */
|
|
17
|
+
export declare function slicetestPlugin(options?: SlicetestOptions | string, cli?: {
|
|
18
|
+
include?: string[];
|
|
19
|
+
}): Plugin;
|
|
16
20
|
export type { SlicetestOptions } from "./config.js";
|
package/dist/vitest.js
CHANGED
|
@@ -30,6 +30,10 @@ const ext = path.extname(fileURLToPath(import.meta.url));
|
|
|
30
30
|
* Vitest root), or nothing to read the slicetest.config.yaml next to the Vitest config.
|
|
31
31
|
*/
|
|
32
32
|
export function slicetest(options) {
|
|
33
|
+
return slicetestPlugin(options);
|
|
34
|
+
}
|
|
35
|
+
/** @internal `include`: the CLI's test files, used as they are instead of extending Vitest's. */
|
|
36
|
+
export function slicetestPlugin(options, cli = {}) {
|
|
33
37
|
let root;
|
|
34
38
|
return {
|
|
35
39
|
name: "slicetest",
|
|
@@ -37,9 +41,9 @@ export function slicetest(options) {
|
|
|
37
41
|
const root = path.resolve(config.root ?? process.cwd());
|
|
38
42
|
// Mutated rather than returned: a returned list would replace Vitest's default include instead of extending it.
|
|
39
43
|
const test = (config.test ??= {});
|
|
40
|
-
// `slicetest record` runs only its session file.
|
|
44
|
+
// `slicetest record` runs only its session file. Extending the CLI's include would run every scenario file.
|
|
41
45
|
if (!process.env[SESSION_ENV])
|
|
42
|
-
test.include = [...(test.include ?? configDefaults.include), YAML_SCENARIOS];
|
|
46
|
+
test.include = cli.include ?? [...(test.include ?? configDefaults.include), YAML_SCENARIOS];
|
|
43
47
|
const resolved = resolveOptions(typeof options === "object" ? options : loadConfigFile(root, options), root);
|
|
44
48
|
return {
|
|
45
49
|
test: {
|
package/dist/webhook.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ export interface HmacScheme {
|
|
|
8
8
|
/** Prepended to the digest, e.g. `sha256=`. */
|
|
9
9
|
prefix?: string;
|
|
10
10
|
}
|
|
11
|
-
export type WebhookProvider = "stripe" | "github" | "slack" | "shopify" | "standard" | "line" | "paddle" | "linear" | "gitlab" | "zoom" | "twitch" | "twilio" | HmacScheme;
|
|
11
|
+
export type WebhookProvider = "stripe" | "github" | "slack" | "shopify" | "standard" | "line" | "paddle" | "linear" | "gitlab" | "zoom" | "twitch" | "twilio" | "typeform" | "notion" | "bitbucket" | "intercom" | HmacScheme;
|
|
12
12
|
export interface WebhookOptions {
|
|
13
13
|
provider: WebhookProvider;
|
|
14
14
|
/** The signing secret the app is configured with. For `standard` (Svix), `whsec_<base64>` or the raw key. */
|
|
@@ -32,14 +32,14 @@ export interface WebhookOptions {
|
|
|
32
32
|
headers?: Record<string, string>;
|
|
33
33
|
}
|
|
34
34
|
/** The providers `signWebhook` knows by name. */
|
|
35
|
-
export declare const WEBHOOK_PROVIDERS: readonly ["stripe", "github", "slack", "shopify", "standard", "line", "paddle", "linear", "gitlab", "zoom", "twitch", "twilio"];
|
|
35
|
+
export declare const WEBHOOK_PROVIDERS: readonly ["stripe", "github", "slack", "shopify", "standard", "line", "paddle", "linear", "gitlab", "zoom", "twitch", "twilio", "typeform", "notion", "bitbucket", "intercom"];
|
|
36
36
|
/**
|
|
37
37
|
* The headers a provider sends with `body`, signed with `secret` the way its
|
|
38
38
|
* SDK verifies them: Stripe's `Stripe-Signature`, GitHub's
|
|
39
39
|
* `X-Hub-Signature-256`, Slack's `v0` signature, Shopify's base64 HMAC,
|
|
40
40
|
* Standard Webhooks (Svix, Resend, Clerk, …), LINE's `X-Line-Signature`,
|
|
41
41
|
* Paddle Billing's `Paddle-Signature`, Linear, GitLab's token, Zoom's `v0`
|
|
42
|
-
* signature
|
|
42
|
+
* signature, Twitch EventSub, Typeform, Notion, Bitbucket and Intercom.
|
|
43
43
|
*/
|
|
44
44
|
export declare function signWebhook(body: string, opts: WebhookOptions): Record<string, string>;
|
|
45
45
|
/** The bytes to send and their content type: objects as JSON (as a form for Twilio), URLSearchParams as a form (Slack commands), strings as-is. */
|
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 () => {
|
|
@@ -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;
|
|
@@ -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,10 +574,30 @@ 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 };
|
|
@@ -581,11 +627,20 @@ function subsetEquals(expected, actual) {
|
|
|
581
627
|
if (Array.isArray(expected))
|
|
582
628
|
return Array.isArray(actual) && actual.length === expected.length && expected.every((e, i) => subsetEquals(e, actual[i]));
|
|
583
629
|
if (expected && typeof expected === "object") {
|
|
584
|
-
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])));
|
|
585
631
|
}
|
|
586
632
|
return Object.is(expected, actual) || expected === actual;
|
|
587
633
|
}
|
|
588
|
-
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) {
|
|
589
644
|
switch (key) {
|
|
590
645
|
case "$type":
|
|
591
646
|
if (arg === "null")
|
|
@@ -596,11 +651,15 @@ function single(key, arg) {
|
|
|
596
651
|
return TYPES[arg];
|
|
597
652
|
throw new Error(`$type must be one of ${Object.keys(TYPES).join(", ")}, integer, null`);
|
|
598
653
|
case "$regex":
|
|
599
|
-
return expect.stringMatching(
|
|
654
|
+
return expect.stringMatching(regexOf(arg, options));
|
|
600
655
|
case "$contains":
|
|
601
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);
|
|
602
657
|
case "$any":
|
|
603
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;
|
|
604
663
|
case "$gt":
|
|
605
664
|
return compare(key, arg, (c) => c > 0);
|
|
606
665
|
case "$gte":
|
|
@@ -631,10 +690,10 @@ function single(key, arg) {
|
|
|
631
690
|
return matcher(`$oneOf ${JSON.stringify(arg)}`, (v) => options.some((o) => equalsMatcher(o, v)));
|
|
632
691
|
}
|
|
633
692
|
case "$format": {
|
|
634
|
-
const
|
|
635
|
-
if (!
|
|
693
|
+
const f = Object.hasOwn(FORMATS, String(arg)) ? FORMATS[String(arg)] : undefined;
|
|
694
|
+
if (!f)
|
|
636
695
|
throw new Error(`$format must be one of ${Object.keys(FORMATS).join(", ")}`);
|
|
637
|
-
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)));
|
|
638
697
|
}
|
|
639
698
|
default:
|
|
640
699
|
throw new Error(`unknown matcher ${key} (expected one of ${MATCHERS.join(", ")})`);
|
|
@@ -652,7 +711,12 @@ export function toMatchers(value) {
|
|
|
652
711
|
return value;
|
|
653
712
|
const keys = Object.keys(value);
|
|
654
713
|
if (keys.length > 0 && keys.every((k) => k.startsWith("$"))) {
|
|
655
|
-
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));
|
|
656
720
|
if (parts.length === 1)
|
|
657
721
|
return parts[0];
|
|
658
722
|
return matcher(keys.map((k) => `${k} ${JSON.stringify(value[k])}`).join(", "), (v) => parts.every((p) => equalsMatcher(p, v)));
|
|
@@ -662,20 +726,18 @@ export function toMatchers(value) {
|
|
|
662
726
|
function isAsymmetric(v) {
|
|
663
727
|
return !!v && typeof v === "object" && typeof v.asymmetricMatch === "function";
|
|
664
728
|
}
|
|
665
|
-
function interpolateTitle(name, row, index) {
|
|
666
|
-
return name.replace(PART, (m, key) => {
|
|
667
|
-
if (key.trim() === "#")
|
|
668
|
-
return String(index);
|
|
669
|
-
const v = lookup(row, key);
|
|
670
|
-
return v === undefined ? m : typeof v === "object" ? JSON.stringify(v) : String(v);
|
|
671
|
-
});
|
|
672
|
-
}
|
|
673
729
|
function verifyResponse(res, e, vars, base) {
|
|
674
730
|
if (e?.status !== undefined)
|
|
675
731
|
expect(res).toHaveStatus(interpolate(e.status, vars));
|
|
676
732
|
if (e?.headers !== undefined) {
|
|
677
|
-
|
|
678
|
-
|
|
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");
|
|
679
741
|
}
|
|
680
742
|
if (e?.json !== undefined)
|
|
681
743
|
check(res.json, e.json, vars, "response JSON");
|
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. */
|
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"],
|
|
@@ -416,6 +439,14 @@ function parseStep(node, fail, lineOf) {
|
|
|
416
439
|
if (q !== undefined && raw.concurrency !== undefined)
|
|
417
440
|
fail(at("expect"), "`expect.queries` can't be used with `concurrency`");
|
|
418
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
|
+
}
|
|
419
450
|
if (raw.concurrency !== undefined && raw.capture !== undefined)
|
|
420
451
|
fail(at("capture"), "`capture` can't be used with `concurrency`: there is more than one response");
|
|
421
452
|
{
|
|
@@ -423,8 +454,8 @@ function parseStep(node, fail, lineOf) {
|
|
|
423
454
|
if (statuses !== undefined) {
|
|
424
455
|
if (raw.concurrency === undefined)
|
|
425
456
|
fail(at("expect"), "`expect.statuses` needs `concurrency`");
|
|
426
|
-
if (!statuses || typeof statuses !== "object" || Object.entries(statuses).some(([k, v]) =>
|
|
427
|
-
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 }");
|
|
428
459
|
}
|
|
429
460
|
}
|
|
430
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.
|