slicetest 0.6.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +177 -24
  2. package/dist/cli.js +66 -2
  3. package/dist/config.d.ts +18 -0
  4. package/dist/config.js +13 -0
  5. package/dist/connection.d.ts +5 -0
  6. package/dist/connection.js +31 -0
  7. package/dist/db.d.ts +12 -1
  8. package/dist/db.js +33 -6
  9. package/dist/diagram.d.ts +13 -0
  10. package/dist/diagram.js +89 -0
  11. package/dist/form.d.ts +7 -2
  12. package/dist/form.js +72 -7
  13. package/dist/global-setup.d.ts +5 -0
  14. package/dist/global-setup.js +74 -9
  15. package/dist/graphql.d.ts +34 -0
  16. package/dist/graphql.js +56 -0
  17. package/dist/har.d.ts +49 -0
  18. package/dist/har.js +107 -0
  19. package/dist/http.d.ts +21 -3
  20. package/dist/http.js +47 -10
  21. package/dist/index.d.ts +5 -3
  22. package/dist/init.js +184 -15
  23. package/dist/list.d.ts +26 -0
  24. package/dist/list.js +71 -0
  25. package/dist/matchers.d.ts +16 -0
  26. package/dist/matchers.js +81 -1
  27. package/dist/openapi.d.ts +21 -0
  28. package/dist/openapi.js +32 -1
  29. package/dist/provided.d.ts +1 -0
  30. package/dist/record.d.ts +1 -1
  31. package/dist/record.js +3 -1
  32. package/dist/recording.d.ts +1 -1
  33. package/dist/recording.js +2 -2
  34. package/dist/runtime.d.ts +12 -5
  35. package/dist/runtime.js +78 -22
  36. package/dist/scenario.d.ts +14 -4
  37. package/dist/scenario.js +36 -17
  38. package/dist/schema.d.ts +5 -0
  39. package/dist/schema.js +73 -0
  40. package/dist/stub.d.ts +63 -0
  41. package/dist/stub.js +292 -5
  42. package/dist/timeline.d.ts +9 -0
  43. package/dist/timeline.js +6 -0
  44. package/dist/webhook.d.ts +16 -7
  45. package/dist/webhook.js +47 -7
  46. package/dist/yaml-runtime.d.ts +3 -2
  47. package/dist/yaml-runtime.js +308 -51
  48. package/dist/yaml.d.ts +82 -6
  49. package/dist/yaml.js +187 -21
  50. package/package.json +2 -1
  51. package/schema/scenario.schema.json +640 -14
@@ -40,4 +40,4 @@ export declare class Recorder {
40
40
  }
41
41
  export declare function readRecordings(file: string): Promise<Recording[]>;
42
42
  /** Append new recordings to `file`, skipping exact duplicates, keeping the order they were made in. */
43
- export declare function mergeRecordings(file: string, upstream: string, added: Recording[]): Promise<void>;
43
+ export declare function mergeRecordings(file: string, upstream: string, added: Recording[], source?: string): Promise<void>;
package/dist/recording.js CHANGED
@@ -137,12 +137,12 @@ export async function readRecordings(file) {
137
137
  return doc;
138
138
  }
139
139
  /** Append new recordings to `file`, skipping exact duplicates, keeping the order they were made in. */
