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
package/dist/record.js ADDED
@@ -0,0 +1,201 @@
1
+ import { existsSync } from "node:fs";
2
+ import { writeFile } from "node:fs/promises";
3
+ import http from "node:http";
4
+ import path from "node:path";
5
+ import { stringify } from "yaml";
6
+ import { mask } from "./trace.js";
7
+ export const SESSION_ENV = "SLICETEST_RECORD_SESSION";
8
+ /** Requests a browser makes on its own, not part of what the scenario is about. */
9
+ const ASSET = /\.(ico|png|jpe?g|gif|webp|avif|svg|css|js|mjs|map|woff2?|ttf|eot)(\?|$)/i;
10
+ /** Hop-by-hop headers, and the ones fetch rewrites. */
11
+ const DROP_REQUEST = new Set(["host", "connection", "keep-alive", "transfer-encoding", "upgrade", "proxy-connection", "content-length", "accept-encoding"]);
12
+ const DROP_RESPONSE = new Set(["connection", "keep-alive", "transfer-encoding", "content-encoding", "content-length"]);
13
+ export async function runSession(ctx, session) {
14
+ const exchanges = [];
15
+ const target = ctx.app.url;
16
+ const proxy = http.createServer(async (req, res) => {
17
+ const chunks = [];
18
+ for await (const c of req)
19
+ chunks.push(c);
20
+ const body = Buffer.concat(chunks);
21
+ const headers = new Headers();
22
+ for (const [k, v] of Object.entries(req.headers)) {
23
+ if (v === undefined || DROP_REQUEST.has(k))
24
+ continue;
25
+ for (const value of Array.isArray(v) ? v : [v])
26
+ headers.append(k, value);
27
+ }
28
+ try {
29
+ const upstream = await fetch(new URL(req.url ?? "/", target), {
30
+ method: req.method,
31
+ headers,
32
+ body: body.length && req.method !== "GET" && req.method !== "HEAD" ? body : undefined,
33
+ redirect: "manual",
34
+ });
35
+ const payload = Buffer.from(await upstream.arrayBuffer());
36
+ const out = {};
37
+ upstream.headers.forEach((v, k) => {
38
+ if (!DROP_RESPONSE.has(k) && k !== "set-cookie")
39
+ out[k] = v;
40
+ });
41
+ const cookies = upstream.headers.getSetCookie();
42
+ if (cookies.length)
43
+ out["set-cookie"] = cookies;
44
+ res.writeHead(upstream.status, out).end(payload);
45
+ exchanges.push({
46
+ method: req.method ?? "GET",
47
+ path: req.url ?? "/",
48
+ contentType: req.headers["content-type"],
49
+ body: body.toString("utf8"),
50
+ status: upstream.status,
51
+ responseType: upstream.headers.get("content-type") ?? undefined,
52
+ response: payload.toString("utf8"),
53
+ });
54
+ }
55
+ catch (e) {
56
+ res.writeHead(502, { "content-type": "text/plain" }).end(`slicetest record: the app didn't answer: ${e.message}\n`);
57
+ }
58
+ });
59
+ await new Promise((resolve, reject) => {
60
+ proxy.once("error", reject);
61
+ proxy.listen(session.port, "127.0.0.1", resolve);
62
+ });
63
+ const url = `http://127.0.0.1:${proxy.address().port}`;
64
+ await writeFile(session.stateFile, JSON.stringify({ url, app: target }));
65
+ while (!existsSync(session.stopFile))
66
+ await new Promise((r) => setTimeout(r, 200));
67
+ await new Promise((resolve) => proxy.close(() => resolve()));
68
+ proxy.closeAllConnections();
69
+ const calls = Object.fromEntries(session.stubs.map((name) => [name, ctx.stub(name).calls()]));
70
+ const changes = await ctx.db.changes();
71
+ const { yaml, summary } = buildScenario(exchanges, calls, changes, { name: path.basename(session.out).replace(/\.scenario\.ya?ml$/, "") });
72
+ await writeFile(session.out, yaml);
73
+ await writeFile(session.stateFile, JSON.stringify({ url, done: true, summary }));
74
+ }
75
+ /** The recorded session as a YAML scenario, with a comment header of what to review. */
76
+ export function buildScenario(exchanges, calls, changes, { name = "recorded session", now = new Date() } = {}) {
77
+ const kept = exchanges.filter((x) => !ASSET.test(x.path));
78
+ const steps = [];
79
+ const notes = [];
80
+ // Stubs first: the answers the services gave, so the replay doesn't need them.
81
+ const unanswered = [];
82
+ let stubRoutes = 0;
83
+ for (const [stub, list] of Object.entries(calls)) {
84
+ const groups = new Map();
85
+ for (const c of list) {
86
+ if (!c.response || c.response.status === 501) {
87
+ unanswered.push(`${stub}: ${c.method} ${c.path}`);
88
+ continue;
89
+ }
90
+ const key = `${c.method} ${c.path}`;
91
+ groups.set(key, [...(groups.get(key) ?? []), c]);
92
+ }
93
+ for (const [on, group] of groups) {
94
+ const replies = group.map((c) => ({ status: c.response.status, ...(c.response.body ? { body: parse(c.response.body, c.response.headers["content-type"]) } : {}) }));
95
+ const same = replies.every((r) => JSON.stringify(r) === JSON.stringify(replies[0]));
96
+ steps.push({ stub, on, ...(same ? { reply: replies[0] } : { sequence: replies }) });
97
+ stubRoutes++;
98
+ }
99
+ }
100
+ if (unanswered.length)
101
+ notes.push(`These calls got no answer while recording (register a route, or give the stub an upstream / autoReply): ${unanswered.join(", ")}`);
102
+ // Values from earlier responses that later requests use (ids in paths, tokens in bodies) become captures.
103
+ const captured = new Map();
104
+ kept.forEach((x, i) => {
105
+ let reqPath = x.path;
106
+ let reqBody = x.body;
107
+ for (const [value, variable] of captured) {
108
+ reqPath = reqPath.split(value).join(`{{${variable}}}`);
109
+ reqBody = reqBody.split(value).join(`{{${variable}}}`);
110
+ }
111
+ const [method, pathOnly] = [x.method, reqPath];
112
+ const step = { request: `${method} ${pathOnly}` };
113
+ if (x.body) {
114
+ if (/json/i.test(x.contentType ?? ""))
115
+ step.json = parse(reqBody, "application/json");
116
+ else if (/x-www-form-urlencoded/i.test(x.contentType ?? ""))
117
+ step.form = Object.fromEntries(new URLSearchParams(reqBody));
118
+ else
119
+ step.body = reqBody;
120
+ }
121
+ const expectation = { status: x.status };
122
+ const json = /json/i.test(x.responseType ?? "") ? parse(x.response, "application/json") : undefined;
123
+ if (json !== undefined && typeof json === "object")
124
+ expectation.json = loosen(json);
125
+ else if (!/html/i.test(x.responseType ?? "") && x.response && x.response.length <= 200)
126
+ expectation.text = x.response;
127
+ step.expect = expectation;
128
+ // Capture top-level values that show up again later.
129
+ if (json && typeof json === "object" && !Array.isArray(json)) {
130
+ const later = kept.slice(i + 1).map((y) => `${y.path}\n${y.body}`).join("\n");
131
+ const capture = {};
132
+ for (const [key, value] of Object.entries(json)) {
133
+ if ((typeof value !== "string" && typeof value !== "number") || String(value).length < 3 || !later.includes(String(value)))
134
+ continue;
135
+ let variable = key.replace(/\W/g, "_");
136
+ while ([...captured.values()].includes(variable))
137
+ variable += "_";
138
+ capture[variable] = `json.${key}`;
139
+ captured.set(String(value), variable);
140
+ }
141
+ if (Object.keys(capture).length)
142
+ step.capture = capture;
143
+ }
144
+ steps.push(step);
145
+ });
146
+ for (const [stub, list] of Object.entries(calls)) {
147
+ const byCall = new Map();
148
+ for (const c of list)
149
+ if (c.response && c.response.status !== 501)
150
+ byCall.set(`${c.method} ${c.path}`, (byCall.get(`${c.method} ${c.path}`) ?? 0) + 1);
151
+ for (const [call, times] of byCall)
152
+ steps.push({ received: stub, call, times });
153
+ }
154
+ const tables = Object.keys(changes);
155
+ const counts = Object.fromEntries(Object.entries(changes).map(([table, c]) => [
156
+ table,
157
+ Object.fromEntries(Object.entries({ inserted: c.inserted.length, updated: c.updated.length, deleted: c.deleted.length }).filter(([, n]) => n > 0)),
158
+ ]));
159
+ steps.push({ changes: counts });
160
+ const skipped = exchanges.length - kept.length;
161
+ if (skipped)
162
+ notes.push(`${skipped} request(s) for static files (scripts, styles, images) were left out.`);
163
+ if (kept.length === 0)
164
+ notes.push("No requests to the app were recorded; send them to the proxy URL printed when recording started.");
165
+ const header = [
166
+ `# Recorded with \`npx slicetest record\` on ${now.toISOString().slice(0, 10)}. Review it before committing:`,
167
+ "# - give the scenario a name that says what it checks, and split it if it checks several things;",
168
+ "# - dates and UUIDs in responses are matched by type only, and `changes` only counts rows;",
169
+ "# tighten what matters (see https://github.com/revo1290/slicetest#yaml-scenarios).",
170
+ ...notes.map((n) => `# - ${n}`),
171
+ "# yaml-language-server: $schema=https://unpkg.com/slicetest/schema/scenario.schema.json",
172
+ "",
173
+ ].join("\n");
174
+ const yaml = header + stringify({ scenarios: [{ name, steps }] }, { lineWidth: 0 });
175
+ const summary = { requests: kept.length, skippedAssets: skipped, stubs: stubRoutes, tables, unanswered };
176
+ return { yaml, summary };
177
+ }
178
+ function parse(text, contentType) {
179
+ if (/json/i.test(contentType ?? "")) {
180
+ try {
181
+ return JSON.parse(text);
182
+ }
183
+ catch {
184
+ return text;
185
+ }
186
+ }
187
+ return text;
188
+ }
189
+ /** Dates and UUIDs change from run to run: expect their type instead of their value. */
190
+ function loosen(value) {
191
+ const walk = (v) => {
192
+ if (v === "[date]" || v === "[uuid]")
193
+ return { $type: "string" };
194
+ if (Array.isArray(v))
195
+ return v.map(walk);
196
+ if (v && typeof v === "object")
197
+ return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)]));
198
+ return v;
199
+ };
200
+ return walk(mask(value));
201
+ }
package/dist/runtime.d.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  import { App } from "./app.js";
2
+ import { Issuer } from "./auth.js";
2
3
  import type { ResolvedOptions } from "./config.js";
