slicetest 0.3.0 → 0.5.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 (60) hide show
  1. package/README.md +211 -7
  2. package/dist/auth.d.ts +45 -0
  3. package/dist/auth.js +128 -0
  4. package/dist/ci.d.ts +29 -0
  5. package/dist/ci.js +57 -0
  6. package/dist/cli.js +31 -3
  7. package/dist/config.d.ts +25 -2
  8. package/dist/config.js +25 -4
  9. package/dist/db.d.ts +26 -0
  10. package/dist/db.js +50 -0
  11. package/dist/doctor.d.ts +22 -0
  12. package/dist/doctor.js +190 -0
  13. package/dist/drivers/driver.d.ts +29 -0
  14. package/dist/drivers/index.js +2 -0
  15. package/dist/drivers/mysql.d.ts +2 -1
  16. package/dist/drivers/mysql.js +38 -0
  17. package/dist/drivers/postgres.d.ts +2 -1
  18. package/dist/drivers/postgres.js +28 -0
  19. package/dist/drivers/sqlite.d.ts +24 -0
  20. package/dist/drivers/sqlite.js +255 -0
  21. package/dist/factory.d.ts +21 -0
  22. package/dist/factory.js +129 -0
  23. package/dist/gen.d.ts +1 -0
  24. package/dist/gen.js +9 -4
  25. package/dist/global-setup.js +24 -3
  26. package/dist/http.d.ts +13 -0
  27. package/dist/http.js +24 -0
  28. package/dist/index.d.ts +5 -1
  29. package/dist/index.js +2 -0
  30. package/dist/init.js +123 -5
  31. package/dist/mail.d.ts +58 -0
  32. package/dist/mail.js +299 -0
  33. package/dist/matchers.d.ts +4 -0
  34. package/dist/matchers.js +33 -0
  35. package/dist/openapi.d.ts +9 -0
  36. package/dist/openapi.js +29 -1
  37. package/dist/provided.d.ts +1 -0
  38. package/dist/query-log.d.ts +49 -0
  39. package/dist/query-log.js +261 -0
  40. package/dist/record-cli.d.ts +6 -0
  41. package/dist/record-cli.js +93 -0
  42. package/dist/record-session.d.ts +1 -0
  43. package/dist/record-session.js +10 -0
  44. package/dist/record.d.ts +46 -0
  45. package/dist/record.js +201 -0
  46. package/dist/runtime.d.ts +10 -0
  47. package/dist/runtime.js +62 -3
  48. package/dist/stub.d.ts +32 -0
  49. package/dist/stub.js +87 -0
  50. package/dist/trace.d.ts +9 -1
  51. package/dist/trace.js +3 -1
  52. package/dist/vitest.js +5 -2
  53. package/dist/webhook.d.ts +40 -0
  54. package/dist/webhook.js +52 -0
  55. package/dist/yaml-runtime.d.ts +1 -0
  56. package/dist/yaml-runtime.js +92 -15
  57. package/dist/yaml.d.ts +59 -1
  58. package/dist/yaml.js +88 -5
  59. package/package.json +11 -4
  60. package/schema/scenario.schema.json +329 -1
