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/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,15 +332,32 @@ 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]));
358
+ const thrown = this.#handlerErrors();
359
+ if (thrown.length > 0)
360
+ throw new Error(`slicetest: a stub's reply threw, so the app got an error response instead of the reply:\n${thrown.join("\n")}`);
305
361
  const unmatched = this.#unmatched();
306
362
  if (unmatched.length > 0) {
307
363
  throw new Error(`slicetest: the app called stubbed services with no matching route:\n${unmatched.join("\n")}`);
@@ -337,16 +393,22 @@ export class Runtime {
337
393
  #checkStubCall(name, spec, call) {
338
394
  if (!call.matched)
339
395
  return []; // already reported as an unmatched call
340
- 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 };
341
397
  const out = spec.checkRequest(call.method, call.path, req).map((c) => `app → ${name}: ${c}`);
342
398
  const res = call.response;
343
399
  if (res) {
344
400
  const body = res.body ? (parseJson(res.body) ?? res.body) : "";
345
- 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])) });
346
402
  out.push(...problems.map((c) => `stub ${name} reply (the real service wouldn't answer this way): ${c}`));
347
403
  }
348
404
  return out;
349
405
  }
406
+ #handlerErrors() {
407
+ return [...this.stubs.values()].flatMap((s) => s.handlerErrors().map(({ call, error }) => {
408
+ const where = error?.stack?.split("\n").find((l) => /^\s+at /.test(l) && !l.includes("node_modules") && !l.includes("node:"))?.trim();
409
+ return ` ${s.name}: ${call.method} ${call.path}: ${error instanceof Error ? error.message : String(error)}${where ? `\n ${where}` : ""}`;
410
+ }));
411
+ }
350
412
  #unmatched() {
