slicetest 0.9.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 (59) hide show
  1. package/README.md +118 -30
  2. package/dist/app.js +10 -2
  3. package/dist/auth.js +2 -1
  4. package/dist/cli-run.d.ts +40 -0
  5. package/dist/cli-run.js +65 -0
  6. package/dist/cli.js +20 -34
  7. package/dist/config.d.ts +33 -2
  8. package/dist/config.js +82 -8
  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 +1 -0
  24. package/dist/form.js +50 -6
  25. package/dist/har.js +2 -2
  26. package/dist/http.d.ts +9 -3
  27. package/dist/http.js +40 -5
  28. package/dist/init.js +11 -2
  29. package/dist/list.d.ts +1 -0
  30. package/dist/list.js +16 -3
  31. package/dist/mail.d.ts +1 -1
  32. package/dist/mail.js +7 -7
  33. package/dist/matchers.d.ts +4 -3
  34. package/dist/matchers.js +18 -6
  35. package/dist/openapi.d.ts +2 -0
  36. package/dist/openapi.js +133 -14
  37. package/dist/record-cli.js +2 -1
  38. package/dist/record.js +2 -1
  39. package/dist/recording.d.ts +4 -1
  40. package/dist/recording.js +14 -3
  41. package/dist/request-url.d.ts +1 -0
  42. package/dist/request-url.js +5 -0
  43. package/dist/runtime.d.ts +3 -3
  44. package/dist/runtime.js +132 -18
  45. package/dist/scenario.js +11 -2
  46. package/dist/stub.js +17 -6
  47. package/dist/vitest.d.ts +4 -0
  48. package/dist/vitest.js +6 -2
  49. package/dist/webhook.d.ts +3 -3
  50. package/dist/webhook.js +10 -2
  51. package/dist/yaml-runtime.d.ts +4 -4
  52. package/dist/yaml-runtime.js +96 -34
  53. package/dist/yaml.d.ts +4 -0
  54. package/dist/yaml.js +33 -2
  55. package/docs/guarantees.md +92 -0
  56. package/docs/state-isolation.md +94 -0
  57. package/package.json +4 -1
  58. package/schema/config.schema.json +675 -0
  59. package/schema/scenario.schema.json +27 -3
package/dist/openapi.js CHANGED
@@ -112,7 +112,8 @@ export class OpenApiSpec {
112
112
  if (!key)
113
113
  return [`${method} ${template} responded ${status}, which ${this.file} doesn't document (documented: ${Object.keys(responses).join(", ") || "none"})`];
114
114
  const [response, at] = this.#resolve(responses[key], ["paths", template, found.method, "responses", key]);
115
- 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)];
116
117
  }
