slicetest 0.7.0 → 0.9.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/dist/scenario.js CHANGED
@@ -6,25 +6,41 @@ const slot = globalThis;
6
6
  export function setRuntime(runtime) {
7
7
  slot[KEY] = runtime;
8
8
  }
9
+ /**
10
+ * Whether a scenario with `tags` runs under `filter` (`SLICETEST_TAGS`): comma- or space-separated tags,
11
+ * any of which it must have, and `!tag`s it must not have. No filter runs everything.
12
+ */
13
+ export function tagsSelected(tags = [], filter = process.env.SLICETEST_TAGS) {
14
+ const terms = (filter ?? "").split(/[\s,]+/).filter(Boolean);
15
+ const excluded = terms.filter((t) => t.startsWith("!")).map((t) => t.slice(1));
16
+ const wanted = terms.filter((t) => !t.startsWith("!"));
17
+ if (tags.some((t) => excluded.includes(t)))
18
+ return false;
19
+ return wanted.length === 0 || tags.some((t) => wanted.includes(t));
20
+ }
9
21
  function define(register) {
10
- return (name, body, timeout) => register(name, async ({ onTestFailed, task }) => {
11
- const runtime = slot[KEY];
12
- if (!runtime) {
13
- throw new Error("slicetest: runtime not started. Add the slicetest() plugin to your vitest config.");
14
- }
15
- if (task.concurrent) {
16
- throw new Error("slicetest: scenarios share one app and database per file, so they can't run concurrently. Remove .concurrent / sequence.concurrent.");
17
- }
18
- const file = task.file?.filepath ?? task.file?.name ?? "";
19
- onTestFailed(async () => {
20
- console.error(`--- slicetest ---\n${await runtime.diagnostics()}\n-----------------`);
21
- await runtime.reportDiagram(file, task.name, true);
22
- });
23
- await runtime.beforeScenario();
24
- await body(runtime.context());
25
- await runtime.afterScenario();
26
- await runtime.reportDiagram(file, task.name, false);
27
- }, timeout);
22
+ return (name, body, options) => {
23
+ const { timeout, tags } = typeof options === "number" ? { timeout: options, tags: undefined } : (options ?? {});
24
+ // Scenarios the tag filter leaves out show as skipped, so the filter is visible in the summary.
25
+ return (tagsSelected(tags) ? register : test.skip)(name, async ({ onTestFailed, task }) => {
26
+ const runtime = slot[KEY];
27
+ if (!runtime) {
28
+ throw new Error("slicetest: runtime not started. Add the slicetest() plugin to your vitest config.");
29
+ }
30
+ if (task.concurrent) {
31
+ throw new Error("slicetest: scenarios share one app and database per file, so they can't run concurrently. Remove .concurrent / sequence.concurrent.");
32
+ }
33
+ const file = task.file?.filepath ?? task.file?.name ?? "";
34
+ onTestFailed(async () => {
35
+ console.error(`--- slicetest ---\n${await runtime.diagnostics()}\n-----------------`);
36
+ await runtime.reportDiagram(file, task.name, true);
37
+ });
38
+ await runtime.beforeScenario();
39
+ await body(runtime.context());
40
+ await runtime.afterScenario();
41
+ await runtime.reportDiagram(file, task.name, false);
42
+ }, timeout);
43
+ };
28
44
  }
29
45
  /**
30
46
  * A test that runs against the real app. The database is reset to the
@@ -36,10 +52,10 @@ export const scenario = Object.assign(define(test), {
36
52
  todo: (name) => test.todo(name),
37
53
  /** Same scenario for each row: `scenario.each(rows)("name %s", async (row, ctx) => ...)`. */