3
4
  import { Dependency } from "./containers.js";
4
5
  import { Db } from "./db.js";
5
6
  import { HttpClient } from "./http.js";
7
+ import { Mailbox } from "./mail.js";
8
+ import { QueryLog } from "./query-log.js";
6
9
  import { Stub } from "./stub.js";
7
10
  import { type MaskOptions, type Trace } from "./trace.js";
8
11
  export interface ScenarioContext {
@@ -19,6 +22,10 @@ export interface ScenarioContext {
19
22
  * with timestamps and UUIDs masked. `expect(await trace()).toMatchSnapshot()`.
20
23
  */
21
24
  trace: (opts?: MaskOptions) => Promise<Trace>;
25
+ /** Mail the app sent during the scenario. Needs `mail: true` in the config. */
26
+ mail: Mailbox;
27
+ /** The OpenID Connect issuer the app trusts: `auth.token(claims)`. Needs `auth` in the config. */
28
+ auth: Issuer;
22
29
  }
23
30
  /** Everything one test file needs: its own database, stub servers and app process. */
24
31
  export declare class Runtime {
@@ -34,6 +41,9 @@ export declare class Runtime {
34
41
  private readonly recorders;
35
42
  private readonly recordDir?;
36
43
  readonly containers: Map<string, Dependency>;
44
+ readonly mailbox?: Mailbox | undefined;
45
+ readonly issuer?: Issuer | undefined;
46
+ readonly queryLog?: QueryLog | undefined;
37
47
  private constructor();
38
48
  get http(): HttpClient;
39
49
  static start(opts: ResolvedOptions, shared: {
package/dist/runtime.js CHANGED
@@ -2,11 +2,14 @@ import { randomUUID } from "node:crypto";
2
2
  import { writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { App } from "./app.js";
5
+ import { Issuer } from "./auth.js";
5
6
  import { Dependency } from "./containers.js";
6
7
  import { Db, formatChanges } from "./db.js";
7
8
  import { engineFor } from "./drivers/index.js";
8
9
  import { formatHistory, HttpClient } from "./http.js";
10
+ import { Mailbox } from "./mail.js";
9
11
  import { OpenApiSpec } from "./openapi.js";
12
+ import { QueryLog } from "./query-log.js";
10
13
  import { Recorder } from "./recording.js";
11
14
  import { Stub } from "./stub.js";
12
15
  import { buildTrace, mask } from "./trace.js";
@@ -23,12 +26,15 @@ export class Runtime {
23
26
  recorders;
24
27
  recordDir;
25
28
  containers;
29
+ mailbox;
30
+ issuer;
31
+ queryLog;
26
32
  #http;
27
33
  /** Responses from the app that don't match its OpenAPI spec, this scenario. */
28
34
  #contract = [];
29
35
  /** Documented responses seen in this file, for the run's coverage report. */
30
36
  #covered = new Set();
31
- constructor(app, services, db, stubs, opts, vars, specs, coverageDir, recorders = new Map(), recordDir, containers = new Map()) {
37
+ constructor(app, services, db, stubs, opts, vars, specs, coverageDir, recorders = new Map(), recordDir, containers = new Map(), mailbox, issuer, queryLog) {
32
38
  this.app = app;
33
39
  this.services = services;
34
40
  this.db = db;
@@ -40,6 +46,9 @@ export class Runtime {
40
46
  this.recorders = recorders;
41
47
  this.recordDir = recordDir;
42
48
  this.containers = containers;
49
+ this.mailbox = mailbox;
50
+ this.issuer = issuer;
51
+ this.queryLog = queryLog;
43
52
  this.#http = this.#client();
44
53
  }
45
54
  #client() {
@@ -72,6 +81,9 @@ export class Runtime {
72
81
  const containers = new Map();
73
82
  const services = new Map();
74
83
  let db;
84
+ let mailbox;
85
+ let issuer;
86
+ let queryLog;
75
87
  try {
76
88
  // Loaded first: a broken spec should fail before anything is started.
77
89
  const specs = {
@@ -102,6 +114,22 @@ export class Runtime {
102
114
  if (failed)
103
115
  throw failed.reason;
104
116
  const vars = { "db.url": url };
117
+ if (opts.db.queries && engine.name !== "sqlite") {
118
+ const u = new URL(url);
119
+ queryLog = await QueryLog.start(engine.name, { host: u.hostname, port: Number(u.port || (engine.name === "mysql" ? 3306 : 5432)) });
120
+ vars["db.url"] = queryLog.proxyUrl(url);
121
+ db.attachQueryLog(queryLog);
122
+ }
123
+ if (engine.name === "sqlite")
124
+ vars["db.path"] = (await import("./drivers/sqlite.js")).sqlitePath(url);
125
+ if (opts.mail) {
126
+ mailbox = await Mailbox.start();
127
+ Object.assign(vars, { "mail.host": mailbox.host, "mail.port": String(mailbox.port), "mail.url": mailbox.url });
128
+ }
129
+ if (opts.auth) {
130
+ issuer = await Issuer.start(opts.auth);
131
+ Object.assign(vars, { "auth.issuer": issuer.url, "auth.jwks": issuer.jwksUrl, "auth.audience": issuer.audience });
132
+ }
105
133
  for (const [name, c] of containers) {
106
134
  vars[`container.${name}`] = c.address;
107
135
  vars[`container.${name}.host`] = c.host;
@@ -116,17 +144,22 @@ export class Runtime {
116
144
  vars[`service.${name}.port`] = String(started.port);
117
145
  }
118
146
  const app = await App.start(opts.app, opts.root, vars);
119
- return new Runtime(app, services, db, stubs, opts, vars, specs, shared.coverageDir, recorders, shared.recordDir, containers);
147
+ return new Runtime(app, services, db, stubs, opts, vars, specs, shared.coverageDir, recorders, shared.recordDir, containers, mailbox, issuer, queryLog);
120
148
  }
121
149
  catch (e) {
122
150
  await Promise.all([...services.values()].map((s) => s.stop()));
123
151
  await db?.close();
124
152
  await Promise.all([...stubs.values()].map((s) => s.close()));
125
153
  await Promise.allSettled([...containers.values()].map((c) => c.stop()));
154
+ await mailbox?.close();
155
+ await issuer?.close();
156
+ await queryLog?.close();
126
157
  throw e;
127
158
  }
128
159
  }
129
160
  context() {
161
+ const mailbox = this.mailbox;
162
+ const issuer = this.issuer;
130
163
  return {
131
164
  http: this.http,
132
165
  db: this.db,
@@ -151,7 +184,17 @@ export class Runtime {
151
184
  throw new Error(`slicetest: unknown container "${name}". Declared containers: ${[...this.containers.keys()].join(", ") || "(none)"}`);
152
185
  return c;
153
186
  },
154
- trace: async (opts) => mask(buildTrace(this.http.history, this.stubs.values(), await this.db.changesSinceStart()), opts),
187
+ trace: async (opts) => mask(buildTrace(this.http.history, this.stubs.values(), await this.db.changesSinceStart(), this.mailbox), opts),
188
+ get mail() {
189
+ if (!mailbox)
190
+ throw new Error("slicetest: mail is off. Add `mail: true` to the config and point the app's SMTP settings at {{mail.host}} / {{mail.port}}.");
191
+ return mailbox;
192
+ },
193
+ get auth() {
194
+ if (!issuer)
195
+ throw new Error("slicetest: auth is off. Add `auth: true` to the config and point the app's JWT settings at {{auth.issuer}} / {{auth.jwks}}.");
196
+ return issuer;
197
+ },
155
198
  };
156
199
  }
157
200
  /** The app and every service, for checks that apply to all of them. */
@@ -177,6 +220,9 @@ export class Runtime {
177
220
  stub.reset();
178
221
  for (const recorder of this.recorders.values())
179
222
  recorder.reset();
223
+ this.mailbox?.reset();
224
+ this.issuer?.reset();
225
+ this.queryLog?.reset();
180
226
  await Promise.all([...this.containers.values()].map((c) => c.reset()));
181
227
  this.http.reset();
182
228
  for (const p of this.#processes())
@@ -247,6 +293,9 @@ export class Runtime {
247
293
  const unmatched = this.#unmatched();
248
294
  if (unmatched.length > 0)
249
295
  sections.push(`stub calls with no matching route:\n${unmatched.join("\n")}`);
296
+ const chaos = [...this.stubs.values()].map((s) => s.describeChaos()).filter((c) => c !== undefined);
297
+ if (chaos.length > 0)
298
+ sections.push(chaos.join("\n"));
250
299
  const contract = this.#contractViolations();
251
300
  if (contract.length > 0)
252
301
  sections.push(`OpenAPI mismatches:\n${contract.map((c) => ` ${c}`).join("\n")}`);
@@ -259,6 +308,13 @@ export class Runtime {
259
308
  catch (e) {
260
309
  sections.push(`database changes during this scenario: unavailable (${e.message})`);
261
310
  }
311
+ if (this.mailbox)
312
+ sections.push(this.mailbox.describe());
313
+ if (this.queryLog) {
314
+ const q = this.queryLog.queries();
315
+ const top = q.shapes().slice(0, 5).map((s) => ` ${s.count > 1 ? `×${s.count} ` : ""}${s.sql.length > 160 ? `${s.sql.slice(0, 157)}...` : s.sql}`);
316
+ sections.push(q.length ? `SQL the app ran during this scenario (${q.length} statements, most frequent first):\n${top.join("\n")}` : "SQL the app ran during this scenario: (none)");
317
+ }
262
318
  const logs = this.app.scenarioLogs();
263
319
  sections.push(logs ? `app output during this scenario:\n${logs}` : "app output during this scenario: (none)");
264
320
  for (const [name, service] of this.services) {
@@ -282,6 +338,9 @@ export class Runtime {
282
338
  this.db.close(),
283
339
  ...[...this.stubs.values()].map((s) => s.close()),
284
340
  ...[...this.containers.values()].map((c) => c.stop()),
341
+ this.mailbox?.close(),
342
+ this.issuer?.close(),
343
+ this.queryLog?.close(),
285
344
  ]);
286
345
  const failed = results.find((r) => r.status === "rejected");
287
346
  if (failed)
package/dist/stub.d.ts CHANGED
@@ -19,6 +19,27 @@ export interface RecordedCall {
19
19
  };
20
20
  /** Answered by the fallback (e.g. an example from the provider's OpenAPI spec), not a registered route. */
21
21
  fallback?: boolean;
22
+ /** The fault `chaos()` injected instead of the normal answer: `503`, `reset`. */
23
+ fault?: string;
24
+ }
25
+ /**
26
+ * Faults injected into a stub's answers, to test the app's retries, timeouts and
27
+ * fallbacks. Random faults come from a seeded generator, so a failing run can be
28
+ * replayed with the seed printed in the failure output.
29
+ */
30
+ export interface ChaosOptions {
31
+ /** Fail the first `n` calls, then answer normally: the shape of a retry test. */
32
+ failFirst?: number;
33
+ /** Share of calls (0–1) answered with one of `statuses`. */
34
+ errorRate?: number;
35
+ /** Error statuses to pick from. Default [500, 502, 503]; 429 and 503 come with `Retry-After: 1`. */
36
+ statuses?: number[];
37
+ /** Share of calls (0–1) whose connection is dropped without an answer. */
38
+ networkErrorRate?: number;
39
+ /** Extra delay for every call, in ms: a fixed value or a [min, max] range. */
40
+ latency?: number | [number, number];
41
+ /** Seed for the random choices. Default: `SLICETEST_CHAOS_SEED`, else random. */
42
+ seed?: number;
22
43
  }
23
44
  export interface StubResponse {
24
45
  status?: number;
@@ -70,6 +91,17 @@ export declare class Stub {
70
91
  * `call.params`) or be a RegExp; `method` may be `*`. Later routes win.
71
92
  */
72
93
  on(method: string, path: string | RegExp, match?: MatchOptions): RouteBuilder;
94
+ /**
95
+ * Inject faults into this stub's answers for the rest of the scenario:
96
+ * `stub("payments").chaos({ failFirst: 2 })` to test a retry,
97
+ * `chaos({ errorRate: 0.3, latency: [50, 200] })` to test resilience.
98
+ * Calls that fault don't use up `once()` / `times()` routes.
99
+ */
100
+ chaos(opts: ChaosOptions): this;
101
+ /** Calls `chaos()` answered with a fault. */
102
+ faults(): RecordedCall[];
103
+ /** The active `chaos()` settings and what they did, for failure output; undefined when off. */
104
+ describeChaos(): string | undefined;
73
105
  /** Calls received so far, optionally filtered by method, path and conditions (same syntax as `on()`). */
74
106
  calls(method?: string, path?: string | RegExp, match?: MatchOptions): RecordedCall[];
75
107
  unmatched(): RecordedCall[];
package/dist/stub.js CHANGED
@@ -12,6 +12,7 @@ export class Stub {
12
12
  #fallback;
13
13
  /** Appended to the 501 answer for a call nothing could answer. */
14
14
  #hint;
15
+ #chaos;
15
16
  url = "";
16
17
  constructor(name) {
17
18
  this.name = name;
@@ -61,6 +62,74 @@ export class Stub {
61
62
  };
62
63
  return builder;
63
64
  }
65
+ /**
66
+ * Inject faults into this stub's answers for the rest of the scenario:
67
+ * `stub("payments").chaos({ failFirst: 2 })` to test a retry,
68
+ * `chaos({ errorRate: 0.3, latency: [50, 200] })` to test resilience.
69
+ * Calls that fault don't use up `once()` / `times()` routes.
70
+ */
71
+ chaos(opts) {
72
+ for (const key of ["errorRate", "networkErrorRate"]) {
73
+ const v = opts[key];
74
+ if (v !== undefined && !(v >= 0 && v <= 1))
75
+ throw new Error(`slicetest: chaos ${key} must be between 0 and 1, got ${v}`);
76
+ }
77
+ if (opts.statuses && (opts.statuses.length === 0 || opts.statuses.some((s) => !Number.isInteger(s) || s < 400 || s > 599))) {
78
+ throw new Error(`slicetest: chaos statuses must be 4xx/5xx codes, got ${JSON.stringify(opts.statuses)}`);
79
+ }
80
+ const envSeed = Number(process.env.SLICETEST_CHAOS_SEED);
81
+ const seed = opts.seed ?? (Number.isInteger(envSeed) ? envSeed : Math.floor(Math.random() * 2 ** 31));
82
+ this.#chaos = { opts, seed, random: mulberry32(seed), calls: 0 };
83
+ return this;
84
+ }
85
+ /** Calls `chaos()` answered with a fault. */
86
+ faults() {
87
+ return this.#calls.filter((c) => c.fault !== undefined);
88
+ }
89
+ /** The active `chaos()` settings and what they did, for failure output; undefined when off. */
90
+ describeChaos() {
91
+ const c = this.#chaos;
92
+ if (!c)
93
+ return undefined;
94
+ const settings = Object.entries(c.opts)
95
+ .filter(([k]) => k !== "seed")
96
+ .map(([k, v]) => `${k} ${JSON.stringify(v)}`)
97
+ .join(", ");
98
+ return `chaos on ${this.name}: ${settings}; ${this.faults().length} of ${c.calls} calls faulted. Replay with SLICETEST_CHAOS_SEED=${c.seed}`;
99
+ }
100
+ /** Delay and fault for the next call, consuming the random sequence in a fixed order. */
101
+ async #injectFault() {
102
+ const c = this.#chaos;
103
+ if (!c)
104
+ return undefined;
105
+ const { opts, random } = c;
106
+ const n = ++c.calls;
107
+ const lat = opts.latency;
108
+ const ms = lat === undefined ? 0 : Array.isArray(lat) ? lat[0] + Math.floor(random() * (lat[1] - lat[0] + 1)) : lat;
109
+ if (ms > 0)
110
+ await new Promise((r) => setTimeout(r, ms));
111
+ const statuses = opts.statuses ?? [500, 502, 503];
112
+ const pick = () => statuses[Math.floor(random() * statuses.length)];
113
+ if (opts.failFirst !== undefined && n <= opts.failFirst)
114
+ return { status: pick() };
115
+ const roll = random();
116
+ if (roll < (opts.networkErrorRate ?? 0))
117
+ return "reset";
118
+ if (roll < (opts.networkErrorRate ?? 0) + (opts.errorRate ?? 0))
119
+ return { status: pick() };
120
+ return undefined;
121
+ }
122
+ #fail(call, req, res, fault) {
123
+ call.matched = true;
124
+ if (fault === "reset") {
125
+ call.fault = "reset";
126
+ req.socket.destroy();
127
+ return;
128
+ }
129
+ call.fault = String(fault.status);
130
+ const retry = fault.status === 429 || fault.status === 503 ? { "retry-after": "1" } : undefined;
131
+ this.#send(call, res, { status: fault.status, headers: retry, body: { error: "slicetest chaos", status: fault.status } });
132
+ }
64
133
  /** Calls received so far, optionally filtered by method, path and conditions (same syntax as `on()`). */
65
134
  calls(method, path, match = {}) {
66
135
  const compiled = path === undefined ? undefined : compilePath(path);
@@ -82,6 +151,7 @@ export class Stub {
82
151
  reset() {
83
152
  this.#routes = [];
84
153
  this.#calls = [];
154
+ this.#chaos = undefined;
85
155
  }
86
156
  /**
87
157
  * Answer calls that no registered route matches, instead of failing with 501.
@@ -127,6 +197,12 @@ export class Stub {
127
197
  route = r;
128
198
  break;
129
199
  }
200
+ // Faults only replace answers the stub would give: an unknown route still fails as unmatched.
201
+ if (route || this.#fallback) {
202
+ const fault = await this.#injectFault();
203
+ if (fault)
204
+ return this.#fail(call, req, res, fault);
205
+ }
130
206
  if (!route) {
131
207
  let out;
132
208
  try {
@@ -262,3 +338,14 @@ function parseJson(body) {
262
338
  return undefined;
263
339
  }
264
340
  }
341
+ /** A small seeded PRNG: the same seed gives the same faults on every run. */
342
+ function mulberry32(seed) {
343
+ let a = seed >>> 0;
344
+ return () => {
345
+ a = (a + 0x6d2b79f5) >>> 0;
346
+ let t = a;
347
+ t = Math.imul(t ^ (t >>> 15), t | 1);
348
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
349
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
350
+ };
351
+ }
package/dist/trace.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Changes } from "./db.js";
2
2
  import type { HttpResponse } from "./http.js";
3
+ import type { Mailbox } from "./mail.js";
3
4
  import type { Stub } from "./stub.js";
4
5
  /**
5
6
  * Everything one scenario did at the three boundaries: the requests it made to
@@ -19,6 +20,13 @@ export interface Trace {
19
20
  status?: number;
20
21
  }[]>;
21
22
  db: Changes;
23
+ /** Mail the app sent, when the `mail` option is on. */
24
+ mail?: {
25
+ from: string;
26
+ to: string[];
27
+ subject: string;
28
+ text: string;
29
+ }[];
22
30
  }
23
31
  export interface MaskOptions {
24
32
  /** Keys whose values are always replaced, e.g. `["token", "password_hash"]`. Matched at any depth. */
@@ -32,4 +40,4 @@ export interface MaskOptions {
32
40
  * `[uuid]`, and the values of `keys` / strings matching `patterns` by `[masked]`.
33
41
  */
34
42
  export declare function mask<T>(value: T, opts?: MaskOptions): T;
35
- export declare function buildTrace(history: readonly HttpResponse[], stubs: Iterable<Stub>, db: Changes): Trace;
43
+ export declare function buildTrace(history: readonly HttpResponse[], stubs: Iterable<Stub>, db: Changes, mailbox?: Mailbox): Trace;
package/dist/trace.js CHANGED
@@ -30,7 +30,7 @@ export function mask(value, opts = {}) {
30
30
  };
31
31
  return walk(value);
32
32
  }
33
- export function buildTrace(history, stubs, db) {
33
+ export function buildTrace(history, stubs, db, mailbox) {
34
34
  const trace = {
35
35
  http: history.map((r) => {
36
36
  const url = new URL(r.url, "http://app");
@@ -53,5 +53,7 @@ export function buildTrace(history, stubs, db) {
53
53
  };
54
54
  });
55
55
  }
56
+ if (mailbox)
57
+ trace.mail = mailbox.messages().map((m) => ({ from: m.from, to: m.to, subject: m.subject, text: m.text }));
56
58
  return trace;
57
59
  }
package/dist/vitest.js CHANGED
@@ -2,6 +2,7 @@ import path from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
3
  import { configDefaults } from "vitest/config";
4
4
  import { resolveOptions } from "./config.js";
5
+ import { SESSION_ENV } from "./record.js";
5
6
  import { parseScenarioFile } from "./yaml.js";
6
7
  /** YAML scenario files are picked up next to the regular test files. */
7
8
  export const YAML_SCENARIOS = "**/*.scenario.{yaml,yml}";
@@ -17,7 +18,9 @@ export function slicetest(options) {
17
18
  const root = path.resolve(config.root ?? process.cwd());
18
19
  // Mutated rather than returned: a returned list would replace Vitest's default include instead of extending it.
19
20
  const test = (config.test ??= {});
20
- test.include = [...(test.include ?? configDefaults.include), YAML_SCENARIOS];
21
+ // `slicetest record` runs only its session file.
22
+ if (!process.env[SESSION_ENV])
23
+ test.include = [...(test.include ?? configDefaults.include), YAML_SCENARIOS];
21
24
  return {
22
25
  test: {
23
26
  globalSetup: [path.join(here, `global-setup${ext}`)],
@@ -31,7 +34,7 @@ export function slicetest(options) {
31
34
  const file = id.split("?")[0];
32
35
  if (!YAML_ID.test(file))
33
36
  return;
34
- const doc = parseScenarioFile(code, path.relative(root ?? process.cwd(), file) || file);
37
+ const doc = { ...parseScenarioFile(code, path.relative(root ?? process.cwd(), file) || file), path: file };
35
38
  const runtime = JSON.stringify(path.join(here, `yaml-runtime${ext}`));
36
39
  return { code: `import { defineYamlScenarios } from ${runtime};\ndefineYamlScenarios(${JSON.stringify(doc)});\n`, map: null };
37
40
  },
@@ -0,0 +1,40 @@
1
+ /** A custom HMAC scheme: `header: prefix + hmac(secret, body)`. */
2
+ export interface HmacScheme {
3
+ header: string;
4
+ /** Default `sha256`. */
5
+ algorithm?: string;
6
+ /** Default `hex`. */
7
+ encoding?: "hex" | "base64";
8
+ /** Prepended to the digest, e.g. `sha256=`. */
9
+ prefix?: string;
10
+ }
11
+ export type WebhookProvider = "stripe" | "github" | "slack" | "shopify" | "standard" | HmacScheme;
12
+ export interface WebhookOptions {
13
+ provider: WebhookProvider;
14
+ /** The signing secret the app is configured with. For `standard` (Svix), `whsec_<base64>` or the raw key. */
15
+ secret: string;
16
+ /** Event type, sent where the provider puts it: `X-GitHub-Event`, `X-Shopify-Topic`. */
17
+ event?: string;
18
+ /** Unix seconds the signature is made for. Default: now. */
19
+ timestamp?: number;
20
+ /** Signed ten minutes ago: an app that rejects replays must refuse it. */
21
+ stale?: boolean;
22
+ /** Signed with a different secret: the app must refuse it. */
23
+ invalidSignature?: boolean;
24
+ /** Message id for `standard` (`webhook-id`). Default: a random `msg_…`. */
25
+ id?: string;
26
+ /** Extra request headers. */
27
+ headers?: Record<string, string>;
28
+ }
29
+ /**
30
+ * The headers a provider sends with `body`, signed with `secret` the way its
31
+ * SDK verifies them: Stripe's `Stripe-Signature`, GitHub's
32
+ * `X-Hub-Signature-256`, Slack's `v0` signature, Shopify's base64 HMAC and
33
+ * Standard Webhooks (Svix, Resend, Clerk, …).
34
+ */
35
+ export declare function signWebhook(body: string, opts: WebhookOptions): Record<string, string>;
36
+ /** The bytes to send and their content type: objects as JSON, URLSearchParams as a form (Slack commands), strings as-is. */
37
+ export declare function webhookBody(payload: unknown): {
38
+ body: string;
39
+ type: string;
40
+ };