117
118
  /**
118
119
  * A response the real service could send to `method path`: the lowest
@@ -133,8 +134,13 @@ export class OpenApiSpec {
133
134
  const response = this.#deref(responses[key]);
134
135
  const content = response?.content ?? {};
135
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))]));
136
142
  if (!media)
137
- return { status };
143
+ return { status, ...(Object.keys(required).length ? { headers: required } : {}) };
138
144
  const m = content[media] ?? {};
139
145
  const named = m.examples && Object.values(m.examples)[0];
140
146
  const body = m.example !== undefined
@@ -142,7 +148,7 @@ export class OpenApiSpec {
142
148
  : named !== undefined
143
149
  ? (this.#deref(named)?.value ?? null)
144
150
  : this.#sample(m.schema);
145
- 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 ?? "") };
146
152
  }
147
153
  /**
148
154
  * Every operation with what it takes to call it: sample values for its
@@ -220,10 +226,14 @@ export class OpenApiSpec {
220
226
  case "object":
221
227
  return Object.fromEntries(Object.entries(s.properties ?? {}).map(([k, v]) => [k, this.#sample(v, depth + 1)]));
222
228
  case "array":
223
- 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
+ });
224
234
  case "integer":
225
235
  case "number":
226
- return typeof s.minimum === "number" ? s.minimum : typeof s.exclusiveMinimum === "number" ? s.exclusiveMinimum + 1 : 0;
236
+ return sampleNumber(s, type === "integer");
227
237
  case "boolean":
228
238
  return true;
229
239
  case "string":
@@ -239,10 +249,23 @@ export class OpenApiSpec {
239
249
  return [`${method} ${path} is not in ${this.file}`];
240
250
  const { template, op } = found;
241
251
  const errors = [];
242
- const params = [...(this.doc.paths[template].parameters ?? []), ...(op.parameters ?? [])].map((p) => this.#deref(p));
243
- for (const p of params) {
244
- if (p?.in === "query" && p.required && !req.query?.has(p.name))
245
- 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
+ }
246
269
  }
247
270
  const [body, at] = this.#resolve(op.requestBody, ["paths", template, found.method, "requestBody"]);
248
271
  if (!body)
@@ -254,6 +277,59 @@ export class OpenApiSpec {
254
277
  }
255
278
  return [...errors, ...this.#checkContent(body.content, req, [...at, "content"], `${method} ${template} request`)];
256
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
+ }
257
333
  #checkContent(content, msg, pointer, label) {
258
334
  if (!content || Object.keys(content).length === 0)
259
335
  return [];
@@ -261,9 +337,11 @@ export class OpenApiSpec {
261
337
  // Without a content-type (e.g. a stub replying with a bare string) there's nothing to hold the body to.
262
338
  if (!type)
263
339
  return [];
264
- const media = Object.keys(content).find((k) => k.toLowerCase() === type) ??
265
- Object.keys(content).find((k) => k.endsWith("/*") && type.startsWith(k.slice(0, -1))) ??
266
- 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) === "*/*");
267
345
  if (!media)
268
346
  return [`${label}: content-type "${type || "(none)"}" is not one of ${Object.keys(content).join(", ")}`];
269
347
  if (!content[media]?.schema || !isJson(media))
@@ -313,9 +391,38 @@ const FORMATS = {
313
391
  ipv6: "2001:db8::1",
314
392
  };
315
393
  function sampleString(s) {
316
- 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);
317
398
  return base.length >= (s.minLength ?? 0) ? base : base.padEnd(s.minLength, "x");