38
54
  each(rows) {
39
- return (name, body, timeout) => {
55
+ return (name, body, options) => {
40
56
  rows.forEach((row, i) => {
41
57
  const title = format(name, row, i);
42
- define(test)(title, (ctx) => body(row, ctx), timeout);
58
+ define(test)(title, (ctx) => body(row, ctx), options);
43
59
  });
44
60
  };
45
61
  },
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Files `migrate: { sql }` applies, in order: the file itself, or a directory's `.sql` files (and
3
+ * Diesel's `<version>_<name>/up.sql`) sorted by name with numbers compared as numbers. Rollback
4
+ * files are left out: golang-migrate / sqlx `*.down.sql`, Diesel's `down.sql` and Flyway undo
5
+ * migrations (`U2__…`).
6
+ */
7
+ export declare function sqlMigrationFiles(target: string): Promise<string[]>;
8
+ /** The statements of a migration file that migrate up: without a goose or dbmate down section. */
9
+ export declare function upSection(sql: string): string;
10
+ /** Apply `files` in order with `exec`, naming the file that fails. */
11
+ export declare function applySqlFiles(files: string[], exec: (sql: string) => Promise<unknown>, root: string): Promise<void>;
@@ -0,0 +1,38 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readdir, readFile, stat } from "node:fs/promises";
3
+ import path from "node:path";
4
+ /** Version numbers compare as numbers: Flyway's `V2__` comes before `V10__`. */
5
+ const byVersion = new Intl.Collator("en", { numeric: true }).compare;
6
+ /**
7
+ * Files `migrate: { sql }` applies, in order: the file itself, or a directory's `.sql` files (and
8
+ * Diesel's `<version>_<name>/up.sql`) sorted by name with numbers compared as numbers. Rollback
9
+ * files are left out: golang-migrate / sqlx `*.down.sql`, Diesel's `down.sql` and Flyway undo
10
+ * migrations (`U2__…`).
11
+ */
12
+ export async function sqlMigrationFiles(target) {
13
+ if (!(await stat(target)).isDirectory())
14
+ return [target];
15
+ const entries = await readdir(target, { withFileTypes: true });
16
+ const files = entries.filter((e) => e.isFile() && e.name.toLowerCase().endsWith(".sql") && !/\.down\.sql$/i.test(e.name) && !/^U\d+(\.\d+)*__/.test(e.name)).map((e) => e.name);
17
+ // Diesel: one directory per migration, holding up.sql and down.sql.
18
+ const dirs = entries.filter((e) => e.isDirectory() && existsSync(path.join(target, e.name, "up.sql"))).map((e) => path.join(e.name, "up.sql"));
19
+ return [...files, ...dirs].sort(byVersion).map((f) => path.join(target, f));
20
+ }
21
+ /** Where a migration's rollback section starts in goose (`-- +goose Down`) and dbmate (`-- migrate:down`) files. */
22
+ const DOWN = /^--\s*(\+goose\s+down|migrate:down)\b/im;
23
+ /** The statements of a migration file that migrate up: without a goose or dbmate down section. */
24
+ export function upSection(sql) {
25
+ const m = DOWN.exec(sql);
26
+ return m ? sql.slice(0, m.index) : sql;
27
+ }
28
+ /** Apply `files` in order with `exec`, naming the file that fails. */
29
+ export async function applySqlFiles(files, exec, root) {
30
+ for (const file of files) {
31
+ try {
32
+ await exec(upSection(await readFile(file, "utf8")));
33
+ }
34
+ catch (e) {
35
+ throw new Error(`slicetest: db.migrate.sql: ${path.relative(root, file) || file}: ${e.message}`, { cause: e });
36
+ }
37
+ }
38
+ }
package/dist/stub.d.ts CHANGED
@@ -8,6 +8,13 @@ export interface RecordedCall {
8
8
  body: string;
9
9
  /** Parsed JSON body, or undefined when the body isn't JSON. */
10
10
  json: any;
11
+ /**
12
+ * Fields of an `application/x-www-form-urlencoded` body (Stripe, Twilio, OAuth token requests),
13
+ * with bracket keys nested as the providers read them: `metadata[order]=7&items[0][price]=p_1`
14
+ * is `{ metadata: { order: "7" }, items: [{ price: "p_1" }] }`. For `multipart/form-data`, the
15
+ * fields with files as `{ filename, type, size, text }`. Undefined for other bodies.
16
+ */
17
+ form?: Record<string, unknown>;
11
18
  /** Values captured by `:name` segments of the matching route's path. */
12
19
  params: Record<string, string>;
13
20
  /** Whether a registered route answered this call. */
@@ -47,7 +54,7 @@ export interface ChaosOptions {
47
54
  export interface StubResponse {
48
55
  status?: number;
49
56
  headers?: Record<string, string>;
50
- /** Objects are sent as JSON. */
57
+ /** Strings and bytes (`Uint8Array`, `ArrayBuffer`) are sent as they are; anything else as JSON. */
51
58
  body?: unknown;
52
59
  }
53
60
  /** One server-sent event: `[event, data]`, or `{ event, data, id }`. Data that isn't a string is sent as JSON. */
@@ -68,9 +75,12 @@ export type Responder = StubResponse | ((call: RecordedCall) => StubResponse | P
68
75
  * `expect.*` asymmetric matchers receive the actual value.
69
76
  */
70
77
  export interface MatchOptions {
71
- query?: Record<string, Matcher>;
78
+ /** A list matches a repeated parameter's values in order: `{ ids: ["1", "2"] }` for `?ids=1&ids=2`. */
79
+ query?: Record<string, Matcher | Matcher[]>;
72
80
  headers?: Record<string, Matcher>;
73
81
  json?: unknown;
82
+ /** Subset of a form-encoded body's fields (`call.form`). Numbers and booleans compare as the strings sent. */
83
+ form?: Record<string, unknown>;
74
84
  body?: Matcher;
75
85
  /** A GraphQL request for this operation (its name, or a RegExp), with `variables` as a subset. */
76
86
  graphql?: {
@@ -128,6 +138,7 @@ export declare class Stub {
128
138
  /**
129
139
  * Answer `method path`. `path` may contain `:name` segments (captured into
130
140
  * `call.params`) or be a RegExp; `method` may be `*`. Later routes win.
141
+ * A query in `path` (`/search?q=tea`) is a condition on those parameters; others may come along.
131
142
  */
132
143
  on(method: string, path: string | RegExp, match?: MatchOptions): RouteBuilder;
133
144
  /**
@@ -151,7 +162,12 @@ export declare class Stub {
151
162
  /** The active `chaos()` settings and what they did, for failure output; undefined when off. */
152
163
  describeChaos(): string | undefined;
153
164
  /** Calls received so far, optionally filtered by method, path and conditions (same syntax as `on()`). */
154
- calls(method?: string, path?: string | RegExp, match?: MatchOptions): RecordedCall[];
165
+ calls(method?: string, fullPath?: string | RegExp, fullMatch?: MatchOptions): RecordedCall[];
166
+ /** Calls whose reply function or fallback threw, with the error; the app got a 500 (502 for a fallback). */
167
+ handlerErrors(): {
168
+ call: RecordedCall;
169
+ error: unknown;
170
+ }[];
155
171
  unmatched(): RecordedCall[];
156
172
  /** Routes registered in this scenario that no call reached, except `optional()` ones. */
157
173
  unusedRoutes(): string[];
@@ -173,4 +189,17 @@ export declare class Stub {
173
189
  }
174
190
  /** `expected` is contained in `actual`: objects compare key by key, arrays element-wise. */
175
191
  export declare function subset(expected: unknown, actual: unknown): boolean;
192
+ /**
193
+ * A form body as nested fields, the way Rack, PHP and Stripe read bracket keys: `a[b]=1` is
194
+ * `{ a: { b: "1" } }`, `a[]=1&a[]=2` and `a[0]=1&a[1]=2` are arrays, and a plain key sent
195
+ * twice (`to=1&to=2`) becomes an array too.
196
+ */
197
+ export declare function parseForm(body: string): Record<string, unknown>;
198
+ /** A file in a multipart body, as `call.form` shows it. `text` only for text, JSON, XML and CSV files. */
199
+ export interface UploadedFile {
200
+ filename: string;
201
+ type: string;
202
+ size: number;
203
+ text?: string;
204
+ }
176
205
  export {};
package/dist/stub.js CHANGED
@@ -30,6 +30,8 @@ export class Stub {
30
30
  /** Appended to the 501 answer for a call nothing could answer. */
31
31
  #hint;
32
32
  #chaos;
33
+ /** Reply functions (and fallbacks) that threw: the app got a 500 / 502, the scenario must fail with the error. */
34
+ #errors = [];
33
35
  url = "";
34
36
  constructor(name) {
35
37
  this.name = name;
@@ -54,6 +56,7 @@ export class Stub {
54
56
  /**
55
57
  * Answer `method path`. `path` may contain `:name` segments (captured into
56
58
  * `call.params`) or be a RegExp; `method` may be `*`. Later routes win.
59
+ * A query in `path` (`/search?q=tea`) is a condition on those parameters; others may come along.
57
60
  */
58
61
  on(method, path, match = {}) {
59
62
  return this.#on(method, path, match);
@@ -77,7 +80,8 @@ export class Stub {
77
80
  };
78
81
  return gql;
79
82
  }
80
- #on(method, path, match, label) {
83
+ #on(method, fullPath, fullMatch, label) {
84
+ const { path, match } = splitQuery(fullPath, fullMatch);
81
85
  const { pattern, paramNames } = compilePath(path);
82
86
  const route = {
83
87
  method: method.toUpperCase(),
@@ -179,12 +183,17 @@ export class Stub {
179
183
  this.#send(call, res, { status: fault.status, headers: retry, body: { error: "slicetest chaos", status: fault.status } });
180
184
  }
181
185
  /** Calls received so far, optionally filtered by method, path and conditions (same syntax as `on()`). */
182
- calls(method, path, match = {}) {
186
+ calls(method, fullPath, fullMatch = {}) {
187
+ const { path, match } = fullPath === undefined ? { path: undefined, match: fullMatch } : splitQuery(fullPath, fullMatch);
183
188
  const compiled = path === undefined ? undefined : compilePath(path);
184
189
  return this.#calls.filter((c) => (!method || method === "*" || c.method === method.toUpperCase()) &&
185
190
  (!compiled || matchPath(path, compiled.pattern, c.path) !== undefined) &&
186
191
  matchConditions(match, c));
187
192
  }
193
+ /** Calls whose reply function or fallback threw, with the error; the app got a 500 (502 for a fallback). */
194
+ handlerErrors() {
195
+ return this.#errors;
196
+ }
188
197
  unmatched() {
189
198
  return this.#calls.filter((c) => !c.matched);
190
199
  }
@@ -239,6 +248,7 @@ export class Stub {
239
248
  this.#routes = [];
240
249
  this.#calls = [];
241
250
  this.#chaos = undefined;
251
+ this.#errors = [];
242
252
  }
243
253
  /**
244
254
  * Answer calls that no registered route matches, instead of failing with 501.
@@ -259,7 +269,9 @@ export class Stub {
259
269
  const chunks = [];
260
270
  for await (const chunk of req)
261
271
  chunks.push(chunk);
262
- const body = Buffer.concat(chunks).toString("utf8");
272
+ const raw = Buffer.concat(chunks);
273
+ const body = raw.toString("utf8");
274
+ const contentType = req.headers["content-type"] ?? "";
263
275
  const url = new URL(req.url ?? "/", this.url);
264
276
  const call = {
265
277
  method: req.method ?? "GET",
@@ -268,6 +280,7 @@ export class Stub {
268
280
  headers: req.headers,
269
281
  body,
270
282
  json: parseJson(body),
283
+ form: isForm(contentType) ? parseForm(body) : /^multipart\/form-data/i.test(contentType) ? await parseMultipart(raw, contentType) : undefined,
271
284
  params: {},
272
285
  matched: false,
273
286
  };
@@ -300,6 +313,7 @@ export class Stub {
300
313
  out = await this.#fallback?.(call);
301
314
  }
302
315
  catch (e) {
316
+ this.#errors.push({ call, error: e });
303
317
  res.writeHead(502, { "content-type": "text/plain" }).end(e.message);
304
318
  return;
305
319
  }
@@ -327,10 +341,15 @@ export class Stub {
327
341
  this.#send(call, res, typeof route.respond === "function" ? await route.respond(call) : route.respond);
328
342
  }
329
343
  catch (e) {
344
+ this.#errors.push({ call, error: e });
330
345
  res.writeHead(500).end(`slicetest: stub handler threw: ${e}`);
331
346
  }
332
347
  }
333
348
  #send(call, res, out) {
349
+ if (out.body instanceof ArrayBuffer || (ArrayBuffer.isView(out.body) && !(out.body instanceof Uint8Array))) {
350
+ const view = out.body;
351
+ out = { ...out, body: view instanceof ArrayBuffer ? new Uint8Array(view) : new Uint8Array(view.buffer, view.byteOffset, view.byteLength) };
352
+ }
334
353
  const raw = out.body === undefined || typeof out.body === "string" || out.body instanceof Uint8Array;
335
354
  const headers = { ...out.headers };
336
355
  if (!raw && !Object.keys(headers).some((h) => h.toLowerCase() === "content-type")) {
@@ -346,6 +365,15 @@ export class Stub {
346
365
  res.end(payload);
347
366
  }
348
367
  }
368
+ /** `/search?q=tea` is the path `/search` with the query condition `q: "tea"`; `match.query` wins over the path's. */
369
+ function splitQuery(path, match) {
370
+ if (typeof path !== "string" || !path.includes("?"))
371
+ return { path, match };
372
+ const [bare, search] = [path.slice(0, path.indexOf("?")), path.slice(path.indexOf("?") + 1)];
373
+ const params = new URLSearchParams(search);
374
+ const query = Object.fromEntries([...new Set(params.keys())].map((k) => [k, params.getAll(k).length > 1 ? params.getAll(k) : params.get(k)]));
375
+ return { path: bare || "/", match: { ...match, query: { ...query, ...match.query } } };
376
+ }
349
377
  function compilePath(path) {
350
378
  if (typeof path !== "string" || !path.includes("/:"))
351
379
  return { pattern: undefined, paramNames: [] };
@@ -371,12 +399,21 @@ function matchPath(path, pattern, actual, names = []) {
371
399
  if (!m)
372
400
  return undefined;
373
401
  const params = { ...m.groups };
374
- names.forEach((n, i) => (params[n] = decodeURIComponent(m[i + 1])));
402
+ names.forEach((n, i) => (params[n] = decodeSegment(m[i + 1])));
375
403
  return params;
376
404
  }
405
+ /** A path segment decoded, or as sent when it isn't valid percent-encoding (`%zz`), instead of dropping the call. */
406
+ function decodeSegment(segment) {
407
+ try {
408
+ return decodeURIComponent(segment);
409
+ }
410
+ catch {
411
+ return segment;
412
+ }
413
+ }
377
414
  function matchConditions(match, call) {
378
415
  for (const [k, m] of Object.entries(match.query ?? {})) {
379
- if (!test(m, call.query.get(k) ?? undefined))
416
+ if (!testQuery(m, call.query, k))
380
417
  return false;
381
418
  }
382
419
  for (const [k, m] of Object.entries(match.headers ?? {})) {
@@ -388,6 +425,8 @@ function matchConditions(match, call) {
388
425
  return false;
389
426
  if (match.json !== undefined && !subset(match.json, call.json))
390
427
  return false;
428
+ if (match.form !== undefined && !subset(formExpectation(match.form, call.form), call.form))
429
+ return false;
391
430
  if (match.graphql) {
392
431
  const g = call.graphql;
393
432
  if (!g)
@@ -403,9 +442,11 @@ function matchConditions(match, call) {
403
442
  /** The first condition of `match` that `call` fails, described; undefined when it meets them all. */
404
443
  function conditionMismatch(match, call) {
405
444
  for (const [k, m] of Object.entries(match.query ?? {})) {
406
- const v = call.query.get(k) ?? undefined;
407
- if (!test(m, v))
408
- return `query ${k}: expected ${show(m)}, got ${v === undefined ? "nothing" : JSON.stringify(v)}`;
445
+ if (testQuery(m, call.query, k))
446
+ continue;
447
+ const all = call.query.getAll(k);
448
+ const got = all.length === 0 ? "nothing" : Array.isArray(m) || all.length > 1 ? JSON.stringify(all) : JSON.stringify(all[0]);
449
+ return `query ${k}: expected ${Array.isArray(m) ? `[${m.map(show).join(", ")}]` : show(m)}, got ${got}`;
409
450
  }
410
451
  for (const [k, m] of Object.entries(match.headers ?? {})) {
411
452
  const raw = call.headers[k.toLowerCase()];
@@ -422,6 +463,13 @@ function conditionMismatch(match, call) {
422
463
  if (diff)
423
464
  return diff;
424
465
  }
466
+ if (match.form !== undefined) {
467
+ if (call.form === undefined)
468
+ return `form: expected a form-encoded body, got ${call.body ? `${call.headers["content-type"] ?? "no content type"}: ${JSON.stringify(call.body.slice(0, 100))}` : "an empty body"}`;
469
+ const diff = difference(formExpectation(match.form, call.form), call.form, "form");
470
+ if (diff)
471
+ return diff;
472
+ }
425
473
  if (match.graphql) {
426
474
  const g = call.graphql;
427
475
  if (!g)
@@ -504,6 +552,13 @@ function distance(a, b) {
504
552
  }
505
553
  return row[b.length];
506
554
  }
555
+ /** A query condition: one matcher for the first value, or a list for every value of a repeated parameter, in order. */
556
+ function testQuery(m, query, key) {
557
+ if (!Array.isArray(m))
558
+ return test(m, query.get(key) ?? undefined);
559
+ const values = query.getAll(key);
560
+ return values.length === m.length && m.every((x, i) => test(x, values[i]));
561
+ }
507
562
  function test(m, value) {
508
563
  if (isAsymmetric(m))
509
564
  return m.asymmetricMatch(value);
@@ -534,6 +589,91 @@ export function subset(expected, actual) {
534
589
  function isAsymmetric(m) {
535
590
  return !!m && typeof m === "object" && typeof m.asymmetricMatch === "function";
536
591
  }
592
+ function isForm(contentType) {
593
+ return !!contentType && contentType.split(";")[0].trim().toLowerCase() === "application/x-www-form-urlencoded";
594
+ }
595
+ /** Keys kept as plain fields rather than nested into, so a body can't reach `Object.prototype`. */
596
+ const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
597
+ /**
598
+ * A form body as nested fields, the way Rack, PHP and Stripe read bracket keys: `a[b]=1` is
599
+ * `{ a: { b: "1" } }`, `a[]=1&a[]=2` and `a[0]=1&a[1]=2` are arrays, and a plain key sent
600
+ * twice (`to=1&to=2`) becomes an array too.
601
+ */
602
+ export function parseForm(body) {
603
+ const out = {};
604
+ for (const [key, value] of new URLSearchParams(body)) {
605
+ const m = /^([^[\]]+)((?:\[[^\]]*\])*)$/.exec(key);
606
+ const path = m?.[2] ? [m[1], ...[...m[2].matchAll(/\[([^\]]*)\]/g)].map((x) => x[1])] : undefined;
607
+ // `__proto__[x]=1` is sent by whatever the app forwards; it must not reach Object.prototype.
608
+ if (!path || path.some((seg) => UNSAFE_KEYS.has(seg))) {
609
+ const prev = Object.hasOwn(out, key) ? out[key] : undefined;
610
+ Object.defineProperty(out, key, { value: prev === undefined ? value : Array.isArray(prev) ? [...prev, value] : [prev, value], enumerable: true, writable: true, configurable: true });
611
+ continue;
612
+ }
613
+ let node = out;
614
+ for (let i = 0; i < path.length; i++) {
615
+ const seg = path[i];
616
+ const last = i === path.length - 1;
617
+ const nextIsIndex = !last && (path[i + 1] === "" || /^\d+$/.test(path[i + 1]));
618
+ if (Array.isArray(node)) {
619
+ const index = seg === "" ? node.length : Number(seg);
620
+ if (last)
621
+ node[index] = value;
622
+ else
623
+ node = (node[index] ??= nextIsIndex ? [] : {});
624
+ }
625
+ else {
626
+ if (last)
627
+ node[seg] = value;
628
+ else {
629
+ const child = node[seg];
630
+ node = (child && typeof child === "object" ? child : (node[seg] = nextIsIndex ? [] : {}));
631
+ }
632
+ }
633
+ }
634
+ }
635
+ return out;
636
+ }
637
+ /**
638
+ * Form values arrive as strings: compare `amount: 2000` and `capture: true` with what was sent.
639
+ * Only where a string was sent, so an uploaded file's `size` stays a number.
640
+ */
641
+ function formExpectation(expected, actual) {
642
+ if ((typeof expected === "number" || typeof expected === "boolean") && typeof actual === "string")
643
+ return String(expected);
644
+ if (Array.isArray(expected))
645
+ return expected.map((e, i) => formExpectation(e, Array.isArray(actual) ? actual[i] : undefined));
646
+ if (expected && typeof expected === "object" && !isAsymmetric(expected) && !(expected instanceof RegExp)) {
647
+ const at = (k) => (actual && typeof actual === "object" ? actual[k] : undefined);
648
+ return Object.fromEntries(Object.entries(expected).map(([k, v]) => [k, formExpectation(v, at(k))]));
649
+ }
650
+ return expected;
651
+ }
652
+ const TEXTUAL = /^(text\/|application\/(json|xml|csv|x-ndjson|yaml|javascript)|[^;]*\+(json|xml))/i;
653
+ /** Fields of a `multipart/form-data` body, files as `{ filename, type, size, text }`. Undefined if it can't be parsed. */
654
+ async function parseMultipart(raw, contentType) {
655
+ let data;
656
+ try {
657
+ data = await new Response(new Uint8Array(raw), { headers: { "content-type": contentType } }).formData();
658
+ }
659
+ catch {
660
+ return undefined;
661
+ }
662
+ const out = {};
663
+ for (const [key, value] of data) {
664
+ const v = typeof value === "string"
665
+ ? value
666
+ : {
667
+ filename: value.name,
668
+ type: value.type,
669
+ size: value.size,
670
+ ...(TEXTUAL.test(value.type) ? { text: await value.text() } : {}),
671
+ };
672
+ const prev = out[key];
673
+ out[key] = prev === undefined ? v : Array.isArray(prev) ? [...prev, v] : [prev, v];
674
+ }
675
+ return out;
676
+ }
537
677
  function parseJson(body) {
538
678
  if (!body)
539
679
  return undefined;
package/dist/webhook.d.ts CHANGED
@@ -8,12 +8,12 @@ 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" | HmacScheme;
11
+ export type WebhookProvider = "stripe" | "github" | "slack" | "shopify" | "standard" | "line" | "paddle" | "linear" | "gitlab" | "zoom" | "twitch" | "twilio" | 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. */
15
15
  secret: string;
16
- /** Event type, sent where the provider puts it: `X-GitHub-Event`, `X-Shopify-Topic`. */
16
+ /** Event type, sent where the provider puts it: `X-GitHub-Event`, `X-Shopify-Topic`, `X-Gitlab-Event`, `Linear-Event`, `Twitch-Eventsub-Message-Type`. */
17
17
  event?: string;
18
18
  /** Unix seconds the signature is made for. Default: now. */
19
19
  timestamp?: number;
@@ -21,20 +21,29 @@ export interface WebhookOptions {
21
21
  stale?: boolean;
22
22
  /** Signed with a different secret: the app must refuse it. */
23
23
  invalidSignature?: boolean;
24
- /** Message id for `standard` (`webhook-id`). Default: a random `msg_…`. */
24
+ /** Message id for `standard` (`webhook-id`, default a random `msg_…`) and `twitch`. */
25
25
  id?: string;
26
+ /**
27
+ * The URL the app sees the request at, for providers that sign it (Twilio). `http.webhook()` fills in
28
+ * the app's address and the path; set it when the app validates against a public URL it's configured with.
29
+ */
30
+ url?: string;
26
31
  /** Extra request headers. */
27
32
  headers?: Record<string, string>;
28
33
  }
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"];
29
36
  /**
30
37
  * The headers a provider sends with `body`, signed with `secret` the way its
31
38
  * 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, …).
39
+ * `X-Hub-Signature-256`, Slack's `v0` signature, Shopify's base64 HMAC,
40
+ * Standard Webhooks (Svix, Resend, Clerk, …), LINE's `X-Line-Signature`,
41
+ * Paddle Billing's `Paddle-Signature`, Linear, GitLab's token, Zoom's `v0`
42
+ * signature and Twitch EventSub.
34
43
  */
35
44
  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): {
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. */
46
+ export declare function webhookBody(payload: unknown, provider?: WebhookProvider): {
38
47
  body: string;
39
48
  type: string;
40
49
  };
package/dist/webhook.js CHANGED
@@ -1,10 +1,15 @@
1
1
  import { createHmac, randomUUID } from "node:crypto";
2
+ import { encodeForm } from "./form.js";
3
+ /** The providers `signWebhook` knows by name. */
4
+ export const WEBHOOK_PROVIDERS = ["stripe", "github", "slack", "shopify", "standard", "line", "paddle", "linear", "gitlab", "zoom", "twitch", "twilio"];
2
5
  const hmac = (algorithm, key, data) => createHmac(algorithm, key).update(data);
3
6
  /**
4
7
  * The headers a provider sends with `body`, signed with `secret` the way its
5
8
  * 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, …).
9
+ * `X-Hub-Signature-256`, Slack's `v0` signature, Shopify's base64 HMAC,
10
+ * Standard Webhooks (Svix, Resend, Clerk, …), LINE's `X-Line-Signature`,
11
+ * Paddle Billing's `Paddle-Signature`, Linear, GitLab's token, Zoom's `v0`
12
+ * signature and Twitch EventSub.
8
13
  */
9
14
  export function signWebhook(body, opts) {
10
15
  const now = Math.floor(Date.now() / 1000);
@@ -38,15 +43,50 @@ export function signWebhook(body, opts) {
38
43
  const key = secret.startsWith("whsec_") ? Buffer.from(secret.slice(6), "base64") : Buffer.from(secret);
39
44
  return { "webhook-id": id, "webhook-timestamp": String(ts), "webhook-signature": `v1,${hmac("sha256", key, `${id}.${ts}.${body}`).digest("base64")}` };
40
45
  }
46
+ case "line":
47
+ return { "x-line-signature": hmac("sha256", secret, body).digest("base64") };
48
+ case "paddle":
49
+ return { "paddle-signature": `ts=${ts};h1=${hmac("sha256", secret, `${ts}:${body}`).digest("hex")}` };
50
+ case "linear":
51
+ return {
52
+ "linear-signature": hmac("sha256", secret, body).digest("hex"),
53
+ "linear-delivery": randomUUID(),
54
+ ...(opts.event ? { "linear-event": opts.event } : {}),
55
+ };
56
+ case "gitlab":
57
+ return { "x-gitlab-token": secret, "x-gitlab-event": opts.event ?? "Push Hook", "x-gitlab-event-uuid": randomUUID() };
58
+ case "zoom":
59
+ return { "x-zm-request-timestamp": String(ts), "x-zm-signature": `v0=${hmac("sha256", secret, `v0:${ts}:${body}`).digest("hex")}` };
60
+ case "twitch": {
61
+ const id = opts.id ?? randomUUID();
62
+ const at = new Date(ts * 1000).toISOString();
63
+ return {
64
+ "twitch-eventsub-message-id": id,
65
+ "twitch-eventsub-message-timestamp": at,
66
+ "twitch-eventsub-message-type": opts.event ?? "notification",
67
+ "twitch-eventsub-message-signature": `sha256=${hmac("sha256", secret, `${id}${at}${body}`).digest("hex")}`,
68
+ };
69
+ }
70
+ case "twilio": {
71
+ // Twilio's validateRequest: the URL, then each POST parameter's name and value, sorted by name; HMAC-SHA1, base64.
72
+ if (!opts.url)
73
+ throw new Error("slicetest: a twilio webhook is signed over its URL: pass `url`, or send it with http.webhook()");
74
+ const params = [...new URLSearchParams(body)].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
75
+ return { "x-twilio-signature": hmac("sha1", secret, opts.url + params.map(([k, v]) => k + v).join("")).digest("base64") };
76
+ }
41
77
  default:
42
- throw new Error(`slicetest: unknown webhook provider ${JSON.stringify(p)} (expected stripe, github, slack, shopify, standard or { header, prefix, encoding })`);
78
+ throw new Error(`slicetest: unknown webhook provider ${JSON.stringify(p)} (expected ${WEBHOOK_PROVIDERS.join(", ")} or { header, prefix, encoding })`);
43
79
  }
44
80
  }
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) {
81
+ /** 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. */
82
+ export function webhookBody(payload, provider) {
83
+ const form = "application/x-www-form-urlencoded";
47
84
  if (typeof payload === "string")
48
- return { body: payload, type: "application/json" };
85
+ return { body: payload, type: provider === "twilio" ? form : "application/json" };
49
86
  if (payload instanceof URLSearchParams)
50
- return { body: payload.toString(), type: "application/x-www-form-urlencoded" };
87
+ return { body: payload.toString(), type: form };
88
+ // Twilio posts its parameters as a form.
89
+ if (provider === "twilio" && payload && typeof payload === "object")
90
+ return { body: encodeForm(payload).toString(), type: form };
51
91
  return { body: JSON.stringify(payload ?? {}), type: "application/json" };
52
92
  }
@@ -12,8 +12,9 @@ export declare function lookup(obj: unknown, path: string): unknown;
12
12
  */
13
13
  export declare function interpolate(value: unknown, vars: Vars): unknown;
14
14
  /**
15
- * `{ $type: number }`, `{ $regex: "^ch_" }`, `{ $contains: "x" }` and
16
- * `{ $any: true }` become Vitest asymmetric matchers.
15
+ * `{ $type: number }`, `{ $regex: "^ch_" }`, `{ $contains: "x" }`, `{ $any: true }`,
16
+ * `{ $gte: 1, $lt: 10 }`, `{ $len: 3 }`, `{ $not: … }`, `{ $oneOf: [...] }` and
17
+ * `{ $format: uuid }` become Vitest asymmetric matchers. Several `$` keys must all hold.
17
18
  */
18
19
  export declare function toMatchers(value: unknown): unknown;
19
20
  export {};