@@ -0,0 +1,52 @@
1
+ import { createHmac, randomUUID } from "node:crypto";
2
+ const hmac = (algorithm, key, data) => createHmac(algorithm, key).update(data);
3
+ /**
4
+ * The headers a provider sends with `body`, signed with `secret` the way its
5
+ * SDK verifies them: Stripe's `Stripe-Signature`, GitHub's
6
+ * `X-Hub-Signature-256`, Slack's `v0` signature, Shopify's base64 HMAC and
7
+ * Standard Webhooks (Svix, Resend, Clerk, …).
8
+ */
9
+ export function signWebhook(body, opts) {
10
+ const now = Math.floor(Date.now() / 1000);
11
+ const ts = opts.timestamp ?? (opts.stale ? now - 600 : now);
12
+ const secret = opts.invalidSignature ? `${opts.secret}-not-the-real-secret` : opts.secret;
13
+ const p = opts.provider;
14
+ if (typeof p === "object") {
15
+ if (!p.header)
16
+ throw new Error("slicetest: a custom webhook provider needs `header`, e.g. { header: \"X-Signature\", prefix: \"sha256=\" }");
17
+ return { [p.header]: `${p.prefix ?? ""}${hmac(p.algorithm ?? "sha256", secret, body).digest(p.encoding ?? "hex")}` };
18
+ }
19
+ switch (p) {
20
+ case "stripe":
21
+ return { "stripe-signature": `t=${ts},v1=${hmac("sha256", secret, `${ts}.${body}`).digest("hex")}` };
22
+ case "github":
23
+ return {
24
+ "x-hub-signature-256": `sha256=${hmac("sha256", secret, body).digest("hex")}`,
25
+ "x-github-event": opts.event ?? "ping",
26
+ "x-github-delivery": randomUUID(),
27
+ };
28
+ case "slack":
29
+ return { "x-slack-request-timestamp": String(ts), "x-slack-signature": `v0=${hmac("sha256", secret, `v0:${ts}:${body}`).digest("hex")}` };
30
+ case "shopify":
31
+ return {
32
+ "x-shopify-hmac-sha256": hmac("sha256", secret, body).digest("base64"),
33
+ ...(opts.event ? { "x-shopify-topic": opts.event } : {}),
34
+ "x-shopify-webhook-id": randomUUID(),
35
+ };
36
+ case "standard": {
37
+ const id = opts.id ?? `msg_${randomUUID().replace(/-/g, "")}`;
38
+ const key = secret.startsWith("whsec_") ? Buffer.from(secret.slice(6), "base64") : Buffer.from(secret);
39
+ return { "webhook-id": id, "webhook-timestamp": String(ts), "webhook-signature": `v1,${hmac("sha256", key, `${id}.${ts}.${body}`).digest("base64")}` };
40
+ }
41
+ default:
42
+ throw new Error(`slicetest: unknown webhook provider ${JSON.stringify(p)} (expected stripe, github, slack, shopify, standard or { header, prefix, encoding })`);
43
+ }
44
+ }
45
+ /** The bytes to send and their content type: objects as JSON, URLSearchParams as a form (Slack commands), strings as-is. */
46
+ export function webhookBody(payload) {
47
+ if (typeof payload === "string")
48
+ return { body: payload, type: "application/json" };
49
+ if (payload instanceof URLSearchParams)
50
+ return { body: payload.toString(), type: "application/x-www-form-urlencoded" };
51
+ return { body: JSON.stringify(payload ?? {}), type: "application/json" };
52
+ }
@@ -1,3 +1,4 @@
1
+ import "./provided.js";
1
2
  import "./matchers.js";
2
3
  import type { YamlFile } from "./yaml.js";
3
4
  type Vars = Record<string, unknown>;
@@ -1,7 +1,10 @@
1
- import { expect } from "vitest";
1
+ import { expect, inject } from "vitest";
2
+ import { recordYamlFailure } from "./ci.js";
3
+ import "./provided.js";
2
4
  import { formatChanges } from "./db.js";
3
5
  import "./matchers.js";
4
6
  import { scenario } from "./scenario.js";
7
+ import { signWebhook, webhookBody } from "./webhook.js";
5
8
  /** Registers the scenarios of one parsed YAML file. Called from the module the plugin generates for it. */