318
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
+ }
319
426
  function pickResponse(responses, status) {
320
427
  const s = String(status);
321
428
  return [s, `${s[0]}XX`, `${s[0]}xx`, "default"].find((k) => k in responses);
@@ -353,7 +460,8 @@ export function formatCoverage(spec, hits) {
353
460
  return { covered, total: keys.length, percent, markdown, text: `slicetest: OpenAPI coverage (${spec.file}): ${covered}/${keys.length} documented responses (${percent}%)\n${lines.join("\n")}` };
354
461
  }
355
462
  function isJson(media) {
356
- return /[/+]json$/i.test(media) || media === "*/*";
463
+ const type = media.split(";")[0].trim();
464
+ return /[/+]json$/i.test(type) || type === "*/*";
357
465
  }
358
466
  function escape(s) {
359
467
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -366,6 +474,17 @@ export function nullableToType(node) {
366
474
  return node;
367
475
  for (const [k, v] of Object.entries(node))
368
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
+ }
369
488
  if (node.nullable === true) {
370
489
  if (node.enum && !node.enum.includes(null))
371
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,8 @@ 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[]>;
41
44
  export declare const REDACTED = "[redacted]";
42
45
  /**
43
46
  * A request with credential values replaced by `[redacted]`, so recordings can be committed and
package/dist/recording.js CHANGED
@@ -88,8 +88,15 @@ 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)));
99
+ const query = queryOf(call.query);
93
100
  return redactRequest({
94
101
  method: call.method,
95
102
  path: call.path,
@@ -111,7 +118,7 @@ const FORM_BODY = /^[^\s=&]+=[^\s&]*(&[^\s=&]+=[^\s&]*)*$/;
111
118
  export function redactRequest(r) {
112
119
  const out = { ...r };
113
120
  if (r.query)
114
- out.query = Object.fromEntries(Object.entries(r.query).map(([k, v]) => [k, SECRET.test(k) ? REDACTED : v]));
121
+ out.query = Object.fromEntries(Object.entries(r.query).map(([k, v]) => [k, !SECRET.test(k) ? v : Array.isArray(v) ? v.map(() => REDACTED) : REDACTED]));
115
122
  if (r.json !== undefined)
116
123
  out.json = redactJson(r.json);
117
124
  if (r.body && FORM_BODY.test(r.body)) {
@@ -142,7 +149,11 @@ function sortKeys(v) {
142
149
  return v;
143
150
  }
144
151
  function toResponse(e) {
145
- 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 ?? "") };
146
157
  }
147
158
  function parse(text) {
148
159
  if (!text)
@@ -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
  /**
package/dist/runtime.js CHANGED
@@ -4,6 +4,7 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { App } from "./app.js";
6
6
  import { Issuer } from "./auth.js";
7
+ import { seedFiles } from "./config.js";
7
8
  import { Dependency } from "./containers.js";
8
9
  import { connectionVars } from "./connection.js";
9
10
  import { Db, formatChanges, noDatabase } from "./db.js";
@@ -125,7 +126,7 @@ export class Runtime {
125
126
  schemas: opts.db.schemas,
126
127
  keep: opts.db.keep,
127
128
  ignoreChanges: opts.db.ignoreChanges,
128
- seedFile: opts.db.seed && path.resolve(opts.root, opts.db.seed),
129
+ seedFiles: seedFiles(opts.db.seed).map((f) => path.resolve(opts.root, f)),
129
130
  })
130
131
  : noDatabase();
131
132
  const started = await Promise.allSettled(Object.entries(opts.containers).map(async ([name, c]) => containers.set(name, await Dependency.start(name, c))));
@@ -234,7 +235,7 @@ export class Runtime {
234
235
  throw new Error(`slicetest: unknown container "${name}". Declared containers: ${[...this.containers.keys()].join(", ") || "(none)"}`);
235
236
  return c;
236
237
  },
237
- trace: async (opts) => mask(buildTrace(this.http.history, this.stubs.values(), await this.db.changesSinceStart(), this.mailbox), opts),
238
+ trace: async (opts) => mask(buildTrace(this.http.requests, this.stubs.values(), await this.db.changesSinceStart(), this.mailbox), opts),
238
239
  diagram: () => this.diagram(),
239
240
  get mail() {
240
241
  if (!mailbox)
@@ -252,6 +253,34 @@ export class Runtime {
252
253
  #processes() {
253
254
  return [this.app, ...this.services.values()];
254
255
  }
256
+ // Marked busy until afterScenario() sees `idle` answer 2xx, so a scenario that failed, or that Vitest
257
+ // timed out mid-wait, leaves its processes flagged and the next scenario restarts them.
258
+ #busy = new Set();
259
+ #queue = Promise.resolve();
260
+ /** Not concurrent: a scenario Vitest timed out leaves its beforeScenario() running, and a second would race it for `this.app`. */
261
+ #serial(fn) {
262
+ const run = this.#queue.then(fn);
263
+ this.#queue = run.catch(() => { });
264
+ return run;
265
+ }
266
+ #hooked(hook) {
267
+ const all = [
268
+ { name: "app", proc: this.app, options: this.opts.app },
269
+ ...[...this.services].map(([n, proc]) => ({ name: `services.${n}`, proc, options: this.opts.services[n] })),
270
+ ];
271
+ return all.filter((p) => p.options[hook] && !p.proc.exited);
272
+ }
273
+ /** Every busy process fails the scenario, not only the first, and each is flagged for a restart. */
274
+ async #waitIdle(deadline) {
275
+ const hooked = this.#hooked("idle");
276
+ const results = await Promise.allSettled(hooked.map(async ({ name, proc, options }) => {
277
+ await pollIdle(proc, options.idle, `${name}.idle`, deadline);
278
+ this.#busy.delete(proc);
279
+ }));
280
+ const failed = results.flatMap((r) => (r.status === "rejected" ? [r.reason.message] : []));
281
+ if (failed.length > 0)
282
+ throw new Error(failed.join("\n"));
283
+ }
255
284
  #usageDir;
