slicetest 0.6.1 → 0.8.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 +177 -24
- package/dist/cli.js +66 -2
- package/dist/config.d.ts +18 -0
- package/dist/config.js +13 -0
- package/dist/connection.d.ts +5 -0
- package/dist/connection.js +31 -0
- package/dist/db.d.ts +12 -1
- package/dist/db.js +33 -6
- package/dist/diagram.d.ts +13 -0
- package/dist/diagram.js +89 -0
- package/dist/form.d.ts +7 -2
- package/dist/form.js +72 -7
- package/dist/global-setup.d.ts +5 -0
- package/dist/global-setup.js +74 -9
- package/dist/graphql.d.ts +34 -0
- package/dist/graphql.js +56 -0
- package/dist/har.d.ts +49 -0
- package/dist/har.js +107 -0
- package/dist/http.d.ts +21 -3
- package/dist/http.js +47 -10
- package/dist/index.d.ts +5 -3
- package/dist/init.js +184 -15
- package/dist/list.d.ts +26 -0
- package/dist/list.js +71 -0
- package/dist/matchers.d.ts +16 -0
- package/dist/matchers.js +81 -1
- package/dist/openapi.d.ts +21 -0
- package/dist/openapi.js +32 -1
- package/dist/provided.d.ts +1 -0
- package/dist/record.d.ts +1 -1
- package/dist/record.js +3 -1
- package/dist/recording.d.ts +1 -1
- package/dist/recording.js +2 -2
- package/dist/runtime.d.ts +12 -5
- package/dist/runtime.js +78 -22
- package/dist/scenario.d.ts +14 -4
- package/dist/scenario.js +36 -17
- package/dist/schema.d.ts +5 -0
- package/dist/schema.js +73 -0
- package/dist/stub.d.ts +63 -0
- package/dist/stub.js +292 -5
- package/dist/timeline.d.ts +9 -0
- package/dist/timeline.js +6 -0
- 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 +308 -51
- package/dist/yaml.d.ts +82 -6
- package/dist/yaml.js +187 -21
- package/package.json +2 -1
- package/schema/scenario.schema.json +640 -14
package/dist/stub.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import http from "node:http";
|
|
2
2
|
import { isDeepStrictEqual } from "node:util";
|
|
3
|
+
import { describeGraphQL, graphqlErrors, graphqlOf } from "./graphql.js";
|
|
4
|
+
import { timeline } from "./timeline.js";
|
|
3
5
|
/**
|
|
4
6
|
* A streaming reply in Server-Sent Events format, as LLM APIs stream (Anthropic, OpenAI):
|
|
5
7
|
* `stub("anthropic").on("POST", "/v1/messages").reply(sse([["message_start", {...}], ...]))`.
|
|
@@ -54,6 +56,28 @@ export class Stub {
|
|
|
54
56
|
* `call.params`) or be a RegExp; `method` may be `*`. Later routes win.
|
|
55
57
|
*/
|
|
56
58
|
on(method, path, match = {}) {
|
|
59
|
+
return this.#on(method, path, match);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Answer a GraphQL operation, whatever path the app posts it to:
|
|
63
|
+
* `stub("github").graphql("CreateIssue", { variables: { title: "Bug" } }).data({ createIssue: { issue: { number: 1 } } })`.
|
|
64
|
+
* The operation is `operationName`, or the name in the document when the client sends none.
|
|
65
|
+
*/
|
|
66
|
+
graphql(operation, match = {}) {
|
|
67
|
+
const { variables, path, ...rest } = match;
|
|
68
|
+
const builder = this.#on("*", path ?? /.*/, { ...rest, graphql: { operation, variables } }, `GraphQL ${operation}${path ? ` at ${path}` : ""}`);
|
|
69
|
+
const gql = {
|
|
70
|
+
...builder,
|
|
71
|
+
times: (n) => (builder.times(n), gql),
|
|
72
|
+
once: () => (builder.once(), gql),
|
|
73
|
+
delay: (ms) => (builder.delay(ms), gql),
|
|
74
|
+
optional: () => (builder.optional(), gql),
|
|
75
|
+
data: (data) => builder.reply(async (call) => ({ status: 200, body: { data: typeof data === "function" ? await data(call) : data } })),
|
|
76
|
+
errors: (errors, data) => builder.reply({ status: 200, body: { errors: graphqlErrors(errors), ...(data === undefined ? {} : { data }) } }),
|
|
77
|
+
};
|
|
78
|
+
return gql;
|
|
79
|
+
}
|
|
80
|
+
#on(method, path, match, label) {
|
|
57
81
|
const { pattern, paramNames } = compilePath(path);
|
|
58
82
|
const route = {
|
|
59
83
|
method: method.toUpperCase(),
|
|
@@ -64,6 +88,7 @@ export class Stub {
|
|
|
64
88
|
remaining: Infinity,
|
|
65
89
|
delayMs: 0,
|
|
66
90
|
hits: 0,
|
|
91
|
+
label,
|
|
67
92
|
};
|
|
68
93
|
const add = (respond, extra = {}) => {
|
|
69
94
|
this.#routes.unshift({ ...route, ...extra, respond });
|
|
@@ -73,6 +98,7 @@ export class Stub {
|
|
|
73
98
|
times: (n) => ((route.remaining = n), builder),
|
|
74
99
|
once: () => builder.times(1),
|
|
75
100
|
delay: (ms) => ((route.delayMs = ms), builder),
|
|
101
|
+
optional: () => ((route.optional = true), builder),
|
|
76
102
|
reply: (statusOrResponse, body, headers) => add(typeof statusOrResponse === "number" ? { status: statusOrResponse, body, headers } : statusOrResponse),
|
|
77
103
|
replySequence: (responses) => {
|
|
78
104
|
if (responses.length === 0)
|
|
@@ -162,14 +188,53 @@ export class Stub {
|
|
|
162
188
|
unmatched() {
|
|
163
189
|
return this.#calls.filter((c) => !c.matched);
|
|
164
190
|
}
|
|
191
|
+
/** Routes registered in this scenario that no call reached, except `optional()` ones. */
|
|
192
|
+
unusedRoutes() {
|
|
193
|
+
return this.#routes.filter((r) => r.hits === 0 && !r.optional).map((r) => r.label ?? `${r.method} ${r.path}`).reverse();
|
|
194
|
+
}
|
|
165
195
|
/** Human-readable list of registered routes, for diagnostics. */
|
|
166
196
|
describeRoutes() {
|
|
167
197
|
return this.#routes.map((r) => {
|
|
168
198
|
const limit = Number.isFinite(r.remaining + r.hits) ? ` (${r.hits}/${r.remaining + r.hits} used)` : "";
|
|
169
|
-
const
|
|
170
|
-
|
|
199
|
+
const keys = Object.keys(r.match).filter((k) => !(k === "graphql" && r.label));
|
|
200
|
+
const cond = keys.length ? ` + ${keys.join("/")} conditions` : "";
|
|
201
|
+
return `${r.label ?? `${r.method} ${r.path}`}${cond}${limit}`;
|
|
171
202
|
});
|
|
172
203
|
}
|
|
204
|
+
/**
|
|
205
|
+
* Why `call` wasn't answered, measured against the registered route it came closest to:
|
|
206
|
+
* `closest route POST /v1/charges: json.amount: expected 100, got "100"`. Undefined without routes.
|
|
207
|
+
*/
|
|
208
|
+
explain(call) {
|
|
209
|
+
let best;
|
|
210
|
+
for (const route of this.#routes) {
|
|
211
|
+
const reasons = [];
|
|
212
|
+
let score = 0;
|
|
213
|
+
if (route.method !== "*" && route.method !== call.method) {
|
|
214
|
+
reasons.push(`method is ${call.method}, the route takes ${route.method}`);
|
|
215
|
+
score += 1;
|
|
216
|
+
}
|
|
217
|
+
if (!matchPath(route.path, route.pattern, call.path, route.paramNames)) {
|
|
218
|
+
reasons.push(pathHint(route.path, call.path));
|
|
219
|
+
score += 2 + (typeof route.path === "string" ? Math.min(distance(route.path, call.path) / 4, 3) : 1);
|
|
220
|
+
}
|
|
221
|
+
const why = conditionMismatch(route.match, call);
|
|
222
|
+
if (why) {
|
|
223
|
+
reasons.push(why);
|
|
224
|
+
score += 1;
|
|
225
|
+
}
|
|
226
|
+
if (reasons.length === 0 && route.remaining <= 0) {
|
|
227
|
+
reasons.push(`the route already answered its ${route.hits} call(s) (once() / times())`);
|
|
228
|
+
score += 0.5;
|
|
229
|
+
}
|
|
230
|
+
if (!best || score < best.score)
|
|
231
|
+
best = { route, reasons, score };
|
|
232
|
+
}
|
|
233
|
+
if (!best)
|
|
234
|
+
return undefined;
|
|
235
|
+
const label = best.route.label ?? `${best.route.method} ${best.route.path}`;
|
|
236
|
+
return `closest route ${label}: ${best.reasons.join("; ") || "matches now (registered after the call arrived?)"}`;
|
|
237
|
+
}
|
|
173
238
|
reset() {
|
|
174
239
|
this.#routes = [];
|
|
175
240
|
this.#calls = [];
|
|
@@ -190,10 +255,13 @@ export class Stub {
|
|
|
190
255
|
await new Promise((resolve) => this.#server.close(resolve));
|
|
191
256
|
}
|
|
192
257
|
async #handle(req, res) {
|
|
258
|
+
const start = performance.now();
|
|
193
259
|
const chunks = [];
|
|
194
260
|
for await (const chunk of req)
|
|
195
261
|
chunks.push(chunk);
|
|
196
|
-
const
|
|
262
|
+
const raw = Buffer.concat(chunks);
|
|
263
|
+
const body = raw.toString("utf8");
|
|
264
|
+
const contentType = req.headers["content-type"] ?? "";
|
|
197
265
|
const url = new URL(req.url ?? "/", this.url);
|
|
198
266
|
const call = {
|
|
199
267
|
method: req.method ?? "GET",
|
|
@@ -202,9 +270,13 @@ export class Stub {
|
|
|
202
270
|
headers: req.headers,
|
|
203
271
|
body,
|
|
204
272
|
json: parseJson(body),
|
|
273
|
+
form: isForm(contentType) ? parseForm(body) : /^multipart\/form-data/i.test(contentType) ? await parseMultipart(raw, contentType) : undefined,
|
|
205
274
|
params: {},
|
|
206
275
|
matched: false,
|
|
207
276
|
};
|
|
277
|
+
call.graphql = graphqlOf(call);
|
|
278
|
+
timeline.set(call, { start });
|
|
279
|
+
res.once("close", () => (timeline.get(call).end = performance.now()));
|
|
208
280
|
this.#calls.push(call);
|
|
209
281
|
let route;
|
|
210
282
|
for (const r of this.#routes) {
|
|
@@ -236,7 +308,8 @@ export class Stub {
|
|
|
236
308
|
}
|
|
237
309
|
if (!out) {
|
|
238
310
|
const hint = this.#hint ? ` (${this.#hint})` : "";
|
|
239
|
-
|
|
311
|
+
const what = call.graphql ? `${describeGraphQL(call.graphql)} (${call.method} ${call.path})` : `${call.method} ${call.path}`;
|
|
312
|
+
res.writeHead(501, { "content-type": "text/plain" }).end(`slicetest: no stub for ${what}${hint}`);
|
|
240
313
|
return;
|
|
241
314
|
}
|
|
242
315
|
call.matched = true;
|
|
@@ -301,9 +374,18 @@ function matchPath(path, pattern, actual, names = []) {
|
|
|
301
374
|
if (!m)
|
|
302
375
|
return undefined;
|
|
303
376
|
const params = { ...m.groups };
|
|
304
|
-
names.forEach((n, i) => (params[n] =
|
|
377
|
+
names.forEach((n, i) => (params[n] = decodeSegment(m[i + 1])));
|
|
305
378
|
return params;
|
|
306
379
|
}
|
|
380
|
+
/** A path segment decoded, or as sent when it isn't valid percent-encoding (`%zz`), instead of dropping the call. */
|
|
381
|
+
function decodeSegment(segment) {
|
|
382
|
+
try {
|
|
383
|
+
return decodeURIComponent(segment);
|
|
384
|
+
}
|
|
385
|
+
catch {
|
|
386
|
+
return segment;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
307
389
|
function matchConditions(match, call) {
|
|
308
390
|
for (const [k, m] of Object.entries(match.query ?? {})) {
|
|
309
391
|
if (!test(m, call.query.get(k) ?? undefined))
|
|
@@ -318,8 +400,131 @@ function matchConditions(match, call) {
|
|
|
318
400
|
return false;
|
|
319
401
|
if (match.json !== undefined && !subset(match.json, call.json))
|
|
320
402
|
return false;
|
|
403
|
+
if (match.form !== undefined && !subset(formExpectation(match.form, call.form), call.form))
|
|
404
|
+
return false;
|
|
405
|
+
if (match.graphql) {
|
|
406
|
+
const g = call.graphql;
|
|
407
|
+
if (!g)
|
|
408
|
+
return false;
|
|
409
|
+
const { operation, variables } = match.graphql;
|
|
410
|
+
if (operation !== undefined && !(operation instanceof RegExp ? g.operation !== undefined && operation.test(g.operation) : g.operation === operation))
|
|
411
|
+
return false;
|
|
412
|
+
if (variables !== undefined && !subset(variables, g.variables))
|
|
413
|
+
return false;
|
|
414
|
+
}
|
|
321
415
|
return true;
|
|
322
416
|
}
|
|
417
|
+
/** The first condition of `match` that `call` fails, described; undefined when it meets them all. */
|
|
418
|
+
function conditionMismatch(match, call) {
|
|
419
|
+
for (const [k, m] of Object.entries(match.query ?? {})) {
|
|
420
|
+
const v = call.query.get(k) ?? undefined;
|
|
421
|
+
if (!test(m, v))
|
|
422
|
+
return `query ${k}: expected ${show(m)}, got ${v === undefined ? "nothing" : JSON.stringify(v)}`;
|
|
423
|
+
}
|
|
424
|
+
for (const [k, m] of Object.entries(match.headers ?? {})) {
|
|
425
|
+
const raw = call.headers[k.toLowerCase()];
|
|
426
|
+
const v = Array.isArray(raw) ? raw.join(", ") : raw;
|
|
427
|
+
if (!test(m, v))
|
|
428
|
+
return `header ${k.toLowerCase()}: expected ${show(m)}, got ${v === undefined ? "nothing" : JSON.stringify(v)}`;
|
|
429
|
+
}
|
|
430
|
+
if (match.body !== undefined && !test(match.body, call.body))
|
|
431
|
+
return `body: expected ${show(match.body)}, got ${JSON.stringify(call.body.slice(0, 100))}`;
|
|
432
|
+
if (match.json !== undefined) {
|
|
433
|
+
if (call.json === undefined)
|
|
434
|
+
return `json: expected a JSON body, got ${call.body ? JSON.stringify(call.body.slice(0, 100)) : "an empty body"}`;
|
|
435
|
+
const diff = difference(match.json, call.json, "json");
|
|
436
|
+
if (diff)
|
|
437
|
+
return diff;
|
|
438
|
+
}
|
|
439
|
+
if (match.form !== undefined) {
|
|
440
|
+
if (call.form === undefined)
|
|
441
|
+
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"}`;
|
|
442
|
+
const diff = difference(formExpectation(match.form, call.form), call.form, "form");
|
|
443
|
+
if (diff)
|
|
444
|
+
return diff;
|
|
445
|
+
}
|
|
446
|
+
if (match.graphql) {
|
|
447
|
+
const g = call.graphql;
|
|
448
|
+
if (!g)
|
|
449
|
+
return "graphql: the call isn't a GraphQL request";
|
|
450
|
+
const { operation, variables } = match.graphql;
|
|
451
|
+
if (operation !== undefined && !(operation instanceof RegExp ? g.operation !== undefined && operation.test(g.operation) : g.operation === operation)) {
|
|
452
|
+
return `graphql operation: expected ${show(operation)}, got ${g.operation ?? "an anonymous operation"}`;
|
|
453
|
+
}
|
|
454
|
+
if (variables !== undefined) {
|
|
455
|
+
const diff = difference(variables, g.variables, "variables");
|
|
456
|
+
if (diff)
|
|
457
|
+
return diff;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return undefined;
|
|
461
|
+
}
|
|
462
|
+
/** Where `actual` first stops containing `expected` (the rules of `subset`), as `json.items.0.sku: expected "a", got "b"`. */
|
|
463
|
+
function difference(expected, actual, at) {
|
|
464
|
+
if (subset(expected, actual))
|
|
465
|
+
return undefined;
|
|
466
|
+
if (Array.isArray(expected) && Array.isArray(actual)) {
|
|
467
|
+
if (expected.length !== actual.length)
|
|
468
|
+
return `${at}: expected ${expected.length} item(s), got ${actual.length}`;
|
|
469
|
+
for (const [i, e] of expected.entries()) {
|
|
470
|
+
const d = difference(e, actual[i], `${at}.${i}`);
|
|
471
|
+
if (d)
|
|
472
|
+
return d;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
if (expected && typeof expected === "object" && !Array.isArray(expected) && !isAsymmetric(expected) && !(expected instanceof RegExp) && actual && typeof actual === "object" && !Array.isArray(actual)) {
|
|
476
|
+
for (const [k, v] of Object.entries(expected)) {
|
|
477
|
+
if (!(k in actual))
|
|
478
|
+
return `${at}.${k}: expected ${show(v)}, got nothing`;
|
|
479
|
+
const d = difference(v, actual[k], `${at}.${k}`);
|
|
480
|
+
if (d)
|
|
481
|
+
return d;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
return `${at}: expected ${show(expected)}, got ${short(actual)}`;
|
|
485
|
+
}
|
|
486
|
+
function show(m) {
|
|
487
|
+
if (isAsymmetric(m))
|
|
488
|
+
return m.toAsymmetricMatcher?.() ?? String(m);
|
|
489
|
+
if (m instanceof RegExp)
|
|
490
|
+
return String(m);
|
|
491
|
+
if (typeof m === "function")
|
|
492
|
+
return "a value the given function accepts";
|
|
493
|
+
return short(m);
|
|
494
|
+
}
|
|
495
|
+
function short(v) {
|
|
496
|
+
const s = v === undefined ? "nothing" : JSON.stringify(v);
|
|
497
|
+
return s.length > 80 ? `${s.slice(0, 79)}…` : s;
|
|
498
|
+
}
|
|
499
|
+
/** What is off about the path: a trailing slash, letter case, a prefix, or just a different path. */
|
|
500
|
+
function pathHint(expected, actual) {
|
|
501
|
+
if (typeof expected !== "string")
|
|
502
|
+
return `path ${actual} doesn't match ${expected}`;
|
|
503
|
+
const trim = (p) => (p.length > 1 ? p.replace(/\/+$/, "") : p);
|
|
504
|
+
if (trim(expected) === trim(actual))
|
|
505
|
+
return `path is ${actual}, the route is ${expected} (trailing slash)`;
|
|
506
|
+
if (expected.toLowerCase() === actual.toLowerCase())
|
|
507
|
+
return `path is ${actual}, the route is ${expected} (letter case)`;
|
|
508
|
+
if (actual.endsWith(expected))
|
|
509
|
+
return `path is ${actual}, the route is ${expected}: is the base URL's path (${actual.slice(0, -expected.length)}) in the env value?`;
|
|
510
|
+
if (expected.endsWith(actual))
|
|
511
|
+
return `path is ${actual}, the route is ${expected}: the base URL the app was given may lack ${expected.slice(0, -actual.length)}`;
|
|
512
|
+
return `path is ${actual}, the route is ${expected}`;
|
|
513
|
+
}
|
|
514
|
+
/** Levenshtein distance, to find the route whose path is nearest. */
|
|
515
|
+
function distance(a, b) {
|
|
516
|
+
const row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
517
|
+
for (let i = 1; i <= a.length; i++) {
|
|
518
|
+
let prev = row[0];
|
|
519
|
+
row[0] = i;
|
|
520
|
+
for (let j = 1; j <= b.length; j++) {
|
|
521
|
+
const tmp = row[j];
|
|
522
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
523
|
+
prev = tmp;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return row[b.length];
|
|
527
|
+
}
|
|
323
528
|
function test(m, value) {
|
|
324
529
|
if (isAsymmetric(m))
|
|
325
530
|
return m.asymmetricMatch(value);
|
|
@@ -350,6 +555,88 @@ export function subset(expected, actual) {
|
|
|
350
555
|
function isAsymmetric(m) {
|
|
351
556
|
return !!m && typeof m === "object" && typeof m.asymmetricMatch === "function";
|
|
352
557
|
}
|
|
558
|
+
function isForm(contentType) {
|
|
559
|
+
return !!contentType && contentType.split(";")[0].trim().toLowerCase() === "application/x-www-form-urlencoded";
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* A form body as nested fields, the way Rack, PHP and Stripe read bracket keys: `a[b]=1` is
|
|
563
|
+
* `{ a: { b: "1" } }`, `a[]=1&a[]=2` and `a[0]=1&a[1]=2` are arrays, and a plain key sent
|
|
564
|
+
* twice (`to=1&to=2`) becomes an array too.
|
|
565
|
+
*/
|
|
566
|
+
export function parseForm(body) {
|
|
567
|
+
const out = {};
|
|
568
|
+
for (const [key, value] of new URLSearchParams(body)) {
|
|
569
|
+
const m = /^([^[\]]+)((?:\[[^\]]*\])*)$/.exec(key);
|
|
570
|
+
if (!m || !m[2]) {
|
|
571
|
+
const prev = out[key];
|
|
572
|
+
out[key] = prev === undefined ? value : Array.isArray(prev) ? [...prev, value] : [prev, value];
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
const path = [m[1], ...[...m[2].matchAll(/\[([^\]]*)\]/g)].map((x) => x[1])];
|
|
576
|
+
let node = out;
|
|
577
|
+
for (let i = 0; i < path.length; i++) {
|
|
578
|
+
const seg = path[i];
|
|
579
|
+
const last = i === path.length - 1;
|
|
580
|
+
const nextIsIndex = !last && (path[i + 1] === "" || /^\d+$/.test(path[i + 1]));
|
|
581
|
+
if (Array.isArray(node)) {
|
|
582
|
+
const index = seg === "" ? node.length : Number(seg);
|
|
583
|
+
if (last)
|
|
584
|
+
node[index] = value;
|
|
585
|
+
else
|
|
586
|
+
node = (node[index] ??= nextIsIndex ? [] : {});
|
|
587
|
+
}
|
|
588
|
+
else {
|
|
589
|
+
if (last)
|
|
590
|
+
node[seg] = value;
|
|
591
|
+
else {
|
|
592
|
+
const child = node[seg];
|
|
593
|
+
node = (child && typeof child === "object" ? child : (node[seg] = nextIsIndex ? [] : {}));
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
return out;
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Form values arrive as strings: compare `amount: 2000` and `capture: true` with what was sent.
|
|
602
|
+
* Only where a string was sent, so an uploaded file's `size` stays a number.
|
|
603
|
+
*/
|
|
604
|
+
function formExpectation(expected, actual) {
|
|
605
|
+
if ((typeof expected === "number" || typeof expected === "boolean") && typeof actual === "string")
|
|
606
|
+
return String(expected);
|
|
607
|
+
if (Array.isArray(expected))
|
|
608
|
+
return expected.map((e, i) => formExpectation(e, Array.isArray(actual) ? actual[i] : undefined));
|
|
609
|
+
if (expected && typeof expected === "object" && !isAsymmetric(expected) && !(expected instanceof RegExp)) {
|
|
610
|
+
const at = (k) => (actual && typeof actual === "object" ? actual[k] : undefined);
|
|
611
|
+
return Object.fromEntries(Object.entries(expected).map(([k, v]) => [k, formExpectation(v, at(k))]));
|
|
612
|
+
}
|
|
613
|
+
return expected;
|
|
614
|
+
}
|
|
615
|
+
const TEXTUAL = /^(text\/|application\/(json|xml|csv|x-ndjson|yaml|javascript)|[^;]*\+(json|xml))/i;
|
|
616
|
+
/** Fields of a `multipart/form-data` body, files as `{ filename, type, size, text }`. Undefined if it can't be parsed. */
|
|
617
|
+
async function parseMultipart(raw, contentType) {
|
|
618
|
+
let data;
|
|
619
|
+
try {
|
|
620
|
+
data = await new Response(new Uint8Array(raw), { headers: { "content-type": contentType } }).formData();
|
|
621
|
+
}
|
|
622
|
+
catch {
|
|
623
|
+
return undefined;
|
|
624
|
+
}
|
|
625
|
+
const out = {};
|
|
626
|
+
for (const [key, value] of data) {
|
|
627
|
+
const v = typeof value === "string"
|
|
628
|
+
? value
|
|
629
|
+
: {
|
|
630
|
+
filename: value.name,
|
|
631
|
+
type: value.type,
|
|
632
|
+
size: value.size,
|
|
633
|
+
...(TEXTUAL.test(value.type) ? { text: await value.text() } : {}),
|
|
634
|
+
};
|
|
635
|
+
const prev = out[key];
|
|
636
|
+
out[key] = prev === undefined ? v : Array.isArray(prev) ? [...prev, v] : [prev, v];
|
|
637
|
+
}
|
|
638
|
+
return out;
|
|
639
|
+
}
|
|
353
640
|
function parseJson(body) {
|
|
354
641
|
if (!body)
|
|
355
642
|
return undefined;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When requests to the app and calls to stubs started and ended (`performance.now()`),
|
|
3
|
+
* kept beside the objects rather than on them, so that the public shapes (and snapshots
|
|
4
|
+
* of them) don't change. Used to put a scenario's events in order for `diagram()`.
|
|
5
|
+
*/
|
|
6
|
+
export declare const timeline: WeakMap<object, {
|
|
7
|
+
start: number;
|
|
8
|
+
end?: number;
|
|
9
|
+
}>;
|
package/dist/timeline.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When requests to the app and calls to stubs started and ended (`performance.now()`),
|
|
3
|
+
* kept beside the objects rather than on them, so that the public shapes (and snapshots
|
|
4
|
+
* of them) don't change. Used to put a scenario's events in order for `diagram()`.
|
|
5
|
+
*/
|
|
6
|
+
export const timeline = new WeakMap();
|
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 {};
|