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/README.md +83 -17
- package/dist/cli-run.d.ts +13 -0
- package/dist/cli-run.js +15 -0
- package/dist/cli.js +32 -2
- package/dist/config.d.ts +6 -0
- package/dist/config.js +48 -0
- package/dist/db.d.ts +12 -1
- package/dist/db.js +33 -6
- package/dist/form.d.ts +8 -2
- package/dist/form.js +85 -11
- package/dist/global-setup.d.ts +5 -0
- package/dist/global-setup.js +32 -11
- package/dist/har.js +3 -3
- package/dist/http.d.ts +40 -4
- package/dist/http.js +126 -13
- package/dist/index.d.ts +4 -3
- package/dist/init.js +53 -8
- package/dist/list.d.ts +26 -0
- package/dist/list.js +71 -0
- package/dist/matchers.d.ts +21 -3
- package/dist/matchers.js +91 -2
- package/dist/openapi.d.ts +0 -5
- package/dist/openapi.js +13 -6
- package/dist/record.d.ts +1 -1
- package/dist/record.js +3 -1
- package/dist/recording.d.ts +7 -0
- package/dist/recording.js +38 -4
- package/dist/runtime.js +13 -0
- package/dist/scenario.d.ts +14 -4
- package/dist/scenario.js +36 -20
- package/dist/sql-migrations.d.ts +11 -0
- package/dist/sql-migrations.js +38 -0
- package/dist/stub.d.ts +32 -3
- package/dist/stub.js +148 -8
- package/dist/webhook.d.ts +16 -7
- package/dist/webhook.js +47 -7
- package/dist/yaml-runtime.d.ts +3 -2
- package/dist/yaml-runtime.js +287 -36
- package/dist/yaml.d.ts +51 -4
- package/dist/yaml.js +105 -14
- package/package.json +1 -1
- package/schema/scenario.schema.json +431 -6
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,
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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,
|
|
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),
|
|
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
|
-
/**
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
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] =
|
|
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 (!
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
}
|
package/dist/yaml-runtime.d.ts
CHANGED
|
@@ -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" }`
|
|
16
|
-
* `{ $
|
|
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 {};
|