256
285
  /** Note which provider operations the stubs were called for, before their calls are cleared. */
257
286
  #collectUsage() {
@@ -266,20 +295,30 @@ export class Runtime {
266
295
  this.#used.set(name, used);
267
296
  }
268
297
  }
269
- async beforeScenario() {
298
+ beforeScenario() {
299
+ return this.#serial(() => this.#before());
300
+ }
301
+ /** `deadline`: when the test's own timeout is about to end, so `idle` reports before Vitest does. */
302
+ afterScenario(deadline) {
303
+ return this.#serial(() => this.#after(deadline));
304
+ }
305
+ async #before() {
270
306
  this.#collectUsage();
271
- // A crash already failed the scenario that caused it; give the next one a fresh process.
272
- for (const [name, service] of this.services) {
273
- if (!service.exited)
274
- continue;
275
- await service.stop();
276
- this.services.set(name, await App.start(this.opts.services[name], this.opts.root, this.vars, `service.${name}`, service.port));
307
+ // A failed scenario skipped afterScenario(). One look, not a wait: it already failed, and a restart ends the work.
308
+ for (const { proc, options } of this.#hooked("idle")) {
309
+ if (this.#busy.has(proc) && (await probeIdle(proc, options.idle)))
310
+ this.#busy.delete(proc);
277
311
  }
278
- if (this.app.exited) {
312
+ // Stop before the reset, start after. Not start-then-reset: the old process could still write into the
313
+ // fresh database, and the new one would boot against stale rows.
314
+ const restartService = (name, service) => service.exited || this.#busy.has(service) || this.opts.services[name].restart === "scenario";
315
+ const services = [...this.services].filter(([name, service]) => restartService(name, service));
316
+ const restartApp = !!this.app.exited || this.#busy.has(this.app) || this.opts.app.restart === "scenario";
317
+ if (restartApp)
279
318
  await this.app.stop();
280
- this.app = await App.start(this.opts.app, this.opts.root, this.vars);
281
- this.#http = this.#client();
282
- }
319
+ for (const [, service] of [...services].reverse())
320
+ await service.stop();
321
+ this.#busy.clear();
283
322
  this.#contract = [];
284
323
  await this.db.reset();
285
324
  for (const stub of this.stubs.values())
@@ -293,13 +332,27 @@ export class Runtime {
293
332
  this.interceptor?.blocked.clear();
294
333
  await Promise.all([...this.containers.values()].map((c) => c.reset()));
295
334
  this.http.reset();
335
+ for (const [name, old] of services) {
336
+ this.services.set(name, await App.start(this.opts.services[name], this.opts.root, this.vars, `service.${name}`, old.port));
337
+ }
338
+ if (restartApp) {
339
+ this.app = await App.start(this.opts.app, this.opts.root, this.vars);
340
+ this.#http = this.#client();
341
+ }
296
342
  for (const p of this.#processes())
297
343
  p.beginScenario();
344
+ // Not before the start: a cache filled at boot would hold the pre-reset rows.
345
+ for (const { name, proc, options } of this.#hooked("reset")) {
346
+ await callHook(proc, options.reset, `${name}.reset`);
347
+ }
348
+ for (const { proc } of this.#hooked("idle"))
349
+ this.#busy.add(proc);
298
350
  }
299
351
  /** Failures that the scenario body can't see on its own. */
300
- async afterScenario() {
352
+ async #after(deadline) {
301
353
  await Promise.all(this.#processes().map((p) => p.settle()));
302
354
  this.#assertAlive();
355
+ await this.#waitIdle(deadline);
303
356
  if (this.interceptor?.blocked.size)
304
357
  throw new Error(blockedHint([...this.interceptor.blocked]));
305
358
  const thrown = this.#handlerErrors();
@@ -340,12 +393,12 @@ export class Runtime {
340
393
  #checkStubCall(name, spec, call) {
341
394
  if (!call.matched)
342
395
  return []; // already reported as an unmatched call
343
- const req = { contentType: header(call.headers["content-type"]), body: call.json ?? call.body, query: call.query };
396
+ const req = { contentType: header(call.headers["content-type"]), body: call.json ?? call.body, query: call.query, headers: call.headers };
344
397
  const out = spec.checkRequest(call.method, call.path, req).map((c) => `app → ${name}: ${c}`);
345
398
  const res = call.response;
346
399
  if (res) {
347
400
  const body = res.body ? (parseJson(res.body) ?? res.body) : "";
348
- const problems = spec.checkResponse(call.method, call.path, { status: res.status, contentType: res.headers["content-type"], body });
401
+ const problems = spec.checkResponse(call.method, call.path, { status: res.status, contentType: res.headers["content-type"], body, headers: Object.fromEntries(Object.entries(res.headers).map(([k, v]) => [k.toLowerCase(), v])) });
349
402
  out.push(...problems.map((c) => `stub ${name} reply (the real service wouldn't answer this way): ${c}`));
350
403
  }
351
404
  return out;
@@ -375,7 +428,7 @@ export class Runtime {
375
428
  /** The current scenario as a Mermaid sequence diagram. */
376
429
  async diagram() {
377
430
  const changes = this.opts.db.none ? undefined : await this.db.changesSinceStart().catch(() => undefined);
378
- return sequenceDiagram(this.http.history, this.stubs.values(), changes, this.mailbox);
431
+ return sequenceDiagram(this.http.requests, this.stubs.values(), changes, this.mailbox);
379
432
  }
380
433
  /**
381
434
  * After a scenario: its diagram goes to the `SLICETEST_DIAGRAMS` directory (one Markdown page
@@ -511,8 +564,69 @@ async function ensureWorkerDatabase(engine, adminUrl, template, prefix) {
511
564
  await admin.close();
512
565
  }
513
566
  }
567
+ const HOOK_TIMEOUT = 5000;
568
+ /** Not `new URL(path, base)` alone: `//host/x` and `/\host` would send the request to another host. */
569
+ function hookUrl(proc, hook) {
570
+ const url = new URL(hook.path, proc.url);
571
+ if (url.origin !== new URL(proc.url).origin)
572
+ throw new Error(`slicetest: hook path ${JSON.stringify(hook.path)} leaves the ${proc.label} (${url.origin}); use a path on the app`);
573
+ return url;
574
+ }
575
+ async function callHook(proc, hook, where) {
576
+ const method = (hook.method ?? "POST").toUpperCase();
577
+ const what = `${method} ${hook.path}`;
578
+ let res;
579
+ try {
580
+ // Not following redirects: a 3xx must not move the request off the app.
581
+ res = await fetch(hookUrl(proc, hook), { method, redirect: "manual", signal: AbortSignal.timeout(hook.timeout ?? HOOK_TIMEOUT) });
582
+ }
583
+ catch (e) {
584
+ throw new Error(`slicetest: ${where}: ${what} failed: ${e.message}. The ${proc.label}'s in-process state wasn't reset, so this scenario could see the previous one's. Check that the ${proc.label} serves ${hook.path}.`);
585
+ }
586
+ const body = (await res.text().catch(() => "")).slice(0, 300);
587
+ if (res.status >= 300) {
588
+ throw new Error(`slicetest: ${where}: ${what} answered ${res.status}${body ? `: ${body}` : ""}. The ${proc.label}'s in-process state wasn't reset, so this scenario could see the previous one's.`);
589
+ }
590
+ }
591
+ /** One request: is the process idle right now. */
592
+ async function probeIdle(proc, hook) {
593
+ try {
594
+ const res = await fetch(hookUrl(proc, hook), { method: (hook.method ?? "GET").toUpperCase(), redirect: "manual", signal: AbortSignal.timeout(1000) });
595
+ await res.body?.cancel();
596
+ return res.ok;
597
+ }
598
+ catch {
599
+ return false;
600
+ }
601
+ }
602
+ /** Poll `app.idle` until it answers 2xx, for `timeout` or until `deadline` (epoch ms), whichever is first. */
603
+ async function pollIdle(proc, hook, where, deadline) {
604
+ const timeout = hook.timeout ?? HOOK_TIMEOUT;
605
+ const start = Date.now();
606
+ const limit = Math.min(start + timeout, deadline ?? Infinity);
607
+ const method = (hook.method ?? "GET").toUpperCase();
608
+ let last = "no answer";
609
+ for (;;) {
610
+ try {
611
+ const res = await fetch(hookUrl(proc, hook), { method, redirect: "manual", signal: AbortSignal.timeout(Math.max(100, Math.min(1000, limit - Date.now()))) });
612
+ const body = (await res.text().catch(() => "")).trim().slice(0, 200);
613
+ if (res.ok)
614
+ return;
615
+ last = `${res.status}${body ? ` ${body}` : ""}`;
616
+ }
617
+ catch (e) {
618
+ last = e.message;
619
+ }
620
+ if (Date.now() >= limit)
621
+ break;
622
+ await new Promise((r) => setTimeout(r, 50));
623
+ }
624
+ const cut = limit < start + timeout ? `; the test's own timeout left no more (raise it, or lower ${where}.timeout)` : "";
625
+ throw new Error(`slicetest: ${where}: the ${proc.label} still had background work after ${Date.now() - start}ms (${method} ${hook.path} last answered: ${last})${cut}. ` +
626
+ `Left running, it would write into a later scenario, so the ${proc.label} is restarted before the next one. Finish the work before the scenario ends, wait for it in the scenario, or raise the timeout.`);
627
+ }
514
628
  function message(res) {
515
- return { status: res.status, contentType: res.headers.get("content-type") ?? undefined, body: res.json ?? res.text };
629
+ return { status: res.status, contentType: res.headers.get("content-type") ?? undefined, body: res.json ?? res.text, headers: Object.fromEntries(res.headers) };
516
630
  }
517
631
  function header(v) {
518
632
  return Array.isArray(v) ? v[0] : v;
package/dist/scenario.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { clearScenarioDeadline, setScenarioDeadline } from "./deadline.js";
1
2
  import { test } from "vitest";
2
3
  // Kept on globalThis so it survives the setup file and the test file loading
3
4
  // separate copies of this module (e.g. one from dist, one through an alias).
@@ -35,9 +36,17 @@ function define(register) {
35
36
  console.error(`--- slicetest ---\n${await runtime.diagnostics()}\n-----------------`);
36
37
  await runtime.reportDiagram(file, task.name, true);
37
38
  });
39
+ const started = Date.now();
38
40
  await runtime.beforeScenario();
39
- await body(runtime.context());
40
- await runtime.afterScenario();
41
+ setScenarioDeadline(started, task.timeout);
42
+ try {
43
+ await body(runtime.context());
44
+ }
45
+ finally {
46
+ clearScenarioDeadline();
47
+ }
48
+ // Leaves a margin for the checks after the wait, so `idle` reports before Vitest's timeout does.
49
+ await runtime.afterScenario(task.timeout ? started + task.timeout - 500 : undefined);
41
50
  await runtime.reportDiagram(file, task.name, false);
42
51
  }, timeout);
43
52
  };