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.
Files changed (63) hide show
  1. package/README.md +131 -35
  2. package/dist/app.js +10 -2
  3. package/dist/auth.js +2 -1
  4. package/dist/cli-run.d.ts +53 -0
  5. package/dist/cli-run.js +80 -0
  6. package/dist/cli.js +27 -29
  7. package/dist/config.d.ts +33 -2
  8. package/dist/config.js +121 -2
  9. package/dist/container-runtime.d.ts +4 -0
  10. package/dist/container-runtime.js +16 -0
  11. package/dist/containers.js +4 -3
  12. package/dist/db.d.ts +3 -1
  13. package/dist/db.js +45 -8
  14. package/dist/deadline.d.ts +7 -0
  15. package/dist/deadline.js +24 -0
  16. package/dist/doctor.js +27 -5
  17. package/dist/drivers/driver.d.ts +6 -0
  18. package/dist/drivers/mysql.js +5 -1
  19. package/dist/drivers/postgres.d.ts +2 -0
  20. package/dist/drivers/postgres.js +20 -1
  21. package/dist/exit.d.ts +1 -0
  22. package/dist/exit.js +28 -0
  23. package/dist/form.d.ts +2 -0
  24. package/dist/form.js +62 -9
  25. package/dist/global-setup.js +3 -6
  26. package/dist/har.js +4 -4
  27. package/dist/http.d.ts +38 -4
  28. package/dist/http.js +135 -10
  29. package/dist/init.js +64 -10
  30. package/dist/list.d.ts +1 -0
  31. package/dist/list.js +16 -3
  32. package/dist/mail.d.ts +1 -1
  33. package/dist/mail.js +7 -7
  34. package/dist/matchers.d.ts +18 -6
  35. package/dist/matchers.js +75 -8
  36. package/dist/openapi.d.ts +2 -5
  37. package/dist/openapi.js +146 -20
  38. package/dist/record-cli.js +2 -1
  39. package/dist/record.js +2 -1
  40. package/dist/recording.d.ts +11 -1
  41. package/dist/recording.js +51 -6
  42. package/dist/request-url.d.ts +1 -0
  43. package/dist/request-url.js +5 -0
  44. package/dist/runtime.d.ts +3 -3
  45. package/dist/runtime.js +144 -18
  46. package/dist/scenario.js +11 -2
  47. package/dist/sql-migrations.d.ts +11 -0
  48. package/dist/sql-migrations.js +38 -0
  49. package/dist/stub.d.ts +10 -3
  50. package/dist/stub.js +64 -16
  51. package/dist/vitest.d.ts +4 -0
  52. package/dist/vitest.js +6 -2
  53. package/dist/webhook.d.ts +3 -3
  54. package/dist/webhook.js +10 -2
  55. package/dist/yaml-runtime.d.ts +4 -4
  56. package/dist/yaml-runtime.js +141 -38
  57. package/dist/yaml.d.ts +12 -2
  58. package/dist/yaml.js +47 -4
  59. package/docs/guarantees.md +92 -0
  60. package/docs/state-isolation.md +94 -0
  61. package/package.json +4 -1
  62. package/schema/config.schema.json +675 -0
  63. 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
- * An OpenAPI 3.0 / 3.1 document, used to check that real traffic matches it:
10
- * the app's responses against the app's own spec, and the app's calls to a
11
- * stubbed service (and the stub's canned replies) against that service's spec.
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
- .map((s) => new URL(s.url ?? "/", "http://x").pathname.replace(/\/$/, ""))
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
- return this.#checkContent(response?.content, res, [...at, "content"], `${method} ${template} → ${status}`);
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) }, () => this.#sample(s.items, depth + 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 typeof s.minimum === "number" ? s.minimum : typeof s.exclusiveMinimum === "number" ? s.exclusiveMinimum + 1 : 0;
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 params = [...(this.doc.paths[template].parameters ?? []), ...(op.parameters ?? [])].map((p) => this.#deref(p));
236
- for (const p of params) {
237
- if (p?.in === "query" && p.required && !req.query?.has(p.name))
238
- errors.push(`${method} ${template}: required query parameter "${p.name}" is missing`);
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
- const media = Object.keys(content).find((k) => k.toLowerCase() === type) ??
258
- Object.keys(content).find((k) => k.endsWith("/*") && type.startsWith(k.slice(0, -1))) ??
259
- Object.keys(content).find((k) => k === "*/*");
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
- const base = (s.format && FORMATS[s.format]) ?? "string";
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
- return /[/+]json$/i.test(media) || media === "*/*";
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];
@@ -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 { startVitest } = await import("vitest/node");
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(new URL(req.url ?? "/", target), {
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,
@@ -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 = Object.fromEntries([...call.query.entries()].sort(([a], [b]) => a.localeCompare(b)));
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(r) {
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
- return { status: e.response.status, headers: e.response.headers, body: e.response.json !== undefined ? e.response.json : (e.response.body ?? "") };
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: request bodies are stored as sent.\n`;
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;
@@ -0,0 +1,5 @@
1
+ // Not `new URL(target, base)`: for `//users/42` that reads `users` as a host, and the path is lost.
2
+ export function requestUrl(target, base) {
3
+ const raw = target ?? "/";
4
+ return raw.startsWith("/") ? new URL(new URL(base).origin + raw) : new URL(raw, base);
5
+ }
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 { ResolvedOptions } from "./config.js";
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
- /** Failures that the scenario body can't see on its own. */
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
  /**