351
413
  return [...this.stubs.values()].flatMap((s) => {
352
414
  const calls = s.unmatched();
@@ -366,7 +428,7 @@ export class Runtime {
366
428
  /** The current scenario as a Mermaid sequence diagram. */
367
429
  async diagram() {
368
430
  const changes = this.opts.db.none ? undefined : await this.db.changesSinceStart().catch(() => undefined);
369
- return sequenceDiagram(this.http.history, this.stubs.values(), changes, this.mailbox);
431
+ return sequenceDiagram(this.http.requests, this.stubs.values(), changes, this.mailbox);
370
432
  }
371
433
  /**
372
434
  * After a scenario: its diagram goes to the `SLICETEST_DIAGRAMS` directory (one Markdown page
@@ -401,6 +463,9 @@ export class Runtime {
401
463
  if (exit)
402
464
  sections.push(`the ${p.label} exited (code ${exit.code}, signal ${exit.signal}); it will be restarted for the next scenario`);
403
465
  }
466
+ const thrown = this.#handlerErrors();
467
+ if (thrown.length > 0)
468
+ sections.push(`stub replies that threw:\n${thrown.join("\n")}`);
404
469
  const unmatched = this.#unmatched();
405
470
  if (unmatched.length > 0)
406
471
  sections.push(`stub calls with no matching route:\n${unmatched.join("\n")}`);
@@ -499,8 +564,69 @@ async function ensureWorkerDatabase(engine, adminUrl, template, prefix) {
499
564
  await admin.close();
500
565
  }
501
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
+ }
502
628
  function message(res) {
503
- 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) };
504
630
  }
505
631
  function header(v) {
506
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
  };
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Files `migrate: { sql }` applies, in order: the file itself, or a directory's `.sql` files (and
3
+ * Diesel's `<version>_<name>/up.sql`) sorted by name with numbers compared as numbers. Rollback
4
+ * files are left out: golang-migrate / sqlx `*.down.sql`, Diesel's `down.sql` and Flyway undo
5
+ * migrations (`U2__…`).
6
+ */
7
+ export declare function sqlMigrationFiles(target: string): Promise<string[]>;
8
+ /** The statements of a migration file that migrate up: without a goose or dbmate down section. */
9
+ export declare function upSection(sql: string): string;
10
+ /** Apply `files` in order with `exec`, naming the file that fails. */
11
+ export declare function applySqlFiles(files: string[], exec: (sql: string) => Promise<unknown>, root: string): Promise<void>;
@@ -0,0 +1,38 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readdir, readFile, stat } from "node:fs/promises";
3
+ import path from "node:path";
4
+ /** Version numbers compare as numbers: Flyway's `V2__` comes before `V10__`. */
5
+ const byVersion = new Intl.Collator("en", { numeric: true }).compare;
6
+ /**
7
+ * Files `migrate: { sql }` applies, in order: the file itself, or a directory's `.sql` files (and
8
+ * Diesel's `<version>_<name>/up.sql`) sorted by name with numbers compared as numbers. Rollback
9
+ * files are left out: golang-migrate / sqlx `*.down.sql`, Diesel's `down.sql` and Flyway undo
10
+ * migrations (`U2__…`).
11
+ */
12
+ export async function sqlMigrationFiles(target) {
13
+ if (!(await stat(target)).isDirectory())
14
+ return [target];
15
+ const entries = await readdir(target, { withFileTypes: true });
16
+ const files = entries.filter((e) => e.isFile() && e.name.toLowerCase().endsWith(".sql") && !/\.down\.sql$/i.test(e.name) && !/^U\d+(\.\d+)*__/.test(e.name)).map((e) => e.name);
17
+ // Diesel: one directory per migration, holding up.sql and down.sql.
18
+ const dirs = entries.filter((e) => e.isDirectory() && existsSync(path.join(target, e.name, "up.sql"))).map((e) => path.join(e.name, "up.sql"));
19
+ return [...files, ...dirs].sort(byVersion).map((f) => path.join(target, f));
20
+ }
21
+ /** Where a migration's rollback section starts in goose (`-- +goose Down`) and dbmate (`-- migrate:down`) files. */
22
+ const DOWN = /^--\s*(\+goose\s+down|migrate:down)\b/im;
23
+ /** The statements of a migration file that migrate up: without a goose or dbmate down section. */
24
+ export function upSection(sql) {
25
+ const m = DOWN.exec(sql);
26
+ return m ? sql.slice(0, m.index) : sql;
27
+ }
28
+ /** Apply `files` in order with `exec`, naming the file that fails. */
29
+ export async function applySqlFiles(files, exec, root) {
30
+ for (const file of files) {
31
+ try {
32
+ await exec(upSection(await readFile(file, "utf8")));
33
+ }
34
+ catch (e) {
35
+ throw new Error(`slicetest: db.migrate.sql: ${path.relative(root, file) || file}: ${e.message}`, { cause: e });
36
+ }
37
+ }
38
+ }
package/dist/stub.d.ts CHANGED
@@ -54,7 +54,7 @@ export interface ChaosOptions {
54
54
  export interface StubResponse {
55
55
  status?: number;
56
56
  headers?: Record<string, string>;
57
- /** Objects are sent as JSON. */
57
+ /** Strings and bytes (`Uint8Array`, `ArrayBuffer`) are sent as they are; anything else as JSON. */
58
58
  body?: unknown;
59
59
  }
60
60
  /** One server-sent event: `[event, data]`, or `{ event, data, id }`. Data that isn't a string is sent as JSON. */
@@ -75,7 +75,8 @@ export type Responder = StubResponse | ((call: RecordedCall) => StubResponse | P
75
75
  * `expect.*` asymmetric matchers receive the actual value.
76
76
  */
77
77
  export interface MatchOptions {
78
- query?: Record<string, Matcher>;
78
+ /** A list matches a repeated parameter's values in order: `{ ids: ["1", "2"] }` for `?ids=1&ids=2`. */
79
+ query?: Record<string, Matcher | Matcher[]>;
79
80
  headers?: Record<string, Matcher>;
80
81
  json?: unknown;
81
82
  /** Subset of a form-encoded body's fields (`call.form`). Numbers and booleans compare as the strings sent. */
@@ -137,6 +138,7 @@ export declare class Stub {
137
138
  /**
138
139
  * Answer `method path`. `path` may contain `:name` segments (captured into
139
140
  * `call.params`) or be a RegExp; `method` may be `*`. Later routes win.
141
+ * A query in `path` (`/search?q=tea`) is a condition on those parameters; others may come along.
140
142
  */
141
143
  on(method: string, path: string | RegExp, match?: MatchOptions): RouteBuilder;
142
144
  /**
@@ -160,7 +162,12 @@ export declare class Stub {
160
162
  /** The active `chaos()` settings and what they did, for failure output; undefined when off. */
161
163
  describeChaos(): string | undefined;
162
164
  /** Calls received so far, optionally filtered by method, path and conditions (same syntax as `on()`). */
163
- calls(method?: string, path?: string | RegExp, match?: MatchOptions): RecordedCall[];
165
+ calls(method?: string, fullPath?: string | RegExp, fullMatch?: MatchOptions): RecordedCall[];
166
+ /** Calls whose reply function or fallback threw, with the error; the app got a 500 (502 for a fallback). */
167
+ handlerErrors(): {
168
+ call: RecordedCall;
169
+ error: unknown;
170
+ }[];
164
171
  unmatched(): RecordedCall[];
165
172
  /** Routes registered in this scenario that no call reached, except `optional()` ones. */
166
173
  unusedRoutes(): string[];
package/dist/stub.js CHANGED
@@ -2,6 +2,7 @@ import http from "node:http";
2
2
  import { isDeepStrictEqual } from "node:util";
3
3
  import { describeGraphQL, graphqlErrors, graphqlOf } from "./graphql.js";
4
4
  import { timeline } from "./timeline.js";
5
+ import { requestUrl } from "./request-url.js";
5
6
  /**
6
7
  * A streaming reply in Server-Sent Events format, as LLM APIs stream (Anthropic, OpenAI):
7
8
  * `stub("anthropic").on("POST", "/v1/messages").reply(sse([["message_start", {...}], ...]))`.
@@ -30,6 +31,8 @@ export class Stub {
30
31
  /** Appended to the 501 answer for a call nothing could answer. */
31
32
  #hint;
32
33
  #chaos;
34
+ /** Reply functions (and fallbacks) that threw: the app got a 500 / 502, the scenario must fail with the error. */
35
+ #errors = [];
33
36
  url = "";
34
37
  constructor(name) {
35
38
  this.name = name;
@@ -54,6 +57,7 @@ export class Stub {
54
57
  /**
55
58
  * Answer `method path`. `path` may contain `:name` segments (captured into
56
59
  * `call.params`) or be a RegExp; `method` may be `*`. Later routes win.
60
+ * A query in `path` (`/search?q=tea`) is a condition on those parameters; others may come along.
57
61
  */
58
62
  on(method, path, match = {}) {
59
63
  return this.#on(method, path, match);
@@ -77,7 +81,8 @@ export class Stub {
77
81
  };
78
82
  return gql;
79
83
  }
80
- #on(method, path, match, label) {
84
+ #on(method, fullPath, fullMatch, label) {
85
+ const { path, match } = splitQuery(fullPath, fullMatch);
81
86
  const { pattern, paramNames } = compilePath(path);
82
87
  const route = {
83
88
  method: method.toUpperCase(),
@@ -179,12 +184,17 @@ export class Stub {
179
184
  this.#send(call, res, { status: fault.status, headers: retry, body: { error: "slicetest chaos", status: fault.status } });
180
185
  }
181
186
  /** Calls received so far, optionally filtered by method, path and conditions (same syntax as `on()`). */
182
- calls(method, path, match = {}) {
187
+ calls(method, fullPath, fullMatch = {}) {
188
+ const { path, match } = fullPath === undefined ? { path: undefined, match: fullMatch } : splitQuery(fullPath, fullMatch);
183
189
  const compiled = path === undefined ? undefined : compilePath(path);
184
190
  return this.#calls.filter((c) => (!method || method === "*" || c.method === method.toUpperCase()) &&
185
191
  (!compiled || matchPath(path, compiled.pattern, c.path) !== undefined) &&
186
192
  matchConditions(match, c));
187
193
  }
194
+ /** Calls whose reply function or fallback threw, with the error; the app got a 500 (502 for a fallback). */
195
+ handlerErrors() {
196
+ return this.#errors;
197
+ }
188
198
  unmatched() {
189
199
  return this.#calls.filter((c) => !c.matched);
190
200
  }
@@ -239,6 +249,7 @@ export class Stub {
239
249
  this.#routes = [];
240
250
  this.#calls = [];
241
251
  this.#chaos = undefined;
252
+ this.#errors = [];
242
253
  }
243
254
  /**
244
255
  * Answer calls that no registered route matches, instead of failing with 501.
@@ -262,7 +273,7 @@ export class Stub {
262
273
  const raw = Buffer.concat(chunks);
263
274
  const body = raw.toString("utf8");
264
275
  const contentType = req.headers["content-type"] ?? "";
265
- const url = new URL(req.url ?? "/", this.url);
276
+ const url = requestUrl(req.url, this.url);
266
277
  const call = {
267
278
  method: req.method ?? "GET",
268
279
  path: url.pathname,
@@ -303,6 +314,7 @@ export class Stub {
303
314
  out = await this.#fallback?.(call);
304
315
  }
305
316
  catch (e) {
317
+ this.#errors.push({ call, error: e });
306
318
  res.writeHead(502, { "content-type": "text/plain" }).end(e.message);
307
319
  return;
308
320
  }
@@ -330,10 +342,15 @@ export class Stub {
330
342
  this.#send(call, res, typeof route.respond === "function" ? await route.respond(call) : route.respond);
331
343
  }
332
344
  catch (e) {
345
+ this.#errors.push({ call, error: e });
333
346
  res.writeHead(500).end(`slicetest: stub handler threw: ${e}`);
334
347
  }
335
348
  }
336
349
  #send(call, res, out) {
350
+ if (out.body instanceof ArrayBuffer || (ArrayBuffer.isView(out.body) && !(out.body instanceof Uint8Array))) {
351
+ const view = out.body;
352
+ out = { ...out, body: view instanceof ArrayBuffer ? new Uint8Array(view) : new Uint8Array(view.buffer, view.byteOffset, view.byteLength) };
353
+ }
337
354
  const raw = out.body === undefined || typeof out.body === "string" || out.body instanceof Uint8Array;
338
355
  const headers = { ...out.headers };
339
356
  if (!raw && !Object.keys(headers).some((h) => h.toLowerCase() === "content-type")) {
@@ -349,15 +366,29 @@ export class Stub {
349
366
  res.end(payload);
350
367
  }
351
368
  }
369
+ /** `/search?q=tea` is the path `/search` with the query condition `q: "tea"`; `match.query` wins over the path's. */
370
+ function splitQuery(path, match) {
371
+ if (typeof path !== "string" || !path.includes("?"))
372
+ return { path, match };
373
+ const [bare, search] = [path.slice(0, path.indexOf("?")), path.slice(path.indexOf("?") + 1)];
374
+ const params = new URLSearchParams(search);
375
+ const query = Object.fromEntries([...new Set(params.keys())].map((k) => [k, params.getAll(k).length > 1 ? params.getAll(k) : params.get(k)]));
376
+ return { path: bare || "/", match: { ...match, query: { ...query, ...match.query } } };
377
+ }
352
378
  function compilePath(path) {
353
- if (typeof path !== "string" || !path.includes("/:"))
379
+ if (typeof path !== "string" || !/\/[:*]/.test(path))
354
380
  return { pattern: undefined, paramNames: [] };
355
381
  const paramNames = [];
356
382
  const source = path
357
383
  .split("/")
358
384
  .map((seg) => {
385
+ // Like Express's `*`: the rest of the path, slashes included.
386
+ if (seg === "*") {
387
+ paramNames.push("*");
388
+ return "(.+)";
389
+ }
359
390
  if (!seg.startsWith(":"))
360
- return seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
391
+ return encodeLiteral(seg).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
361
392
  paramNames.push(seg.slice(1));
362
393
  return "([^/]+)";
363
394
  })
@@ -367,7 +398,7 @@ function compilePath(path) {
367
398
  /** Returns captured params on a match, undefined otherwise. */
368
399
  function matchPath(path, pattern, actual, names = []) {
369
400
  if (typeof path === "string" && !pattern)
370
- return path === actual ? {} : undefined;
401
+ return path === actual || encodeLiteral(path) === actual ? {} : undefined;
371
402
  const re = pattern ?? path;
372
403
  re.lastIndex = 0;
373
404
  const m = re.exec(actual);
@@ -377,6 +408,10 @@ function matchPath(path, pattern, actual, names = []) {
377
408
  names.forEach((n, i) => (params[n] = decodeSegment(m[i + 1])));
378
409
  return params;
379
410
  }
411
+ /** A route written with a space or non-ASCII characters (`/検索`) is what the app sent percent-encoded (`/%E6%A4%9C…`). */
412
+ function encodeLiteral(path) {
413
+ return path.replace(/[^\x21-\x7e]+/gu, encodeURIComponent);
414
+ }
380
415
  /** A path segment decoded, or as sent when it isn't valid percent-encoding (`%zz`), instead of dropping the call. */
381
416
  function decodeSegment(segment) {
382
417
  try {
@@ -388,7 +423,7 @@ function decodeSegment(segment) {
388
423
  }
389
424
  function matchConditions(match, call) {
390
425
  for (const [k, m] of Object.entries(match.query ?? {})) {
391
- if (!test(m, call.query.get(k) ?? undefined))
426
+ if (!testQuery(m, call.query, k))
392
427
  return false;
393
428
  }
394
429
  for (const [k, m] of Object.entries(match.headers ?? {})) {
@@ -417,9 +452,11 @@ function matchConditions(match, call) {
417
452
  /** The first condition of `match` that `call` fails, described; undefined when it meets them all. */
418
453
  function conditionMismatch(match, call) {
419
454
  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)}`;
455
+ if (testQuery(m, call.query, k))
456
+ continue;
457
+ const all = call.query.getAll(k);
458
+ const got = all.length === 0 ? "nothing" : Array.isArray(m) || all.length > 1 ? JSON.stringify(all) : JSON.stringify(all[0]);
459
+ return `query ${k}: expected ${Array.isArray(m) ? `[${m.map(show).join(", ")}]` : show(m)}, got ${got}`;
423
460
  }
424
461
  for (const [k, m] of Object.entries(match.headers ?? {})) {
425
462
  const raw = call.headers[k.toLowerCase()];
@@ -525,6 +562,13 @@ function distance(a, b) {
525
562
  }
526
563
  return row[b.length];
527
564
  }
565
+ /** A query condition: one matcher for the first value, or a list for every value of a repeated parameter, in order. */
566
+ function testQuery(m, query, key) {
567
+ if (!Array.isArray(m))
568
+ return test(m, query.get(key) ?? undefined);
569
+ const values = query.getAll(key);
570
+ return values.length === m.length && m.every((x, i) => test(x, values[i]));
571
+ }
528
572
  function test(m, value) {
529
573
  if (isAsymmetric(m))
530
574
  return m.asymmetricMatch(value);
@@ -558,6 +602,8 @@ function isAsymmetric(m) {
558
602
  function isForm(contentType) {
559
603
  return !!contentType && contentType.split(";")[0].trim().toLowerCase() === "application/x-www-form-urlencoded";
560
604
  }
605
+ /** Keys kept as plain fields rather than nested into, so a body can't reach `Object.prototype`. */
606
+ const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
561
607
  /**
562
608
  * A form body as nested fields, the way Rack, PHP and Stripe read bracket keys: `a[b]=1` is
563
609
  * `{ a: { b: "1" } }`, `a[]=1&a[]=2` and `a[0]=1&a[1]=2` are arrays, and a plain key sent
@@ -567,12 +613,13 @@ export function parseForm(body) {
567
613
  const out = {};
568
614
  for (const [key, value] of new URLSearchParams(body)) {
569
615
  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];
616
+ const path = m?.[2] ? [m[1], ...[...m[2].matchAll(/\[([^\]]*)\]/g)].map((x) => x[1])] : undefined;
617
+ // `__proto__[x]=1` is sent by whatever the app forwards; it must not reach Object.prototype.
618
+ if (!path || path.some((seg) => UNSAFE_KEYS.has(seg))) {
619
+ const prev = Object.hasOwn(out, key) ? out[key] : undefined;
620
+ Object.defineProperty(out, key, { value: prev === undefined ? value : Array.isArray(prev) ? [...prev, value] : [prev, value], enumerable: true, writable: true, configurable: true });
573
621
  continue;
574
622
  }
575
- const path = [m[1], ...[...m[2].matchAll(/\[([^\]]*)\]/g)].map((x) => x[1])];
576
623
  let node = out;
577
624
  for (let i = 0; i < path.length; i++) {
578
625
  const seg = path[i];
@@ -632,8 +679,9 @@ async function parseMultipart(raw, contentType) {
632
679
  size: value.size,
633
680
  ...(TEXTUAL.test(value.type) ? { text: await value.text() } : {}),
634
681
  };
635
- const prev = out[key];
636
- out[key] = prev === undefined ? v : Array.isArray(prev) ? [...prev, v] : [prev, v];
682
+ // `out[key]` would read Object's `constructor` and set the prototype for `__proto__`.
683
+ const prev = Object.hasOwn(out, key) ? out[key] : undefined;
684
+ Object.defineProperty(out, key, { value: prev === undefined ? v : Array.isArray(prev) ? [...prev, v] : [prev, v], enumerable: true, writable: true, configurable: true });
637
685
  }
638
686
  return out;
639
687
  }
package/dist/vitest.d.ts CHANGED
@@ -13,4 +13,8 @@ export declare const YAML_SCENARIOS = "**/*.scenario.{yaml,yml}";
13
13
  * Vitest root), or nothing to read the slicetest.config.yaml next to the Vitest config.
14
14
  */
15
15
  export declare function slicetest(options?: SlicetestOptions | string): Plugin;
16
+ /** @internal `include`: the CLI's test files, used as they are instead of extending Vitest's. */
17
+ export declare function slicetestPlugin(options?: SlicetestOptions | string, cli?: {
18
+ include?: string[];
19
+ }): Plugin;
16
20
  export type { SlicetestOptions } from "./config.js";
package/dist/vitest.js CHANGED
@@ -30,6 +30,10 @@ const ext = path.extname(fileURLToPath(import.meta.url));
30
30
  * Vitest root), or nothing to read the slicetest.config.yaml next to the Vitest config.
31
31
  */
32
32
  export function slicetest(options) {
33
+ return slicetestPlugin(options);
34
+ }
35
+ /** @internal `include`: the CLI's test files, used as they are instead of extending Vitest's. */
36
+ export function slicetestPlugin(options, cli = {}) {
33
37
  let root;
34
38
  return {
35
39
  name: "slicetest",
@@ -37,9 +41,9 @@ export function slicetest(options) {
37
41
  const root = path.resolve(config.root ?? process.cwd());
38
42
  // Mutated rather than returned: a returned list would replace Vitest's default include instead of extending it.
39
43
  const test = (config.test ??= {});
40
- // `slicetest record` runs only its session file.
44
+ // `slicetest record` runs only its session file. Extending the CLI's include would run every scenario file.
41
45
  if (!process.env[SESSION_ENV])
42
- test.include = [...(test.include ?? configDefaults.include), YAML_SCENARIOS];
46
+ test.include = cli.include ?? [...(test.include ?? configDefaults.include), YAML_SCENARIOS];
43
47
  const resolved = resolveOptions(typeof options === "object" ? options : loadConfigFile(root, options), root);
44
48
  return {
45
49
  test: {
package/dist/webhook.d.ts CHANGED
@@ -8,7 +8,7 @@ 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" | "line" | "paddle" | "linear" | "gitlab" | "zoom" | "twitch" | "twilio" | HmacScheme;
11
+ export type WebhookProvider = "stripe" | "github" | "slack" | "shopify" | "standard" | "line" | "paddle" | "linear" | "gitlab" | "zoom" | "twitch" | "twilio" | "typeform" | "notion" | "bitbucket" | "intercom" | 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. */
@@ -32,14 +32,14 @@ export interface WebhookOptions {
32
32
  headers?: Record<string, string>;
33
33
  }
34
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"];
35
+ export declare const WEBHOOK_PROVIDERS: readonly ["stripe", "github", "slack", "shopify", "standard", "line", "paddle", "linear", "gitlab", "zoom", "twitch", "twilio", "typeform", "notion", "bitbucket", "intercom"];
36
36
  /**
37
37
  * The headers a provider sends with `body`, signed with `secret` the way its
38
38
  * SDK verifies them: Stripe's `Stripe-Signature`, GitHub's
39
39
  * `X-Hub-Signature-256`, Slack's `v0` signature, Shopify's base64 HMAC,
40
40
  * Standard Webhooks (Svix, Resend, Clerk, …), LINE's `X-Line-Signature`,
41
41
  * Paddle Billing's `Paddle-Signature`, Linear, GitLab's token, Zoom's `v0`
42
- * signature and Twitch EventSub.
42
+ * signature, Twitch EventSub, Typeform, Notion, Bitbucket and Intercom.
43
43
  */
44
44
  export declare function signWebhook(body: string, opts: WebhookOptions): Record<string, string>;
45
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. */