6
9
  export function defineYamlScenarios(doc) {
7
10
  for (const sc of doc.scenarios) {
@@ -17,11 +20,17 @@ async function runSteps(doc, sc, steps, ctx, vars) {
17
20
  for (const [i, step] of steps.entries()) {
18
21
  try {
19
22
  // A log step waits by itself; `within` is its timeout.
20
- await retry("within" in step && !("log" in step) ? step.within : undefined, () => runStep(step, ctx, vars));
23
+ // Log and mail steps wait by themselves; `within` is their timeout.
24
+ await retry("within" in step && !("log" in step) && !("mail" in step) ? step.within : undefined, () => runStep(step, ctx, vars));
21
25
  }
22
26
  catch (e) {
23
27
  const label = step.name ?? describeStep(step);
24
28
  const where = `${doc.file}:${step.line} (${sc.name}, step ${i + 1}: ${label})`;
29
+ const ciDir = inject("slicetestDb")?.ciDir;
30
+ if (ciDir && doc.path) {
31
+ const message = (e instanceof Error ? e.message : String(e)).replace(/\x1b\[[0-9;]*m/g, "");
32
+ await recordYamlFailure(ciDir, { file: doc.path, line: step.line, scenario: sc.name, step: `step ${i + 1}: ${label}`, message }).catch(() => { });
33
+ }
25
34
  if (e instanceof Error) {
26
35
  e.message = `${where}\n${e.message}`;
27
36
  throw e;
@@ -32,13 +41,17 @@ async function runSteps(doc, sc, steps, ctx, vars) {
32
41
  }
33
42
  function describeStep(step) {
34
43
  if ("request" in step)
35
- return step.request;
44
+ return step.concurrency ? `${step.request} ×${step.concurrency}` : step.request;
36
45
  if ("stub" in step)
37
46
  return `stub ${step.stub} ${step.on}`;
38
47
  if ("received" in step)
39
48
  return `received ${step.received}${step.call ? ` ${step.call}` : ""}`;
40
49
  if ("insert" in step)
41
50
  return `insert ${step.insert}`;
51
+ if ("make" in step)
52
+ return `make ${step.make}`;
53
+ if ("chaos" in step)
54
+ return `chaos ${step.chaos}`;
42
55
  if ("db" in step)
43
56
  return `db ${step.db}`;
44
57
  if ("changes" in step)
@@ -47,6 +60,8 @@ function describeStep(step) {
47
60
  return "checkpoint";
48
61
  if ("snapshot" in step)
49
62
  return "snapshot";
63
+ if ("mail" in step)
64
+ return `mail${Object.entries(step.mail).map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`).join(",")}`;
50
65
  if ("log" in step)
51
66
  return `log ${step.from ? `from ${step.from} ` : ""}/${step.log}/`;
52
67
  return "sql";
@@ -84,6 +99,24 @@ async function runStep(step, ctx, vars) {
84
99
  await target.waitForLog(new RegExp(interpolate(step.log, vars)), step.within ?? 5000);
85
100
  return;
86
101
  }
102
+ if ("mail" in step) {
103
+ const filter = Object.fromEntries(Object.entries(interpolate(step.mail, vars)).map(([k, v]) => {
104
+ const re = v && typeof v === "object" && "$regex" in v ? new RegExp(String(v.$regex)) : undefined;
105
+ return [k, re ?? String(v)];
106
+ }));
107
+ const box = ctx.mail;
108
+ await retry(step.within ?? 5000, async () => {
109
+ const found = box.messages(filter);
110
+ if (step.times === undefined ? found.length === 0 : found.length !== step.times) {
111
+ const want = step.times === undefined ? "at least one message" : `${step.times} message(s)`;
112
+ throw new Error(`expected ${want} matching ${JSON.stringify(step.mail)}, got ${found.length}\n${box.describe()}`);
113
+ }
114
+ });
115
+ const last = box.last(filter);
116
+ if (last)
117
+ capture(step.capture, last, vars);
118
+ return;
119
+ }
87
120
  if ("changes" in step) {
88
121
  const actual = await ctx.db.changes();
89
122
  const expected = interpolate(step.changes, vars);
@@ -131,28 +164,56 @@ async function runStep(step, ctx, vars) {
131
164
  if ("request" in step) {
132
165
  const [method, rawPath] = splitCall(step.request);
133
166
  const path = interpolate(rawPath, vars);
167
+ const headers = interpolate(step.headers, vars);
134
168
  const opts = {
135
- headers: interpolate(step.headers, vars),
169
+ headers: step.auth ? { ...ctx.auth.header(step.auth === true ? {} : interpolate(step.auth, vars)), ...headers } : headers,
136
170
  query: interpolate(step.query, vars),
137
171
  follow: step.follow,
138
172
  };
139
- const body = step.json !== undefined
173
+ let body = step.json !== undefined
140
174
  ? interpolate(step.json, vars)
141
175
  : step.form !== undefined
142
176
  ? ctx.http.form(interpolate(step.form, vars))
143
177
  : interpolate(step.body, vars);
144
- const res = await ctx.http.request(method, path, body, opts);
178
+ if (step.webhook) {
179
+ const signed = webhookBody(body);
180
+ body = signed.body;
181
+ opts.headers = { "content-type": signed.type, ...signWebhook(signed.body, interpolate(step.webhook, vars)), ...opts.headers };
182
+ }
145
183
  const e = step.expect;
146
- if (e?.status !== undefined)
147
- expect(res).toHaveStatus(interpolate(e.status, vars));
148
- if (e?.headers !== undefined) {
149
- const expected = Object.fromEntries(Object.entries(e.headers).map(([k, v]) => [k.toLowerCase(), v]));
150
- check(Object.fromEntries(res.headers), expected, vars, "response headers");
184
+ const verify = (res) => {
185
+ if (e?.status !== undefined)
186
+ expect(res).toHaveStatus(interpolate(e.status, vars));
187
+ if (e?.headers !== undefined) {
188
+ const expected = Object.fromEntries(Object.entries(e.headers).map(([k, v]) => [k.toLowerCase(), v]));
189
+ check(Object.fromEntries(res.headers), expected, vars, "response headers");
190
+ }
191
+ if (e?.json !== undefined)
192
+ check(res.json, e.json, vars, "response JSON");
193
+ if (e?.text !== undefined)
194
+ check(res.text, e.text, vars, "response text");
195
+ };
196
+ if (step.concurrency !== undefined) {
197
+ // Each request gets its own body: a URLSearchParams body can only be read once.
198
+ const all = await ctx.http.concurrently(step.concurrency, () => ctx.http.request(method, path, body instanceof URLSearchParams ? new URLSearchParams(body) : body, opts));
199
+ if (e?.statuses !== undefined)
200
+ expect(all).toHaveStatuses(interpolate(e.statuses, vars));
201
+ all.forEach(verify);
202
+ return;
151
203
  }
152
- if (e?.json !== undefined)
153
- check(res.json, e.json, vars, "response JSON");
154
- if (e?.text !== undefined)
155
- check(res.text, e.text, vars, "response text");
204
+ const max = e?.queries === undefined ? undefined : interpolate(e.queries, vars);
205
+ let res;
206
+ const queries = await (max === undefined ? undefined : ctx.db.queries(async () => (res = await ctx.http.request(method, path, body, opts))));
207
+ if (!queries)
208
+ res = await ctx.http.request(method, path, body, opts);
209
+ if (queries && max !== undefined) {
210
+ const ran = queries.withoutTransactions();
211
+ if (ran.length > max) {
212
+ const shapes = ran.shapes().map((s) => ` ${s.count > 1 ? `×${s.count} ` : ""}${s.sql}`);
213
+ throw new Error(`expected at most ${max} SQL statements for ${method} ${path}, the app ran ${ran.length}:\n${shapes.join("\n")}`);
214
+ }
215
+ }
216
+ verify(res);
156
217
  capture(step.capture, { status: res.status, json: res.json, text: res.text, headers: Object.fromEntries(res.headers) }, vars);
157
218
  return;
158
219
  }
@@ -161,6 +222,22 @@ async function runStep(step, ctx, vars) {
161
222
  capture(step.capture, { rows, row: rows[0] }, vars);
162
223
  return;
163
224
  }
225
+ if ("chaos" in step) {
226
+ const { chaos, name: _, ...opts } = step;
227
+ ctx.stub(chaos).chaos(opts);
228
+ return;
229
+ }
230
+ if ("make" in step) {
231
+ const given = interpolate(step.rows, vars) ?? {};
232
+ const rows = [];
233
+ if (Array.isArray(given))
234
+ for (const r of given)
235
+ rows.push(await ctx.db.make(step.make, r));
236
+ else
237
+ rows.push(...(await ctx.db.makeMany(step.make, step.count ?? 1, given)));
238
+ capture(step.capture, { rows, row: rows[0] }, vars);
239
+ return;
240
+ }
164
241
  if ("sql" in step) {
165
242
  const rows = await ctx.db.query(step.sql, interpolate(step.params, vars) ?? []);
166
243
  expectRows(step.expect, rows, vars);
package/dist/yaml.d.ts CHANGED
@@ -6,6 +6,8 @@
6
6
  */
7
7
  export interface YamlFile {
8
8
  file: string;
9
+ /** Absolute path, for CI annotations. */
10
+ path?: string;
9
11
  /** Steps run at the start of every scenario in the file. */
10
12
  setup: Step[];
11
13
  scenarios: YamlScenario[];
@@ -19,7 +21,7 @@ export interface YamlScenario {
19
21
  timeout?: number;
20
22
  steps: Step[];
21
23
  }
22
- export type Step = (StubStep | RequestStep | InsertStep | SqlStep | DbStep | ReceivedStep | ChangesStep | CheckpointStep | LogStep | SnapshotStep) & {
24
+ export type Step = (StubStep | RequestStep | InsertStep | MakeStep | ChaosStep | SqlStep | DbStep | ReceivedStep | ChangesStep | CheckpointStep | LogStep | SnapshotStep | MailStep) & {
23
25
  line: number;
24
26
  name?: string;
25
27
  };
@@ -55,11 +57,31 @@ export interface RequestStep {
55
57
  form?: Record<string, unknown>;
56
58
  body?: string;
57
59
  follow?: boolean;
60
+ /** Send `Authorization: Bearer` with a token from the `auth` issuer: `true`, or the claims, e.g. { sub: u1, roles: [admin] }. */
61
+ auth?: true | Record<string, unknown>;
62
+ /** Sign the body as this provider would deliver a webhook: { provider: stripe, secret: whsec_test, event, stale, invalidSignature }. */
63
+ webhook?: {
64
+ provider: string | {
65
+ header: string;
66
+ algorithm?: string;
67
+ encoding?: "hex" | "base64";
68
+ prefix?: string;
69
+ };
70
+ secret: string;
71
+ event?: string;
72
+ stale?: boolean;
73
+ invalidSignature?: boolean;
74
+ };
75
+ /** Send the request this many times at once. `expect` applies to every response; `statuses` counts them. */
76
+ concurrency?: number;
77
+ /** `queries`: at most this many SQL statements (not counting BEGIN/COMMIT), with `db.queries` on. */
58
78
  expect?: {
59
79
  status?: number;
80
+ statuses?: Record<string, number>;
60
81
  headers?: Record<string, unknown>;
61
82
  json?: unknown;
62
83
  text?: unknown;
84
+ queries?: number;
63
85
  };
64
86
  capture?: Record<string, string>;
65
87
  }
@@ -68,6 +90,24 @@ export interface InsertStep {
68
90
  rows: Record<string, unknown> | Record<string, unknown>[];
69
91
  capture?: Record<string, string>;
70
92
  }
93
+ /** Rows built by `db.make()`: only the columns given are fixed, the rest is filled from the schema. */
94
+ export interface MakeStep {
95
+ make: string;
96
+ rows?: Record<string, unknown> | Record<string, unknown>[];
97
+ /** Make this many rows, each with `rows` (a mapping). */
98
+ count?: number;
99
+ capture?: Record<string, string>;
100
+ }
101
+ /** Faults injected into a stub's answers for the rest of the scenario, like `stub(name).chaos()`. */
102
+ export interface ChaosStep {
103
+ chaos: string;
104
+ failFirst?: number;
105
+ errorRate?: number;
106
+ statuses?: number[];
107
+ networkErrorRate?: number;
108
+ latency?: number | [number, number];
109
+ seed?: number;
110
+ }
71
111
  export interface SqlStep {
72
112
  sql: string;
73
113
  params?: unknown[];
@@ -129,6 +169,24 @@ export interface SnapshotStep {
129
169
  /** Keys whose values are masked in addition to dates and UUIDs. */
130
170
  mask?: string[];
131
171
  }
172
+ /**
173
+ * The app sent mail matching `mail:` (strings are exact addresses for to/from and substrings
174
+ * otherwise; `{ $regex }` also works). Waits up to `within` ms (default 5000). `capture` reads
175
+ * the last match: `subject`, `text`, `links.0`.
176
+ */
177
+ export interface MailStep {
178
+ mail: {
179
+ to?: unknown;
180
+ from?: unknown;
181
+ subject?: unknown;
182
+ text?: unknown;
183
+ html?: unknown;
184
+ };
185
+ /** Exact number of matching messages. Default: at least one. */
186
+ times?: number;
187
+ within?: number;
188
+ capture?: Record<string, string>;
189
+ }
132
190
  export declare class YamlScenarioError extends Error {
133
191
  }
134
192
  export declare function parseScenarioFile(text: string, file: string): YamlFile;
package/dist/yaml.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { isMap, isSeq, LineCounter, parseDocument } from "yaml";
2
2
  const KINDS = {
3
3
  stub: ["on", "when", "reply", "sequence", "networkError", "times", "delay"],
4
- request: ["headers", "query", "json", "form", "body", "follow", "expect", "capture"],
4
+ request: ["headers", "query", "json", "form", "body", "follow", "auth", "webhook", "concurrency", "expect", "capture"],
5
5
  insert: ["rows", "capture"],
6
6
  sql: ["params", "expect", "capture", "within"],
7
7
  db: ["where", "orderBy", "expect", "capture", "within"],
@@ -10,17 +10,25 @@ const KINDS = {
10
10
  log: ["from", "within"],
11
11
  checkpoint: [],
12
12
  snapshot: ["mask"],
13
+ mail: ["times", "within", "capture"],
14
+ make: ["rows", "count", "capture"],
15
+ chaos: ["failFirst", "errorRate", "statuses", "networkErrorRate", "latency", "seed"],
13
16
  };
14
17
  const EXPECT_KEYS = {
15
- request: ["status", "headers", "json", "text"],
18
+ request: ["status", "statuses", "headers", "json", "text", "queries"],
16
19
  sql: ["rows", "count"],
17
20
  db: ["rows", "count"],
18
21
  };
19
22
  const CONDITION_KEYS = ["query", "headers", "json", "body"];
23
+ const WEBHOOK_KEYS = ["provider", "secret", "event", "stale", "invalidSignature"];
24
+ const WEBHOOK_PROVIDERS = ["stripe", "github", "slack", "shopify", "standard"];
25
+ const MAIL_KEYS = ["to", "from", "subject", "text", "html"];
20
26
  const CHANGE_KEYS = ["inserted", "updated", "deleted"];
21
27
  const RESPONSE_KEYS = ["status", "headers", "body"];
22
28
  const SCENARIO_KEYS = ["name", "steps", "each", "skip", "only", "timeout"];
23
29
  const CALL = /^([A-Za-z]+|\*)\s+(\/\S*)$/;
30
+ /** A request may also go to a captured URL of the app, e.g. a link from a mail: `GET {{link}}`. */
31
+ const REQUEST = /^[A-Za-z]+\s+(\/\S*|\{\{[^}]+\}\}\S*)$/;
24
32
  export class YamlScenarioError extends Error {
25
33
  }
26
34
  export function parseScenarioFile(text, file) {
@@ -108,6 +116,14 @@ function parseStep(node, fail, lineOf) {
108
116
  }
109
117
  }
110
118
  }
119
+ else if (kind === "mail") {
120
+ const filter = raw.mail;
121
+ if (!filter || typeof filter !== "object" || Array.isArray(filter))
122
+ fail(at(kind), "`mail:` must be a mapping such as { to: alice@example.com, subject: Welcome } ({} for any)");
123
+ for (const k of Object.keys(filter))
124
+ if (!MAIL_KEYS.includes(k))
125
+ fail(at(kind), `unknown key "${k}" in mail (allowed: ${MAIL_KEYS.join(", ")})`);
126
+ }
111
127
  else if (kind === "checkpoint") {
112
128
  if (raw.checkpoint !== true)
113
129
  fail(at(kind), "use `checkpoint: true`");
@@ -131,8 +147,8 @@ function parseStep(node, fail, lineOf) {
131
147
  fail(at(key), `unknown key "${k}" in ${key} (allowed: ${allowedKeys.join(", ")})`);
132
148
  }
133
149
  };
134
- const call = (key) => {
135
- if (raw[key] !== undefined && !CALL.test(String(raw[key])))
150
+ const call = (key, pattern = CALL) => {
151
+ if (raw[key] !== undefined && !pattern.test(String(raw[key])))
136
152
  fail(at(key), `\`${key}\` must look like "POST /path", got "${raw[key]}"`);
137
153
  };
138
154
  if (raw.within !== undefined && (typeof raw.within !== "number" || raw.within <= 0))
@@ -166,14 +182,78 @@ function parseStep(node, fail, lineOf) {
166
182
  break;
167
183
  }
168
184
  case "request":
169
- call("request");
185
+ call("request", REQUEST);
170
186
  if (["json", "form", "body"].filter((k) => raw[k] !== undefined).length > 1)
171
187
  fail(node, "use only one of json / form / body");
188
+ if (raw.webhook !== undefined) {
189
+ const w = raw.webhook;
190
+ if (!w || typeof w !== "object" || Array.isArray(w))
191
+ fail(at("webhook"), "`webhook` must be a mapping such as { provider: stripe, secret: whsec_test }");
192
+ for (const k of Object.keys(w))
193
+ if (!WEBHOOK_KEYS.includes(k))
194
+ fail(at("webhook"), `unknown key "${k}" in webhook (allowed: ${WEBHOOK_KEYS.join(", ")})`);
195
+ if (typeof w.secret !== "string" || !w.secret)
196
+ fail(at("webhook"), "`webhook.secret` is required: the signing secret the app is configured with");
197
+ const p = w.provider;
198
+ if (!(typeof p === "string" ? WEBHOOK_PROVIDERS.includes(p) : p && typeof p === "object" && typeof p.header === "string")) {
199
+ fail(at("webhook"), `\`webhook.provider\` must be one of ${WEBHOOK_PROVIDERS.join(", ")} or { header, prefix, encoding }`);
200
+ }
201
+ }
202
+ if (raw.auth !== undefined && raw.auth !== true && !(raw.auth && typeof raw.auth === "object" && !Array.isArray(raw.auth)))
203
+ fail(at("auth"), "`auth` must be true or the token's claims, e.g. { sub: u1, roles: [admin] }");
204
+ if (raw.concurrency !== undefined && !(Number.isInteger(raw.concurrency) && raw.concurrency >= 1))
205
+ fail(at("concurrency"), "`concurrency` must be a positive whole number");
206
+ {
207
+ const q = raw.expect?.queries;
208
+ if (q !== undefined && !(Number.isInteger(q) && q >= 0))
209
+ fail(at("expect"), "`expect.queries` is the most SQL statements the request may run, e.g. 3");
210
+ if (q !== undefined && raw.concurrency !== undefined)
211
+ fail(at("expect"), "`expect.queries` can't be used with `concurrency`");
212
+ }
213
+ if (raw.concurrency !== undefined && raw.capture !== undefined)
214
+ fail(at("capture"), "`capture` can't be used with `concurrency`: there is more than one response");
215
+ {
216
+ const statuses = raw.expect?.statuses;
217
+ if (statuses !== undefined) {
218
+ if (raw.concurrency === undefined)
219
+ fail(at("expect"), "`expect.statuses` needs `concurrency`");
220
+ if (!statuses || typeof statuses !== "object" || Object.entries(statuses).some(([k, v]) => !/^\d{3}$/.test(k) || !Number.isInteger(v))) {
221
+ fail(at("expect"), "`expect.statuses` maps status codes to counts, e.g. { 201: 1, 409: 9 }");
222
+ }
223
+ }
224
+ }
172
225
  break;
173
226
  case "insert":
174
227
  if (!raw.rows || typeof raw.rows !== "object")
175
228
  fail(at("rows"), "an insert step needs `rows` (a mapping or a list of mappings)");
176
229
  break;
230
+ case "chaos":
231
+ for (const k of ["errorRate", "networkErrorRate"]) {
232
+ if (raw[k] !== undefined && !(typeof raw[k] === "number" && raw[k] >= 0 && raw[k] <= 1))
233
+ fail(at(k), `\`${k}\` must be a number between 0 and 1`);
234
+ }
235
+ for (const k of ["failFirst", "seed"])
236
+ if (raw[k] !== undefined && !Number.isInteger(raw[k]))
237
+ fail(at(k), `\`${k}\` must be a whole number`);
238
+ if (raw.statuses !== undefined && !(Array.isArray(raw.statuses) && raw.statuses.length > 0 && raw.statuses.every((s) => Number.isInteger(s) && s >= 400 && s <= 599))) {
239
+ fail(at("statuses"), "`statuses` must be a list of 4xx/5xx codes, e.g. [503]");
240
+ }
241
+ {
242
+ const l = raw.latency;
243
+ if (l !== undefined && !(typeof l === "number" || (Array.isArray(l) && l.length === 2 && l.every((x) => typeof x === "number"))))
244
+ fail(at("latency"), "`latency` must be milliseconds or [min, max]");
245
+ }
246
+ break;
247
+ case "make":
248
+ if (raw.rows !== undefined && (!raw.rows || typeof raw.rows !== "object"))
249
+ fail(at("rows"), "`rows` must be a mapping or a list of mappings");
250
+ if (raw.count !== undefined) {
251
+ if (!(Number.isInteger(raw.count) && raw.count >= 1))
252
+ fail(at("count"), "`count` must be a positive whole number");
253
+ if (Array.isArray(raw.rows))
254
+ fail(at("count"), "use either `count` or a list of `rows`");
255
+ }
256
+ break;
177
257
  case "log":
178
258
  try {
179
259
  new RegExp(raw.log);
@@ -188,6 +268,9 @@ function parseStep(node, fail, lineOf) {
188
268
  call("call");
189
269
  number("times");
190
270
  break;
271
+ case "mail":
272
+ number("times");
273
+ break;
191
274
  }
192
275
  return { ...raw, line: lineOf(node) };
193
276
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "slicetest",
3
- "version": "0.3.0",
4
- "description": "Framework-agnostic slice tests: drive a real app process over HTTP with a real Postgres and stubbed outbound APIs, checked against OpenAPI contracts.",
3
+ "version": "0.5.0",
4
+ "description": "Framework-agnostic slice tests: drive a real app process over HTTP with a real Postgres, MySQL or SQLite and stubbed outbound APIs, checked against OpenAPI contracts.",
5
5
  "keywords": [
6
6
  "testing",
7
7
  "integration-testing",
@@ -9,6 +9,7 @@
9
9
  "api-testing",
10
10
  "postgres",
11
11
  "mysql",
12
+ "sqlite",
12
13
  "stub",
13
14
  "mock-server",
14
15
  "vitest",
@@ -17,7 +18,13 @@
17
18
  "testcontainers",
18
19
  "language-agnostic",
19
20
  "openapi",
20
- "contract-testing"
21
+ "contract-testing",
22
+ "jwt",
23
+ "oidc",
24
+ "webhooks",
25
+ "chaos-testing",
26
+ "n-plus-one",
27
+ "test-data-factory"
21
28
  ],
22
29
  "author": "pon-bok",
23
30
  "repository": {
@@ -55,7 +62,7 @@
55
62
  "build": "tsc -p tsconfig.build.json",
56
63
  "typecheck": "tsc --noEmit",
57
64
  "test": "vitest run",
58
- "test:dist": "npm run build && tsc -p examples/tsconfig.dist.json && vitest run --config examples/vitest.dist.config.ts && node dist/cli.js --config examples/slicetest.config.yaml",
65
+ "test:dist": "npm run build && tsc -p examples/tsconfig.dist.json && vitest run --config examples/vitest.dist.config.ts && node dist/cli.js --config examples/slicetest.config.yaml && node test/record-dist.mjs",
59
66
  "prepublishOnly": "npm run typecheck && npm run build"
60
67
  },
61
68
  "peerDependencies": {