140
- export async function mergeRecordings(file, upstream, added) {
140
+ export async function mergeRecordings(file, upstream, added, source = `Recorded by slicetest from ${upstream}`) {
141
141
  const entries = await readRecordings(file);
142
142
  for (const e of added)
143
143
  if (!entries.some((x) => isDeepStrictEqual(x, e)))
144
144
  entries.push(e);
145
145
  await mkdir(path.dirname(file), { recursive: true });
146
- const header = `# Recorded by slicetest from ${upstream}. Review before committing: request bodies are stored as sent.\n`;
146
+ const header = `# ${source}. Review before committing: request bodies are stored as sent.\n`;
147
147
  await writeFile(file, header + YAML.stringify(entries, { lineWidth: 0 }));
148
148
  }
package/dist/runtime.d.ts CHANGED
@@ -2,6 +2,7 @@ import { App } from "./app.js";
2
2
  import { Issuer } from "./auth.js";
3
3
  import type { ResolvedOptions } from "./config.js";
4
4
  import { Dependency } from "./containers.js";
5
+ import { connectionVars } from "./connection.js";
5
6
  import { Db } from "./db.js";
6
7
  import { HttpClient } from "./http.js";
7
8
  import { Interceptor } from "./intercept.js";
@@ -24,6 +25,8 @@ export interface ScenarioContext {
24
25
  * with timestamps and UUIDs masked. `expect(await trace()).toMatchSnapshot()`.
25
26
  */
26
27
  trace: (opts?: MaskOptions) => Promise<Trace>;
28
+ /** The scenario so far as a Mermaid sequence diagram: requests, stub calls, mail and changed tables. */
29
+ diagram: () => Promise<string>;
27
30
  /** Mail the app sent during the scenario. Needs `mail: true` in the config. */
28
31
  mail: Mailbox;
29
32
  /** The OpenID Connect issuer the app trusts: `auth.token(claims)`. Needs `auth` in the config. */
@@ -56,20 +59,24 @@ export declare class Runtime {
56
59
  prefix: string;
57
60
  coverageDir?: string;
58
61
  recordDir?: string;
62
+ usageDir?: string;
59
63
  }): Promise<Runtime>;
60
64
  context(): ScenarioContext;
61
65
  beforeScenario(): Promise<void>;
62
66
  /** Failures that the scenario body can't see on its own. */
63
67
  afterScenario(): Promise<void>;
68
+ /** The current scenario as a Mermaid sequence diagram. */
69
+ diagram(): Promise<string>;
70
+ /**
71
+ * After a scenario: its diagram goes to the `SLICETEST_DIAGRAMS` directory (one Markdown page
72
+ * per test file) and, when it failed on GitHub Actions, to the job summary.
73
+ */
74
+ reportDiagram(file: string, scenario: string, failed: boolean, env?: NodeJS.ProcessEnv): Promise<void>;
64
75
  /** What happened during the current scenario, printed when it fails. */
65
76
  diagnostics(): Promise<string>;
66
77
  /** Hand the coverage and recordings gathered so far to the run (merged when it ends). */
67
78
  flush(): Promise<void>;
68
79
  stop(): Promise<void>;
69
80
  }
70
- /**
71
- * The parts of the database URL, for apps that don't take one URL: JDBC (Spring's
72
- * `spring.datasource.url` plus username / password), or separate host / port / name settings.
73
- */
74
- export declare function connectionVars(engine: string, url: string, sqlitePath?: string): Record<string, string>;
75
81
  export declare function blockedHint(hosts: string[]): string;
82
+ export { connectionVars };
package/dist/runtime.js CHANGED
@@ -1,11 +1,14 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { writeFile } from "node:fs/promises";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
3
  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
7
  import { Dependency } from "./containers.js";
8
+ import { connectionVars } from "./connection.js";
8
9
  import { Db, formatChanges, noDatabase } from "./db.js";
10
+ import { diagramPage, failureDiagram, sequenceDiagram } from "./diagram.js";
11
+ import { appendSummary } from "./ci.js";
9
12
  import { engineFor } from "./drivers/index.js";
10
13
  import { formatHistory, HttpClient } from "./http.js";
11
14
  import { Interceptor } from "./intercept.js";
