slicetest 0.8.0 → 0.10.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 +131 -35
- package/dist/app.js +10 -2
- package/dist/auth.js +2 -1
- package/dist/cli-run.d.ts +53 -0
- package/dist/cli-run.js +80 -0
- package/dist/cli.js +27 -29
- package/dist/config.d.ts +33 -2
- package/dist/config.js +121 -2
- package/dist/container-runtime.d.ts +4 -0
- package/dist/container-runtime.js +16 -0
- package/dist/containers.js +4 -3
- package/dist/db.d.ts +3 -1
- package/dist/db.js +45 -8
- package/dist/deadline.d.ts +7 -0
- package/dist/deadline.js +24 -0
- package/dist/doctor.js +27 -5
- package/dist/drivers/driver.d.ts +6 -0
- package/dist/drivers/mysql.js +5 -1
- package/dist/drivers/postgres.d.ts +2 -0
- package/dist/drivers/postgres.js +20 -1
- package/dist/exit.d.ts +1 -0
- package/dist/exit.js +28 -0
- package/dist/form.d.ts +2 -0
- package/dist/form.js +62 -9
- package/dist/global-setup.js +3 -6
- package/dist/har.js +4 -4
- package/dist/http.d.ts +38 -4
- package/dist/http.js +135 -10
- package/dist/init.js +64 -10
- package/dist/list.d.ts +1 -0
- package/dist/list.js +16 -3
- package/dist/mail.d.ts +1 -1
- package/dist/mail.js +7 -7
- package/dist/matchers.d.ts +18 -6
- package/dist/matchers.js +75 -8
- package/dist/openapi.d.ts +2 -5
- package/dist/openapi.js +146 -20
- package/dist/record-cli.js +2 -1
- package/dist/record.js +2 -1
- package/dist/recording.d.ts +11 -1
- package/dist/recording.js +51 -6
- package/dist/request-url.d.ts +1 -0
- package/dist/request-url.js +5 -0
- package/dist/runtime.d.ts +3 -3
- package/dist/runtime.js +144 -18
- package/dist/scenario.js +11 -2
- package/dist/sql-migrations.d.ts +11 -0
- package/dist/sql-migrations.js +38 -0
- package/dist/stub.d.ts +10 -3
- package/dist/stub.js +64 -16
- package/dist/vitest.d.ts +4 -0
- package/dist/vitest.js +6 -2
- package/dist/webhook.d.ts +3 -3
- package/dist/webhook.js +10 -2
- package/dist/yaml-runtime.d.ts +4 -4
- package/dist/yaml-runtime.js +141 -38
- package/dist/yaml.d.ts +12 -2
- package/dist/yaml.js +47 -4
- package/docs/guarantees.md +92 -0
- package/docs/state-isolation.md +94 -0
- package/package.json +4 -1
- package/schema/config.schema.json +675 -0
- package/schema/scenario.schema.json +97 -7
package/dist/openapi.js
CHANGED
|
@@ -5,11 +5,17 @@ import addFormatsModule from "ajv-formats";
|
|
|
5
5
|
import { parse } from "yaml";
|
|
6
6
|
const addFormats = (addFormatsModule.default ?? addFormatsModule);
|
|
7
7
|
const METHODS = ["get", "put", "post", "delete", "options", "head", "patch", "trace"];
|
|
8
|
-
/**
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
/** A server's URL with its `{variables}` filled in: every `enum` value, else the default. */
|
|
9
|
+
function serverUrls(server) {
|
|
10
|
+
let urls = [server.url ?? "/"];
|
|
11
|
+
for (const [name, v] of Object.entries(server.variables ?? {})) {
|
|
12
|
+
const values = [...new Set([...(v.default === undefined ? [] : [v.default]), ...(v.enum ?? [])].map(String))];
|
|
13
|
+
if (values.length === 0 || !urls[0].includes(`{${name}}`))
|
|
14
|
+
continue;
|
|
15
|
+
urls = urls.flatMap((u) => values.map((value) => u.replaceAll(`{${name}}`, value))).slice(0, 50);
|
|
16
|
+
}
|
|
17
|
+
return urls;
|
|
18
|
+
}
|
|
13
19
|
export class OpenApiSpec {
|
|
14
20
|
file;
|
|
15
21
|
doc;
|
|
@@ -45,7 +51,8 @@ export class OpenApiSpec {
|
|
|
45
51
|
// Paths in the spec are relative to the server URL, e.g. /v1 for https://api.example.com/v1.
|
|
46
52
|
this.#basePaths = [
|
|
47
53
|
...new Set((doc.servers ?? [])
|
|
48
|
-
.
|
|
54
|
+
.flatMap((s) => serverUrls(s))
|
|
55
|
+
.map((url) => new URL(url, "http://x").pathname.replace(/\/$/, ""))
|
|
49
56
|
.filter(Boolean)),
|
|
50
57
|
];
|
|
51
58
|
}
|
|
@@ -105,7 +112,8 @@ export class OpenApiSpec {
|
|
|
105
112
|
if (!key)
|
|
106
113
|
return [`${method} ${template} responded ${status}, which ${this.file} doesn't document (documented: ${Object.keys(responses).join(", ") || "none"})`];
|
|
107
114
|
const [response, at] = this.#resolve(responses[key], ["paths", template, found.method, "responses", key]);
|
|
108
|
-
|
|
115
|
+
const label = `${method} ${template} → ${status}`;
|
|
116
|
+
return [...this.#checkHeaders(label, response?.headers, [...at, "headers"], res.headers), ...this.#checkContent(response?.content, res, [...at, "content"], label)];
|
|
109
117
|
}
|
|
110
118
|
/**
|
|
111
119
|
* A response the real service could send to `method path`: the lowest
|
|
@@ -126,8 +134,13 @@ export class OpenApiSpec {
|
|
|
126
134
|
const response = this.#deref(responses[key]);
|
|
127
135
|
const content = response?.content ?? {};
|
|
128
136
|
const media = Object.keys(content).find(isJson) ?? Object.keys(content)[0];
|
|
137
|
+
// The headers the spec requires, so the example passes the same check the stub's replies are held to.
|
|
138
|
+
const required = Object.fromEntries(Object.entries(response?.headers ?? {})
|
|
139
|
+
.map(([name, def]) => [name, this.#deref(def)])
|
|
140
|
+
.filter(([name, h]) => h?.required && !["content-type", "accept", "authorization"].includes(name.toLowerCase()))
|
|
141
|
+
.map(([name, h]) => [name, String(h.example !== undefined ? h.example : this.#sample(h.schema))]));
|
|
129
142
|
if (!media)
|
|
130
|
-
return { status };
|
|
143
|
+
return { status, ...(Object.keys(required).length ? { headers: required } : {}) };
|
|
131
144
|
const m = content[media] ?? {};
|
|
132
145
|
const named = m.examples && Object.values(m.examples)[0];
|
|
133
146
|
const body = m.example !== undefined
|
|
@@ -135,7 +148,7 @@ export class OpenApiSpec {
|
|
|
135
148
|
: named !== undefined
|
|
136
149
|
? (this.#deref(named)?.value ?? null)
|
|
137
150
|
: this.#sample(m.schema);
|
|
138
|
-
return { status, headers: { "content-type": media === "*/*" ? "application/json" : media }, body: isJson(media) ? body : String(body ?? "") };
|
|
151
|
+
return { status, headers: { "content-type": media === "*/*" ? "application/json" : media, ...required }, body: isJson(media) ? body : String(body ?? "") };
|
|
139
152
|
}
|
|
140
153
|
/**
|
|
141
154
|
* Every operation with what it takes to call it: sample values for its
|
|
@@ -213,10 +226,14 @@ export class OpenApiSpec {
|
|
|
213
226
|
case "object":
|
|
214
227
|
return Object.fromEntries(Object.entries(s.properties ?? {}).map(([k, v]) => [k, this.#sample(v, depth + 1)]));
|
|
215
228
|
case "array":
|
|
216
|
-
return Array.from({ length: Math.max(1, s.minItems ?? 1) }, () =>
|
|
229
|
+
return Array.from({ length: Math.max(1, s.minItems ?? 1) }, (_, i) => {
|
|
230
|
+
const items = this.#deref(s.items);
|
|
231
|
+
// Equal items would break `uniqueItems`.
|
|
232
|
+
return s.uniqueItems && Array.isArray(items?.enum) && items.enum.length ? items.enum[i % items.enum.length] : this.#sample(s.items, depth + 1);
|
|
233
|
+
});
|
|
217
234
|
case "integer":
|
|
218
235
|
case "number":
|
|
219
|
-
return
|
|
236
|
+
return sampleNumber(s, type === "integer");
|
|
220
237
|
case "boolean":
|
|
221
238
|
return true;
|
|
222
239
|
case "string":
|
|
@@ -232,10 +249,23 @@ export class OpenApiSpec {
|
|
|
232
249
|
return [`${method} ${path} is not in ${this.file}`];
|
|
233
250
|
const { template, op } = found;
|
|
234
251
|
const errors = [];
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
252
|
+
const listed = [
|
|
253
|
+
...(this.doc.paths[template].parameters ?? []).map((p, i) => this.#resolve(p, ["paths", template, "parameters", String(i)])),
|
|
254
|
+
...(op.parameters ?? []).map((p, i) => this.#resolve(p, ["paths", template, found.method, "parameters", String(i)])),
|
|
255
|
+
];
|
|
256
|
+
for (const [p, at] of listed) {
|
|
257
|
+
if (p?.in === "query") {
|
|
258
|
+
if (p.required && !this.#sentQuery(p, req.query))
|
|
259
|
+
errors.push(`${method} ${template}: required query parameter "${p.name}" is missing`);
|
|
260
|
+
const values = req.query?.getAll(p.name) ?? [];
|
|
261
|
+
if (values.length && p.schema)
|
|
262
|
+
errors.push(...this.#checkValues(`${method} ${template}`, `query parameter "${p.name}"`, values, p.schema, [...at, "schema"]));
|
|
263
|
+
}
|
|
264
|
+
// Authorization, Accept and Content-Type are the transport's business, whatever the spec lists.
|
|
265
|
+
const skipped = ["authorization", "accept", "content-type"];
|
|
266
|
+
if (p?.in === "header" && p.required && req.headers && !skipped.includes(p.name.toLowerCase()) && req.headers[p.name.toLowerCase()] === undefined) {
|
|
267
|
+
errors.push(`${method} ${template}: required header "${p.name}" is missing`);
|
|
268
|
+
}
|
|
239
269
|
}
|
|
240
270
|
const [body, at] = this.#resolve(op.requestBody, ["paths", template, found.method, "requestBody"]);
|
|
241
271
|
if (!body)
|
|
@@ -247,6 +277,59 @@ export class OpenApiSpec {
|
|
|
247
277
|
}
|
|
248
278
|
return [...errors, ...this.#checkContent(body.content, req, [...at, "content"], `${method} ${template} request`)];
|
|
249
279
|
}
|
|
280
|
+
/** Whether the request carries query parameter `p`, also as `name[key]` (deepObject) or as its properties (an exploded form object). */
|
|
281
|
+
#sentQuery(p, query) {
|
|
282
|
+
if (!query)
|
|
283
|
+
return false;
|
|
284
|
+
if (query.has(p.name))
|
|
285
|
+
return true;
|
|
286
|
+
const keys = [...query.keys()];
|
|
287
|
+
if (p.style === "deepObject")
|
|
288
|
+
return keys.some((k) => k.startsWith(`${p.name}[`));
|
|
289
|
+
const schema = this.#deref(p.schema);
|
|
290
|
+
const props = schema?.type === "object" && (p.style ?? "form") === "form" && p.explode !== false ? Object.keys(schema.properties ?? {}) : [];
|
|
291
|
+
return props.some((k) => query.has(k));
|
|
292
|
+
}
|
|
293
|
+
/** A query parameter's or header's values as strings, held to its schema after reading them as the type it declares. */
|
|
294
|
+
#checkValues(label, subject, values, schema, pointer) {
|
|
295
|
+
const [node, at] = this.#resolve(schema, pointer);
|
|
296
|
+
const one = (raw, node, at, where) => {
|
|
297
|
+
if (!["string", "integer", "number", "boolean"].includes(node?.type) && !node?.enum)
|
|
298
|
+
return [];
|
|
299
|
+
const validate = this.#validator(at);
|
|
300
|
+
const value = (node.type === "integer" || node.type === "number") && raw.trim() !== "" && Number.isFinite(Number(raw)) ? Number(raw) : node.type === "boolean" && (raw === "true" || raw === "false") ? raw === "true" : raw;
|
|
301
|
+
if (validate(value))
|
|
302
|
+
return [];
|
|
303
|
+
return [`${label}: ${subject}${where} ${validate.errors[0].message} (got ${JSON.stringify(raw)})`];
|
|
304
|
+
};
|
|
305
|
+
if (node?.type !== "array")
|
|
306
|
+
return one(values[0], node, at, "");
|
|
307
|
+
const [items, itemsAt] = this.#resolve(node.items, [...at, "items"]);
|
|
308
|
+
// One value may carry the whole list (`?ids=1,2`, explode: false). Many servers accept it for the exploded default too, so it isn't held against the spec.
|
|
309
|
+
const list = values.length > 1 ? values : values.flatMap((v) => v.split(","));
|
|
310
|
+
return list.flatMap((raw, i) => one(raw, items, itemsAt, ` item ${i + 1}`));
|
|
311
|
+
}
|
|
312
|
+
/** The headers a response documents: the required ones must be there, and what is there must fit its schema. */
|
|
313
|
+
#checkHeaders(label, documented, at, actual) {
|
|
314
|
+
if (!documented || !actual)
|
|
315
|
+
return [];
|
|
316
|
+
const errors = [];
|
|
317
|
+
for (const [name, def] of Object.entries(documented)) {
|
|
318
|
+
// The spec says to ignore these three when they are listed.
|
|
319
|
+
if (["content-type", "accept", "authorization"].includes(name.toLowerCase()))
|
|
320
|
+
continue;
|
|
321
|
+
const [h, hat] = this.#resolve(def, [...at, name]);
|
|
322
|
+
const raw = actual[name.toLowerCase()];
|
|
323
|
+
if (raw === undefined) {
|
|
324
|
+
if (h?.required)
|
|
325
|
+
errors.push(`${label}: required header "${name}" is missing`);
|
|
326
|
+
}
|
|
327
|
+
else if (h?.schema) {
|
|
328
|
+
errors.push(...this.#checkValues(label, `header "${name}"`, [Array.isArray(raw) ? raw.join(", ") : raw], h.schema, [...hat, "schema"]));
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return errors;
|
|
332
|
+
}
|
|
250
333
|
#checkContent(content, msg, pointer, label) {
|
|
251
334
|
if (!content || Object.keys(content).length === 0)
|
|
252
335
|
return [];
|
|
@@ -254,9 +337,11 @@ export class OpenApiSpec {
|
|
|
254
337
|
// Without a content-type (e.g. a stub replying with a bare string) there's nothing to hold the body to.
|
|
255
338
|
if (!type)
|
|
256
339
|
return [];
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
340
|
+
// A spec may write the key with parameters (`application/json; charset=utf-8`); the type alone decides.
|
|
341
|
+
const bare = (k) => k.split(";")[0].trim().toLowerCase();
|
|
342
|
+
const media = Object.keys(content).find((k) => bare(k) === type) ??
|
|
343
|
+
Object.keys(content).find((k) => bare(k).endsWith("/*") && type.startsWith(bare(k).slice(0, -1))) ??
|
|
344
|
+
Object.keys(content).find((k) => bare(k) === "*/*");
|
|
260
345
|
if (!media)
|
|
261
346
|
return [`${label}: content-type "${type || "(none)"}" is not one of ${Object.keys(content).join(", ")}`];
|
|
262
347
|
if (!content[media]?.schema || !isJson(media))
|
|
@@ -306,9 +391,38 @@ const FORMATS = {
|
|
|
306
391
|
ipv6: "2001:db8::1",
|
|
307
392
|
};
|
|
308
393
|
function sampleString(s) {
|
|
309
|
-
|
|
394
|
+
let base = (s.format && FORMATS[s.format]) ?? "string";
|
|
395
|
+
// A cut-off date or address would break its format as much as the length, so only plain strings are shortened.
|
|
396
|
+
if (!s.format && s.maxLength !== undefined && base.length > s.maxLength)
|
|
397
|
+
base = base.slice(0, s.maxLength);
|
|
310
398
|
return base.length >= (s.minLength ?? 0) ? base : base.padEnd(s.minLength, "x");
|
|
311
399
|
}
|
|
400
|
+
/** A number inside the schema's bounds (3.0's `exclusiveMinimum: true` and 3.1's numeric form), on its `multipleOf` step. */
|
|
401
|
+
function sampleNumber(s, integer) {
|
|
402
|
+
const bound = (value, exclusive) => {
|
|
403
|
+
if (typeof s[exclusive] === "number")
|
|
404
|
+
return { at: s[exclusive], open: true };
|
|
405
|
+
if (typeof s[value] === "number")
|
|
406
|
+
return { at: s[value], open: s[exclusive] === true };
|
|
407
|
+
return undefined;
|
|
408
|
+
};
|
|
409
|
+
const lo = bound("minimum", "exclusiveMinimum");
|
|
410
|
+
const hi = bound("maximum", "exclusiveMaximum");
|
|
411
|
+
let n = 0;
|
|
412
|
+
if (lo)
|
|
413
|
+
n = lo.open ? lo.at + 1 : lo.at;
|
|
414
|
+
else if (hi)
|
|
415
|
+
n = hi.open ? hi.at - 1 : Math.min(0, hi.at);
|
|
416
|
+
// The step past an open lower bound can overshoot a close upper one.
|
|
417
|
+
if (lo && hi && (hi.open ? n >= hi.at : n > hi.at))
|
|
418
|
+
n = lo.open ? (lo.at + hi.at) / 2 : lo.at;
|
|
419
|
+
if (typeof s.multipleOf === "number" && s.multipleOf > 0) {
|
|
420
|
+
n = Math.ceil(n / s.multipleOf) * s.multipleOf;
|
|
421
|
+
if (lo?.open && n <= lo.at)
|
|
422
|
+
n += s.multipleOf;
|
|
423
|
+
}
|
|
424
|
+
return integer ? Math.ceil(n) : n;
|
|
425
|
+
}
|
|
312
426
|
function pickResponse(responses, status) {
|
|
313
427
|
const s = String(status);
|
|
314
428
|
return [s, `${s[0]}XX`, `${s[0]}xx`, "default"].find((k) => k in responses);
|
|
@@ -346,7 +460,8 @@ export function formatCoverage(spec, hits) {
|
|
|
346
460
|
return { covered, total: keys.length, percent, markdown, text: `slicetest: OpenAPI coverage (${spec.file}): ${covered}/${keys.length} documented responses (${percent}%)\n${lines.join("\n")}` };
|
|
347
461
|
}
|
|
348
462
|
function isJson(media) {
|
|
349
|
-
|
|
463
|
+
const type = media.split(";")[0].trim();
|
|
464
|
+
return /[/+]json$/i.test(type) || type === "*/*";
|
|
350
465
|
}
|
|
351
466
|
function escape(s) {
|
|
352
467
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
@@ -359,6 +474,17 @@ export function nullableToType(node) {
|
|
|
359
474
|
return node;
|
|
360
475
|
for (const [k, v] of Object.entries(node))
|
|
361
476
|
node[k] = nullableToType(v);
|
|
477
|
+
// 3.0 writes `minimum: 0, exclusiveMinimum: true`; JSON Schema (and Ajv) want `exclusiveMinimum: 0`.
|
|
478
|
+
for (const [flag, bound] of [["exclusiveMinimum", "minimum"], ["exclusiveMaximum", "maximum"]]) {
|
|
479
|
+
if (typeof node[flag] !== "boolean")
|
|
480
|
+
continue;
|
|
481
|
+
if (node[flag] && typeof node[bound] === "number") {
|
|
482
|
+
node[flag] = node[bound];
|
|
483
|
+
delete node[bound];
|
|
484
|
+
}
|
|
485
|
+
else
|
|
486
|
+
delete node[flag];
|
|
487
|
+
}
|
|
362
488
|
if (node.nullable === true) {
|
|
363
489
|
if (node.enum && !node.enum.includes(null))
|
|
364
490
|
node.enum = [...node.enum, null];
|
package/dist/record-cli.js
CHANGED
|
@@ -4,6 +4,7 @@ import os from "node:os";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { createInterface } from "node:readline";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { compatStart } from "./cli-run.js";
|
|
7
8
|
import { resolveOptions } from "./config.js";
|
|
8
9
|
import { SESSION_ENV } from "./record.js";
|
|
9
10
|
const ext = path.extname(fileURLToPath(import.meta.url));
|
|
@@ -29,7 +30,7 @@ export async function record(configPath, options, { out, port = 0 }) {
|
|
|
29
30
|
process.env[SESSION_ENV] = JSON.stringify(session);
|
|
30
31
|
const stop = () => void writeFile(session.stopFile, "").catch(() => { });
|
|
31
32
|
process.once("SIGINT", stop);
|
|
32
|
-
const
|
|
33
|
+
const startVitest = compatStart(await import("vitest/node"));
|
|
33
34
|
const { slicetest } = await import("./vitest.js");
|
|
34
35
|
const announce = watchState(session.stateFile, (state) => {
|
|
35
36
|
process.stdout.write(`\nRecording. Use the app through ${state.url} (it forwards to ${state.app}).\n` +
|
package/dist/record.js
CHANGED
|
@@ -5,6 +5,7 @@ import path from "node:path";
|
|
|
5
5
|
import { stringify } from "yaml";
|
|
6
6
|
import { parseForm } from "./stub.js";
|
|
7
7
|
import { mask } from "./trace.js";
|
|
8
|
+
import { requestUrl } from "./request-url.js";
|
|
8
9
|
export const SESSION_ENV = "SLICETEST_RECORD_SESSION";
|
|
9
10
|
/** Requests a browser makes on its own, not part of what the scenario is about. */
|
|
10
11
|
const ASSET = /\.(ico|png|jpe?g|gif|webp|avif|svg|css|js|mjs|map|woff2?|ttf|eot)(\?|$)/i;
|
|
@@ -27,7 +28,7 @@ export async function runSession(ctx, session) {
|
|
|
27
28
|
headers.append(k, value);
|
|
28
29
|
}
|
|
29
30
|
try {
|
|
30
|
-
const upstream = await fetch(
|
|
31
|
+
const upstream = await fetch(requestUrl(req.url, target), {
|
|
31
32
|
method: req.method,
|
|
32
33
|
headers,
|
|
33
34
|
body: body.length && req.method !== "GET" && req.method !== "HEAD" ? body : undefined,
|
package/dist/recording.d.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { RecordedCall, StubResponse } from "./stub.js";
|
|
2
2
|
/** One recorded exchange with the real service, as stored in the recordings file. */
|
|
3
3
|
export interface Recording {
|
|
4
|
+
/** A parameter sent more than once (`?ids=1&ids=2`) is a list of its values, in order. */
|
|
4
5
|
request: {
|
|
5
6
|
method: string;
|
|
6
7
|
path: string;
|
|
7
|
-
query?: Record<string, string>;
|
|
8
|
+
query?: Record<string, string | string[]>;
|
|
8
9
|
json?: unknown;
|
|
9
10
|
body?: string;
|
|
10
11
|
};
|
|
@@ -38,6 +39,15 @@ export declare class Recorder {
|
|
|
38
39
|
/** Recordings made by this worker, to be merged into the file when the run ends. */
|
|
39
40
|
added(): Recording[];
|
|
40
41
|
}
|
|
42
|
+
/** Query parameters by name, sorted; a repeated one as the list of its values. */
|
|
43
|
+
export declare function queryOf(params: URLSearchParams): Record<string, string | string[]>;
|
|
44
|
+
export declare const REDACTED = "[redacted]";
|
|
45
|
+
/**
|
|
46
|
+
* A request with credential values replaced by `[redacted]`, so recordings can be committed and
|
|
47
|
+
* replay whatever key the test environment uses. Applied to recordings and to calls alike before
|
|
48
|
+
* they are compared.
|
|
49
|
+
*/
|
|
50
|
+
export declare function redactRequest(r: Recording["request"]): Recording["request"];
|
|
41
51
|
export declare function readRecordings(file: string): Promise<Recording[]>;
|
|
42
52
|
/** Append new recordings to `file`, skipping exact duplicates, keeping the order they were made in. */
|
|
43
53
|
export declare function mergeRecordings(file: string, upstream: string, added: Recording[], source?: string): Promise<void>;
|
package/dist/recording.js
CHANGED
|
@@ -88,16 +88,57 @@ export class Recorder {
|
|
|
88
88
|
};
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
|
+
/** Query parameters by name, sorted; a repeated one as the list of its values. */
|
|
92
|
+
export function queryOf(params) {
|
|
93
|
+
return Object.fromEntries([...new Set(params.keys())].sort((a, b) => a.localeCompare(b)).map((k) => {
|
|
94
|
+
const all = params.getAll(k);
|
|
95
|
+
return [k, all.length > 1 ? all : all[0]];
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
91
98
|
function requestOf(call) {
|
|
92
|
-
const query =
|
|
93
|
-
return {
|
|
99
|
+
const query = queryOf(call.query);
|
|
100
|
+
return redactRequest({
|
|
94
101
|
method: call.method,
|
|
95
102
|
path: call.path,
|
|
96
103
|
...(Object.keys(query).length ? { query } : {}),
|
|
97
104
|
...(call.json !== undefined ? { json: call.json } : call.body ? { body: call.body } : {}),
|
|
98
|
-
};
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
/** Credentials sent in a query, a form or JSON body: `api_key`, `key`, `access_token`, `client_secret`, `password`, `appid`, signatures. */
|
|
108
|
+
const SECRET = /^(api[-_]?key|apikey|key|(access|refresh|id|auth)[-_]?token|token|client[-_]?secret|app[-_]?secret|secret|password|passwd|appid|signature|sig|x-amz-(signature|credential|security-token))$/i;
|
|
109
|
+
/** In bodies, a bare `key` is usually data (a KV store's key, a record's key) that tells requests apart, not a credential. */
|
|
110
|
+
const bodySecret = (k) => SECRET.test(k) && k.toLowerCase() !== "key";
|
|
111
|
+
export const REDACTED = "[redacted]";
|
|
112
|
+
const FORM_BODY = /^[^\s=&]+=[^\s&]*(&[^\s=&]+=[^\s&]*)*$/;
|
|
113
|
+
/**
|
|
114
|
+
* A request with credential values replaced by `[redacted]`, so recordings can be committed and
|
|
115
|
+
* replay whatever key the test environment uses. Applied to recordings and to calls alike before
|
|
116
|
+
* they are compared.
|
|
117
|
+
*/
|
|
118
|
+
export function redactRequest(r) {
|
|
119
|
+
const out = { ...r };
|
|
120
|
+
if (r.query)
|
|
121
|
+
out.query = Object.fromEntries(Object.entries(r.query).map(([k, v]) => [k, !SECRET.test(k) ? v : Array.isArray(v) ? v.map(() => REDACTED) : REDACTED]));
|
|
122
|
+
if (r.json !== undefined)
|
|
123
|
+
out.json = redactJson(r.json);
|
|
124
|
+
if (r.body && FORM_BODY.test(r.body)) {
|
|
125
|
+
const form = new URLSearchParams(r.body);
|
|
126
|
+
if ([...form.keys()].some(bodySecret)) {
|
|
127
|
+
out.body = [...form].map(([k, v]) => `${encodeURIComponent(k)}=${bodySecret(k) ? REDACTED : encodeURIComponent(v)}`).join("&");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
function redactJson(v) {
|
|
133
|
+
if (Array.isArray(v))
|
|
134
|
+
return v.map(redactJson);
|
|
135
|
+
if (v && typeof v === "object")
|
|
136
|
+
return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, typeof x === "string" && bodySecret(k) ? REDACTED : redactJson(x)]));
|
|
137
|
+
return v;
|
|
99
138
|
}
|
|
100
|
-
function keyOf(
|
|
139
|
+
function keyOf(request) {
|
|
140
|
+
// Recordings made before redaction still match: both sides are compared redacted.
|
|
141
|
+
const r = redactRequest(request);
|
|
101
142
|
return JSON.stringify([r.method.toUpperCase(), r.path, sortKeys(r.query ?? {}), sortKeys(r.json ?? null), r.body ?? ""]);
|
|
102
143
|
}
|
|
103
144
|
function sortKeys(v) {
|
|
@@ -108,7 +149,11 @@ function sortKeys(v) {
|
|
|
108
149
|
return v;
|
|
109
150
|
}
|
|
110
151
|
function toResponse(e) {
|
|
111
|
-
|
|
152
|
+
const { status, headers, json, body } = e.response;
|
|
153
|
+
// A string is sent as it is; a JSON string reply (`"ok"`) needs its quotes back.
|
|
154
|
+
if (typeof json === "string")
|
|
155
|
+
return { status, headers: { "content-type": "application/json", ...headers }, body: JSON.stringify(json) };
|
|
156
|
+
return { status, headers, body: json !== undefined ? json : (body ?? "") };
|
|
112
157
|
}
|
|
113
158
|
function parse(text) {
|
|
114
159
|
if (!text)
|
|
@@ -143,6 +188,6 @@ export async function mergeRecordings(file, upstream, added, source = `Recorded
|
|
|
143
188
|
if (!entries.some((x) => isDeepStrictEqual(x, e)))
|
|
144
189
|
entries.push(e);
|
|
145
190
|
await mkdir(path.dirname(file), { recursive: true });
|
|
146
|
-
const header = `# ${source}. Review before committing:
|
|
191
|
+
const header = `# ${source}. Review before committing: credentials in queries and bodies (api_key, access_token, client_secret, password, …) are replaced by ${REDACTED}, but other values are stored as sent.\n`;
|
|
147
192
|
await writeFile(file, header + YAML.stringify(entries, { lineWidth: 0 }));
|
|
148
193
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function requestUrl(target: string | undefined, base: string | URL): URL;
|
package/dist/runtime.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { App } from "./app.js";
|
|
2
2
|
import { Issuer } from "./auth.js";
|
|
3
|
-
import type
|
|
3
|
+
import { type ResolvedOptions } from "./config.js";
|
|
4
4
|
import { Dependency } from "./containers.js";
|
|
5
5
|
import { connectionVars } from "./connection.js";
|
|
6
6
|
import { Db } from "./db.js";
|
|
@@ -63,8 +63,8 @@ export declare class Runtime {
|
|
|
63
63
|
}): Promise<Runtime>;
|
|
64
64
|
context(): ScenarioContext;
|
|
65
65
|
beforeScenario(): Promise<void>;
|
|
66
|
-
/**
|
|
67
|
-
afterScenario(): Promise<void>;
|
|
66
|
+
/** `deadline`: when the test's own timeout is about to end, so `idle` reports before Vitest does. */
|
|
67
|
+
afterScenario(deadline?: number): Promise<void>;
|
|
68
68
|
/** The current scenario as a Mermaid sequence diagram. */
|
|
69
69
|
diagram(): Promise<string>;
|
|
70
70
|
/**
|