@@ -39,6 +42,8 @@ export class Runtime {
39
42
  #contract = [];
40
43
  /** Documented responses seen in this file, for the run's coverage report. */
41
44
  #covered = new Set();
45
+ /** Operations of stubbed providers (with an `openapi` spec) the app called, per stub, for the usage report. */
46
+ #used = new Map();
42
47
  constructor(app, services, db, stubs, opts, vars, specs, coverageDir, recorders = new Map(), recordDir, containers = new Map(), mailbox, issuer, queryLog, interceptor, neon) {
43
48
  this.app = app;
44
49
  this.services = services;
@@ -119,6 +124,7 @@ export class Runtime {
119
124
  ? await Db.connect(await engine.driver(url), url, {
120
125
  schemas: opts.db.schemas,
121
126
  keep: opts.db.keep,
127
+ ignoreChanges: opts.db.ignoreChanges,
122
128
  seedFile: opts.db.seed && path.resolve(opts.root, opts.db.seed),
123
129
  })
124
130
  : noDatabase();
@@ -182,7 +188,9 @@ export class Runtime {
182
188
  const app = await App.start(opts.app, opts.root, vars);
183
189
  if (opts.openapi.fromApp)
184
190
  specs.app = await fetchAppSpec(app.url, opts.openapi.fromApp, shared.coverageDir);
185
- return new Runtime(app, services, db, stubs, opts, vars, specs, shared.coverageDir, recorders, shared.recordDir, containers, mailbox, issuer, queryLog, interceptor, neon);
191
+ const runtime = new Runtime(app, services, db, stubs, opts, vars, specs, shared.coverageDir, recorders, shared.recordDir, containers, mailbox, issuer, queryLog, interceptor, neon);
192
+ runtime.#usageDir = shared.usageDir;
193
+ return runtime;
186
194
  }
187
195
  catch (e) {
188
196
  await Promise.all([...services.values()].map((s) => s.stop()));
@@ -227,6 +235,7 @@ export class Runtime {
227
235
  return c;
228
236
  },
229
237
  trace: async (opts) => mask(buildTrace(this.http.history, this.stubs.values(), await this.db.changesSinceStart(), this.mailbox), opts),
238
+ diagram: () => this.diagram(),
230
239
  get mail() {
231
240
  if (!mailbox)
232
241
  throw new Error("slicetest: mail is off. Add `mail: true` to the config and point the app's SMTP settings at {{mail.host}} / {{mail.port}}.");
@@ -243,7 +252,22 @@ export class Runtime {
243
252
  #processes() {
244
253
  return [this.app, ...this.services.values()];
245
254
  }
255
+ #usageDir;
256
+ /** Note which provider operations the stubs were called for, before their calls are cleared. */
257
+ #collectUsage() {
258
+ for (const [name, spec] of this.specs.stubs) {
259
+ const used = this.#used.get(name) ?? new Set();
260
+ for (const call of this.stubs.get(name).calls()) {
261
+ const op = spec.operationOf(call.method, call.path);
262
+ if (op)
263
+ used.add(op.key);
264
+ }
265
+ if (used.size)
266
+ this.#used.set(name, used);
267
+ }
268
+ }
246
269
  async beforeScenario() {
270
+ this.#collectUsage();
247
271
  // A crash already failed the scenario that caused it; give the next one a fresh process.
248
272
  for (const [name, service] of this.services) {
249
273
  if (!service.exited)
@@ -286,6 +310,14 @@ export class Runtime {
286
310
  if (contract.length > 0) {
287
311
  throw new Error(`slicetest: traffic doesn't match the OpenAPI spec:\n${contract.map((c) => ` ${c}`).join("\n")}`);
288
312
  }
313
+ const unused = this.#unusedRoutes();
314
+ if (this.opts.strictStubs && unused.length > 0) {
315
+ throw new Error(`slicetest: stub routes the app never called (strictStubs):\n${unused.join("\n")}\n` +
316
+ "Remove them, check that the scenario reaches the code that calls them, or mark them .optional() (YAML: optional: true).");
317
+ }
318
+ }
319
+ #unusedRoutes() {
320
+ return [...this.stubs.values()].flatMap((s) => s.unusedRoutes().map((r) => ` ${s.name}: ${r}`));
289
321
  }
290
322
  /** The app's responses against its spec, and its calls to stubs (and the stubs' replies) against theirs. */
291
323
  #contractViolations() {
@@ -322,12 +354,45 @@ export class Runtime {
322
354
  return [];
323
355
  const routes = s.describeRoutes();
324
356
  return [
325
- ...calls.map((c) => ` ${s.name}: ${c.method} ${c.path}${c.query.size ? `?${c.query}` : ""}`),
357
+ ...calls.flatMap((c) => {
358
+ const why = s.explain(c);
359
+ return [` ${s.name}: ${c.method} ${c.path}${c.query.size ? `?${c.query}` : ""}`, ...(why ? [` ${why}`] : [])];
360
+ }),
326
361
  ` registered on ${s.name}: ${routes.length ? routes.join(", ") : "(none)"}`,
327
362
  ...(this.recorders.has(s.name) ? [` ${this.recorders.get(s.name).hint()}`] : []),
328
363
  ];
329
364
  });
330
365
  }
366
+ /** The current scenario as a Mermaid sequence diagram. */
367
+ async diagram() {
368
+ 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);
370
+ }
371
+ /**
372
+ * After a scenario: its diagram goes to the `SLICETEST_DIAGRAMS` directory (one Markdown page
373
+ * per test file) and, when it failed on GitHub Actions, to the job summary.
374
+ */
375
+ async reportDiagram(file, scenario, failed, env = process.env) {
376
+ const dir = env.SLICETEST_DIAGRAMS;
377
+ const summary = failed && env.GITHUB_STEP_SUMMARY;
378
+ if (!dir && !summary)
379
+ return;
380
+ let diagram;
381
+ try {
382
+ diagram = await this.diagram();
383
+ }
384
+ catch {
385
+ return;
386
+ }
387
+ const rel = path.relative(this.opts.root, file).replace(/\\/g, "/");
388
+ if (summary)
389
+ await appendSummary(failureDiagram(rel, scenario, diagram), env);
390
+ if (dir) {
391
+ const out = path.resolve(this.opts.root, dir, `${rel.replace(/^(\.\.\/)+/, "")}.md`);
392
+ await mkdir(path.dirname(out), { recursive: true });
393
+ await writeFile(out, diagramPage(out, rel, scenario, diagram, failed)).catch(() => { });
394
+ }
395
+ }
331
396
  /** What happened during the current scenario, printed when it fails. */
332
397
  async diagnostics() {
333
398
  const sections = [];
@@ -339,6 +404,9 @@ export class Runtime {
339
404
  const unmatched = this.#unmatched();
340
405
  if (unmatched.length > 0)
341
406
  sections.push(`stub calls with no matching route:\n${unmatched.join("\n")}`);
407
+ const unused = this.#unusedRoutes();
408
+ if (unused.length > 0)
409
+ sections.push(`stub routes the app never called:\n${unused.join("\n")}`);
342
410
  const chaos = [...this.stubs.values()].map((s) => s.describeChaos()).filter((c) => c !== undefined);
343
411
  if (chaos.length > 0)
344
412
  sections.push(chaos.join("\n"));
@@ -375,6 +443,12 @@ export class Runtime {
375
443
  }
376
444
  /** Hand the coverage and recordings gathered so far to the run (merged when it ends). */
377
445
  async flush() {
446
+ this.#collectUsage();
447
+ if (this.#usageDir && this.#used.size > 0) {
448
+ const data = Object.fromEntries([...this.#used].map(([k, v]) => [k, [...v]]));
449
+ await writeFile(path.join(this.#usageDir, `${process.pid}-${randomUUID()}.json`), JSON.stringify(data)).catch(() => { });
450
+ this.#used.clear();
451
+ }
378
452
  if (this.coverageDir && this.#covered.size > 0) {
379
453
  await writeFile(path.join(this.coverageDir, `${process.pid}-${randomUUID()}.json`), JSON.stringify([...this.#covered])).catch(() => { });
380
454
  this.#covered.clear();
@@ -439,25 +513,6 @@ function parseJson(text) {
439
513
  return undefined;
440
514
  }
441
515
  }
442
- /**
443
- * The parts of the database URL, for apps that don't take one URL: JDBC (Spring's
444
- * `spring.datasource.url` plus username / password), or separate host / port / name settings.
445
- */
446
- export function connectionVars(engine, url, sqlitePath) {
447
- if (engine === "sqlite")
448
- return { "db.jdbcUrl": `jdbc:sqlite:${sqlitePath}` };
449
- const u = new URL(url);
450
- const port = u.port || (engine === "mysql" ? "3306" : "5432");
451
- const name = decodeURIComponent(u.pathname.replace(/^\//, ""));
452
- return {
453
- "db.host": u.hostname,
454
- "db.port": port,
455
- "db.name": name,
456
- "db.user": decodeURIComponent(u.username),
457
- "db.password": decodeURIComponent(u.password),
458
- "db.jdbcUrl": `jdbc:${engine === "mysql" ? "mysql" : "postgresql"}://${u.hostname}:${port}/${encodeURIComponent(name)}`,
459
- };
460
- }
461
516
  /** The app's own spec, served at `route` (springdoc's /v3/api-docs, FastAPI's /openapi.json, …). */
462
517
  async function fetchAppSpec(appUrl, route, coverageDir) {
463
518
  let text;
@@ -483,3 +538,4 @@ export function blockedHint(hosts) {
483
538
  return message;
484
539
  return `${message}\n${registries.join(", ")} ${registries.length > 1 ? "are package registries" : "is a package registry"}: the command that starts the app (gradle bootRun, mvn spring-boot:run, go run, …) is downloading dependencies, through slicetest's proxy. Download them in \`app.build\` (./gradlew bootJar, mvn package), or start a built artifact (java -jar).`;
485
540
  }
541
+ export { connectionVars };
@@ -1,15 +1,25 @@
1
1
  import type { Runtime, ScenarioContext } from "./runtime.js";
2
2
  export declare function setRuntime(runtime: Runtime | undefined): void;
3
3
  type Body = (ctx: ScenarioContext) => Promise<void> | void;
4
+ export interface ScenarioOptions {
5
+ timeout?: number;
6
+ /** Labels to select scenarios by: `npx slicetest --tag smoke`, or `SLICETEST_TAGS=smoke,!slow` with Vitest. */
7
+ tags?: string[];
8
+ }
9
+ /**
10
+ * Whether a scenario with `tags` runs under `filter` (`SLICETEST_TAGS`): comma- or space-separated tags,
11
+ * any of which it must have, and `!tag`s it must not have. No filter runs everything.
12
+ */
13
+ export declare function tagsSelected(tags?: readonly string[], filter?: string | undefined): boolean;
4
14
  /**
5
15
  * A test that runs against the real app. The database is reset to the
6
16
  * migrated schema (plus seed) and stubs are cleared before each scenario.
7
17
  */
8
- export declare const scenario: ((name: string, body: Body, timeout?: number) => void) & {
9
- only: (name: string, body: Body, timeout?: number) => void;
10
- skip: (name: string, body: Body, timeout?: number) => void;
18
+ export declare const scenario: ((name: string, body: Body, options?: number | ScenarioOptions) => void) & {
19
+ only: (name: string, body: Body, options?: number | ScenarioOptions) => void;
20
+ skip: (name: string, body: Body, options?: number | ScenarioOptions) => void;
11
21
  todo: (name: string) => void;
12
22
  /** Same scenario for each row: `scenario.each(rows)("name %s", async (row, ctx) => ...)`. */
13
- each<T>(rows: readonly T[]): (name: string, body: (row: T, ctx: ScenarioContext) => Promise<void> | void, timeout?: number) => void;
23
+ each<T>(rows: readonly T[]): (name: string, body: (row: T, ctx: ScenarioContext) => Promise<void> | void, options?: number | ScenarioOptions) => void;
14
24
  };
15
25
  export {};
package/dist/scenario.js CHANGED
@@ -6,22 +6,41 @@ const slot = globalThis;
6
6
  export function setRuntime(runtime) {
7
7
  slot[KEY] = runtime;
8
8
  }
9
+ /**
10
+ * Whether a scenario with `tags` runs under `filter` (`SLICETEST_TAGS`): comma- or space-separated tags,
11
+ * any of which it must have, and `!tag`s it must not have. No filter runs everything.
12
+ */
13
+ export function tagsSelected(tags = [], filter = process.env.SLICETEST_TAGS) {
14
+ const terms = (filter ?? "").split(/[\s,]+/).filter(Boolean);
15
+ const excluded = terms.filter((t) => t.startsWith("!")).map((t) => t.slice(1));
16
+ const wanted = terms.filter((t) => !t.startsWith("!"));
17
+ if (tags.some((t) => excluded.includes(t)))
18
+ return false;
19
+ return wanted.length === 0 || tags.some((t) => wanted.includes(t));
20
+ }
9
21
  function define(register) {
10
- return (name, body, timeout) => register(name, async ({ onTestFailed, task }) => {
11
- const runtime = slot[KEY];
12
- if (!runtime) {
13
- throw new Error("slicetest: runtime not started. Add the slicetest() plugin to your vitest config.");
14
- }
15
- if (task.concurrent) {
16
- throw new Error("slicetest: scenarios share one app and database per file, so they can't run concurrently. Remove .concurrent / sequence.concurrent.");
17
- }
18
- onTestFailed(async () => {
19
- console.error(`--- slicetest ---\n${await runtime.diagnostics()}\n-----------------`);
20
- });
21
- await runtime.beforeScenario();
22
- await body(runtime.context());
23
- await runtime.afterScenario();
24
- }, timeout);
22
+ return (name, body, options) => {
23
+ const { timeout, tags } = typeof options === "number" ? { timeout: options, tags: undefined } : (options ?? {});
24
+ // Scenarios the tag filter leaves out show as skipped, so the filter is visible in the summary.
25
+ return (tagsSelected(tags) ? register : test.skip)(name, async ({ onTestFailed, task }) => {
26
+ const runtime = slot[KEY];
27
+ if (!runtime) {
28
+ throw new Error("slicetest: runtime not started. Add the slicetest() plugin to your vitest config.");
29
+ }
30
+ if (task.concurrent) {
31
+ throw new Error("slicetest: scenarios share one app and database per file, so they can't run concurrently. Remove .concurrent / sequence.concurrent.");
32
+ }
33
+ const file = task.file?.filepath ?? task.file?.name ?? "";
34
+ onTestFailed(async () => {
35
+ console.error(`--- slicetest ---\n${await runtime.diagnostics()}\n-----------------`);
36
+ await runtime.reportDiagram(file, task.name, true);
37
+ });
38
+ await runtime.beforeScenario();
39
+ await body(runtime.context());
40
+ await runtime.afterScenario();
41
+ await runtime.reportDiagram(file, task.name, false);
42
+ }, timeout);
43
+ };
25
44
  }
26
45
  /**
27
46
  * A test that runs against the real app. The database is reset to the
@@ -33,10 +52,10 @@ export const scenario = Object.assign(define(test), {
33
52
  todo: (name) => test.todo(name),
34
53
  /** Same scenario for each row: `scenario.each(rows)("name %s", async (row, ctx) => ...)`. */
35
54
  each(rows) {
36
- return (name, body, timeout) => {
55
+ return (name, body, options) => {
37
56
  rows.forEach((row, i) => {
38
57
  const title = format(name, row, i);
39
- define(test)(title, (ctx) => body(row, ctx), timeout);
58
+ define(test)(title, (ctx) => body(row, ctx), options);
40
59
  });
41
60
  };
42
61
  },
@@ -0,0 +1,5 @@
1
+ import { type ValidateFunction } from "ajv";
2
+ /** A schema file and pointer, `schemas/poll.json` or `openapi.yaml#/components/schemas/Poll`, resolved against `base`. */
3
+ export declare function validatorFor(ref: string, base?: string): ValidateFunction;
4
+ /** Problems with `value` against `schema` (an object, or a reference as for `validatorFor`); empty when it matches. */
5
+ export declare function schemaProblems(schema: object | string, value: unknown, base?: string): string[];
package/dist/schema.js ADDED
@@ -0,0 +1,73 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { Ajv } from "ajv";
4
+ import { Ajv2020 } from "ajv/dist/2020.js";
5
+ import addFormatsModule from "ajv-formats";
6
+ import { parse } from "yaml";
7
+ import { nullableToType } from "./openapi.js";
8
+ /**
9
+ * JSON Schema checks for `toMatchSchema()` and YAML `expect.schema`: an inline
10
+ * schema, or a file with an optional JSON pointer, such as an OpenAPI component
11
+ * (`openapi.yaml#/components/schemas/Poll`), whose `$ref`s resolve within that file.
12
+ */
13
+ const addFormats = (addFormatsModule.default ?? addFormatsModule);
14
+ const cache = new Map();
15
+ function ajvFor(draft2020) {
16
+ const ajv = draft2020 ? new Ajv2020({ strict: false, allErrors: true }) : new Ajv({ strict: false, allErrors: true });
17
+ addFormats(ajv);
18
+ // OpenAPI's own formats, which JSON Schema doesn't define.
19
+ for (const f of ["int32", "int64", "float", "double", "byte", "binary", "password"])
20
+ ajv.addFormat(f, true);
21
+ return ajv;
22
+ }
23
+ /** A schema file and pointer, `schemas/poll.json` or `openapi.yaml#/components/schemas/Poll`, resolved against `base`. */
24
+ export function validatorFor(ref, base = process.cwd()) {
25
+ const [file, pointer = ""] = ref.split("#");
26
+ const abs = path.resolve(base, file);
27
+ const key = `${abs}#${pointer}`;
28
+ const hit = cache.get(key);
29
+ if (hit)
30
+ return hit;
31
+ let doc;
32
+ try {
33
+ doc = parse(readFileSync(abs, "utf8"));
34
+ }
35
+ catch (e) {
36
+ throw new Error(`slicetest: can't read schema ${file}: ${e.message}`);
37
+ }
38
+ if (!doc || typeof doc !== "object")
39
+ throw new Error(`slicetest: schema ${file} is not a JSON or YAML document`);
40
+ const openapi = typeof doc.openapi === "string";
41
+ if (openapi && !pointer)
42
+ throw new Error(`slicetest: ${file} is an OpenAPI document; point at a schema in it, e.g. ${file}#/components/schemas/Poll`);
43
+ // OpenAPI 3.0 schemas are a dialect of draft-04/07 with `nullable`; 3.1 and plain files are 2020-12 unless they say otherwise.
44
+ const draft2020 = openapi ? doc.openapi.startsWith("3.1") : !/draft-0[4-7]/.test(String(doc.$schema ?? ""));
45
+ const ajv = ajvFor(draft2020);
46
+ ajv.addSchema(openapi && !draft2020 ? nullableToType(structuredClone(doc)) : doc, "doc");
47
+ let validate;
48
+ try {
49
+ validate = ajv.compile({ $ref: `doc#${pointer}` });
50
+ }
51
+ catch (e) {
52
+ throw new Error(`slicetest: no schema at ${ref}: ${e.message}`);
53
+ }
54
+ cache.set(key, validate);
55
+ return validate;
56
+ }
57
+ const inline = new WeakMap();
58
+ /** Problems with `value` against `schema` (an object, or a reference as for `validatorFor`); empty when it matches. */
59
+ export function schemaProblems(schema, value, base) {
60
+ let validate;
61
+ if (typeof schema === "string")
62
+ validate = validatorFor(schema, base);
63
+ else {
64
+ validate = inline.get(schema) ?? ajvFor(!/draft-0[4-7]/.test(String(schema.$schema ?? ""))).compile(schema);
65
+ inline.set(schema, validate);
66
+ }
67
+ if (validate(value))
68
+ return [];
69
+ return (validate.errors ?? []).map((e) => {
70
+ const extra = e.keyword === "additionalProperties" ? ` (${e.params.additionalProperty})` : e.keyword === "enum" ? ` (${e.params.allowedValues?.map((v) => JSON.stringify(v)).join(", ")})` : "";
71
+ return `${e.instancePath || "(root)"} ${e.message}${extra}`;
72
+ });
73
+ }
package/dist/stub.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import http from "node:http";
2
+ import { type GraphQLCall } from "./graphql.js";
2
3
  export interface RecordedCall {
3
4
  method: string;
4
5
  path: string;
@@ -7,6 +8,13 @@ export interface RecordedCall {
7
8
  body: string;
8
9
  /** Parsed JSON body, or undefined when the body isn't JSON. */
9
10
  json: any;
11
+ /**
12
+ * Fields of an `application/x-www-form-urlencoded` body (Stripe, Twilio, OAuth token requests),
13
+ * with bracket keys nested as the providers read them: `metadata[order]=7&items[0][price]=p_1`
14
+ * is `{ metadata: { order: "7" }, items: [{ price: "p_1" }] }`. For `multipart/form-data`, the
15
+ * fields with files as `{ filename, type, size, text }`. Undefined for other bodies.
16
+ */
17
+ form?: Record<string, unknown>;
10
18
  /** Values captured by `:name` segments of the matching route's path. */
11
19
  params: Record<string, string>;
12
20
  /** Whether a registered route answered this call. */
@@ -21,6 +29,8 @@ export interface RecordedCall {
21
29
  fallback?: boolean;
22
30
  /** The fault `chaos()` injected instead of the normal answer: `503`, `reset`. */
23
31
  fault?: string;
32
+ /** The GraphQL operation, when the call is a GraphQL request. */
33
+ graphql?: GraphQLCall;
24
34
  }
25
35
  /**
26
36
  * Faults injected into a stub's answers, to test the app's retries, timeouts and
@@ -68,7 +78,14 @@ export interface MatchOptions {
68
78
  query?: Record<string, Matcher>;
69
79
  headers?: Record<string, Matcher>;
70
80
  json?: unknown;
81
+ /** Subset of a form-encoded body's fields (`call.form`). Numbers and booleans compare as the strings sent. */
82
+ form?: Record<string, unknown>;
71
83
  body?: Matcher;
84
+ /** A GraphQL request for this operation (its name, or a RegExp), with `variables` as a subset. */
85
+ graphql?: {
86
+ operation?: string | RegExp;
87
+ variables?: unknown;
88
+ };
72
89
  }
73
90
  type Matcher = string | number | boolean | RegExp | ((value: any) => boolean) | {
74
91
  asymmetricMatch(value: unknown): boolean;
@@ -80,6 +97,8 @@ export interface RouteBuilder {
80
97
  once(): RouteBuilder;
81
98
  /** Wait before answering, e.g. to exercise the app's timeouts. */
82
99
  delay(ms: number): RouteBuilder;
100
+ /** The app may or may not call this route: it isn't reported as unused (`strictStubs`). */
101
+ optional(): RouteBuilder;
83
102
  reply(status: number, body?: unknown, headers?: Record<string, string>): Stub;
84
103
  reply(response: Responder): Stub;
85
104
  /** Answer each matching call with the next response in the list; the last one repeats. */
@@ -87,6 +106,21 @@ export interface RouteBuilder {
87
106
  /** Drop the connection without answering. */
88
107
  networkError(): Stub;
89
108
  }
109
+ /** Builder returned by `stub.graphql()`: `reply()` as usual, or `data()` / `errors()` for a GraphQL answer. */
110
+ export interface GraphQLRouteBuilder extends RouteBuilder {
111
+ times(n: number): GraphQLRouteBuilder;
112
+ once(): GraphQLRouteBuilder;
113
+ delay(ms: number): GraphQLRouteBuilder;
114
+ optional(): GraphQLRouteBuilder;
115
+ /** Answer `{ data }` (a function receives the call, with `call.graphql.variables`). */
116
+ data(data: (call: RecordedCall) => unknown): Stub;
117
+ data(data: unknown): Stub;
118
+ /** Answer `{ errors, data }` with status 200, as GraphQL servers report resolver errors. */
119
+ errors(errors: (string | {
120
+ message: string;
121
+ [k: string]: unknown;
122
+ })[], data?: unknown): Stub;
123
+ }
90
124
  /**
91
125
  * A fake outbound service. The app is pointed at `url`; tests register routes
92
126
  * with `on()` and inspect what the app sent with `calls()`.
@@ -105,6 +139,15 @@ export declare class Stub {
105
139
  * `call.params`) or be a RegExp; `method` may be `*`. Later routes win.
106
140
  */
107
141
  on(method: string, path: string | RegExp, match?: MatchOptions): RouteBuilder;
142
+ /**
143
+ * Answer a GraphQL operation, whatever path the app posts it to:
144
+ * `stub("github").graphql("CreateIssue", { variables: { title: "Bug" } }).data({ createIssue: { issue: { number: 1 } } })`.
145
+ * The operation is `operationName`, or the name in the document when the client sends none.
146
+ */
147
+ graphql(operation: string | RegExp, match?: Omit<MatchOptions, "graphql"> & {
148
+ variables?: unknown;
149
+ path?: string | RegExp;
150
+ }): GraphQLRouteBuilder;
108
151
  /**
109
152
  * Inject faults into this stub's answers for the rest of the scenario:
110
153
  * `stub("payments").chaos({ failFirst: 2 })` to test a retry,
@@ -119,8 +162,15 @@ export declare class Stub {
119
162
  /** Calls received so far, optionally filtered by method, path and conditions (same syntax as `on()`). */
120
163
  calls(method?: string, path?: string | RegExp, match?: MatchOptions): RecordedCall[];
121
164
  unmatched(): RecordedCall[];
165
+ /** Routes registered in this scenario that no call reached, except `optional()` ones. */
166
+ unusedRoutes(): string[];
122
167
  /** Human-readable list of registered routes, for diagnostics. */
123
168
  describeRoutes(): string[];
169
+ /**
170
+ * Why `call` wasn't answered, measured against the registered route it came closest to:
171
+ * `closest route POST /v1/charges: json.amount: expected 100, got "100"`. Undefined without routes.
172
+ */
173
+ explain(call: RecordedCall): string | undefined;
124
174
  reset(): void;
125
175
  /**
126
176
  * Answer calls that no registered route matches, instead of failing with 501.
@@ -132,4 +182,17 @@ export declare class Stub {
132
182
  }
133
183
  /** `expected` is contained in `actual`: objects compare key by key, arrays element-wise. */
134
184
  export declare function subset(expected: unknown, actual: unknown): boolean;
185
+ /**
186
+ * A form body as nested fields, the way Rack, PHP and Stripe read bracket keys: `a[b]=1` is
187
+ * `{ a: { b: "1" } }`, `a[]=1&a[]=2` and `a[0]=1&a[1]=2` are arrays, and a plain key sent
188
+ * twice (`to=1&to=2`) becomes an array too.
189
+ */
190
+ export declare function parseForm(body: string): Record<string, unknown>;
191
+ /** A file in a multipart body, as `call.form` shows it. `text` only for text, JSON, XML and CSV files. */
192
+ export interface UploadedFile {
193
+ filename: string;
194
+ type: string;
195
+ size: number;
196
+ text?: string;
197
+ }
135
198
  export {};