slicetest 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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;
@@ -182,7 +187,9 @@ export class Runtime {
182
187
  const app = await App.start(opts.app, opts.root, vars);
183
188
  if (opts.openapi.fromApp)
184
189
  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);
190
+ const runtime = new Runtime(app, services, db, stubs, opts, vars, specs, shared.coverageDir, recorders, shared.recordDir, containers, mailbox, issuer, queryLog, interceptor, neon);
191
+ runtime.#usageDir = shared.usageDir;
192
+ return runtime;
186
193
  }
187
194
  catch (e) {
188
195
  await Promise.all([...services.values()].map((s) => s.stop()));
@@ -227,6 +234,7 @@ export class Runtime {
227
234
  return c;
228
235
  },
229
236
  trace: async (opts) => mask(buildTrace(this.http.history, this.stubs.values(), await this.db.changesSinceStart(), this.mailbox), opts),
237
+ diagram: () => this.diagram(),
230
238
  get mail() {
231
239
  if (!mailbox)
232
240
  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 +251,22 @@ export class Runtime {
243
251
  #processes() {
244
252
  return [this.app, ...this.services.values()];
245
253
  }
254
+ #usageDir;
255
+ /** Note which provider operations the stubs were called for, before their calls are cleared. */
256
+ #collectUsage() {
257
+ for (const [name, spec] of this.specs.stubs) {
258
+ const used = this.#used.get(name) ?? new Set();
259
+ for (const call of this.stubs.get(name).calls()) {
260
+ const op = spec.operationOf(call.method, call.path);
261
+ if (op)
262
+ used.add(op.key);
263
+ }
264
+ if (used.size)
265
+ this.#used.set(name, used);
266
+ }
267
+ }
246
268
  async beforeScenario() {
269
+ this.#collectUsage();
247
270
  // A crash already failed the scenario that caused it; give the next one a fresh process.
248
271
  for (const [name, service] of this.services) {
249
272
  if (!service.exited)
@@ -286,6 +309,14 @@ export class Runtime {
286
309
  if (contract.length > 0) {
287
310
  throw new Error(`slicetest: traffic doesn't match the OpenAPI spec:\n${contract.map((c) => ` ${c}`).join("\n")}`);
288
311
  }
312
+ const unused = this.#unusedRoutes();
313
+ if (this.opts.strictStubs && unused.length > 0) {
314
+ throw new Error(`slicetest: stub routes the app never called (strictStubs):\n${unused.join("\n")}\n` +
315
+ "Remove them, check that the scenario reaches the code that calls them, or mark them .optional() (YAML: optional: true).");
316
+ }
317
+ }
318
+ #unusedRoutes() {
319
+ return [...this.stubs.values()].flatMap((s) => s.unusedRoutes().map((r) => ` ${s.name}: ${r}`));
289
320
  }
290
321
  /** The app's responses against its spec, and its calls to stubs (and the stubs' replies) against theirs. */
291
322
  #contractViolations() {
@@ -322,12 +353,45 @@ export class Runtime {
322
353
  return [];
323
354
  const routes = s.describeRoutes();
324
355
  return [
325
- ...calls.map((c) => ` ${s.name}: ${c.method} ${c.path}${c.query.size ? `?${c.query}` : ""}`),
356
+ ...calls.flatMap((c) => {
357
+ const why = s.explain(c);
358
+ return [` ${s.name}: ${c.method} ${c.path}${c.query.size ? `?${c.query}` : ""}`, ...(why ? [` ${why}`] : [])];
359
+ }),
326
360
  ` registered on ${s.name}: ${routes.length ? routes.join(", ") : "(none)"}`,
327
361
  ...(this.recorders.has(s.name) ? [` ${this.recorders.get(s.name).hint()}`] : []),
328
362
  ];
329
363
  });
330
364
  }
365
+ /** The current scenario as a Mermaid sequence diagram. */
366
+ async diagram() {
367
+ const changes = this.opts.db.none ? undefined : await this.db.changesSinceStart().catch(() => undefined);
368
+ return sequenceDiagram(this.http.history, this.stubs.values(), changes, this.mailbox);
369
+ }
370
+ /**
371
+ * After a scenario: its diagram goes to the `SLICETEST_DIAGRAMS` directory (one Markdown page
372
+ * per test file) and, when it failed on GitHub Actions, to the job summary.
373
+ */
374
+ async reportDiagram(file, scenario, failed, env = process.env) {
375
+ const dir = env.SLICETEST_DIAGRAMS;
376
+ const summary = failed && env.GITHUB_STEP_SUMMARY;
377
+ if (!dir && !summary)
378
+ return;
379
+ let diagram;
380
+ try {
381
+ diagram = await this.diagram();
382
+ }
383
+ catch {
384
+ return;
385
+ }
386
+ const rel = path.relative(this.opts.root, file).replace(/\\/g, "/");
387
+ if (summary)
388
+ await appendSummary(failureDiagram(rel, scenario, diagram), env);
389
+ if (dir) {
390
+ const out = path.resolve(this.opts.root, dir, `${rel.replace(/^(\.\.\/)+/, "")}.md`);
391
+ await mkdir(path.dirname(out), { recursive: true });
392
+ await writeFile(out, diagramPage(out, rel, scenario, diagram, failed)).catch(() => { });
393
+ }
394
+ }
331
395
  /** What happened during the current scenario, printed when it fails. */
332
396
  async diagnostics() {
333
397
  const sections = [];
@@ -339,6 +403,9 @@ export class Runtime {
339
403
  const unmatched = this.#unmatched();
340
404
  if (unmatched.length > 0)
341
405
  sections.push(`stub calls with no matching route:\n${unmatched.join("\n")}`);
406
+ const unused = this.#unusedRoutes();
407
+ if (unused.length > 0)
408
+ sections.push(`stub routes the app never called:\n${unused.join("\n")}`);
342
409
  const chaos = [...this.stubs.values()].map((s) => s.describeChaos()).filter((c) => c !== undefined);
343
410
  if (chaos.length > 0)
344
411
  sections.push(chaos.join("\n"));
@@ -375,6 +442,12 @@ export class Runtime {
375
442
  }
376
443
  /** Hand the coverage and recordings gathered so far to the run (merged when it ends). */
377
444
  async flush() {
445
+ this.#collectUsage();
446
+ if (this.#usageDir && this.#used.size > 0) {
447
+ const data = Object.fromEntries([...this.#used].map(([k, v]) => [k, [...v]]));
448
+ await writeFile(path.join(this.#usageDir, `${process.pid}-${randomUUID()}.json`), JSON.stringify(data)).catch(() => { });
449
+ this.#used.clear();
450
+ }
378
451
  if (this.coverageDir && this.#covered.size > 0) {
379
452
  await writeFile(path.join(this.coverageDir, `${process.pid}-${randomUUID()}.json`), JSON.stringify([...this.#covered])).catch(() => { });
380
453
  this.#covered.clear();
@@ -439,25 +512,6 @@ function parseJson(text) {
439
512
  return undefined;
440
513
  }
441
514
  }
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
515
  /** The app's own spec, served at `route` (springdoc's /v3/api-docs, FastAPI's /openapi.json, …). */
462
516
  async function fetchAppSpec(appUrl, route, coverageDir) {
463
517
  let text;
@@ -483,3 +537,4 @@ export function blockedHint(hosts) {
483
537
  return message;
484
538
  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
539
  }
540
+ export { connectionVars };
package/dist/scenario.js CHANGED
@@ -15,12 +15,15 @@ function define(register) {
15
15
  if (task.concurrent) {
16
16
  throw new Error("slicetest: scenarios share one app and database per file, so they can't run concurrently. Remove .concurrent / sequence.concurrent.");
17
17
  }
18
+ const file = task.file?.filepath ?? task.file?.name ?? "";
18
19
  onTestFailed(async () => {
19
20
  console.error(`--- slicetest ---\n${await runtime.diagnostics()}\n-----------------`);
21
+ await runtime.reportDiagram(file, task.name, true);
20
22
  });
21
23
  await runtime.beforeScenario();
22
24
  await body(runtime.context());
23
25
  await runtime.afterScenario();
26
+ await runtime.reportDiagram(file, task.name, false);
24
27
  }, timeout);
25
28
  }
26
29
  /**
@@ -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;
@@ -21,6 +22,8 @@ export interface RecordedCall {
21
22
  fallback?: boolean;
22
23
  /** The fault `chaos()` injected instead of the normal answer: `503`, `reset`. */
23
24
  fault?: string;
25
+ /** The GraphQL operation, when the call is a GraphQL request. */
26
+ graphql?: GraphQLCall;
24
27
  }
25
28
  /**
26
29
  * Faults injected into a stub's answers, to test the app's retries, timeouts and
@@ -69,6 +72,11 @@ export interface MatchOptions {
69
72
  headers?: Record<string, Matcher>;
70
73
  json?: unknown;
71
74
  body?: Matcher;
75
+ /** A GraphQL request for this operation (its name, or a RegExp), with `variables` as a subset. */
76
+ graphql?: {
77
+ operation?: string | RegExp;
78
+ variables?: unknown;
79
+ };
72
80
  }
73
81
  type Matcher = string | number | boolean | RegExp | ((value: any) => boolean) | {
74
82
  asymmetricMatch(value: unknown): boolean;
@@ -80,6 +88,8 @@ export interface RouteBuilder {
80
88
  once(): RouteBuilder;
81
89
  /** Wait before answering, e.g. to exercise the app's timeouts. */
82
90
  delay(ms: number): RouteBuilder;
91
+ /** The app may or may not call this route: it isn't reported as unused (`strictStubs`). */
92
+ optional(): RouteBuilder;
83
93
  reply(status: number, body?: unknown, headers?: Record<string, string>): Stub;
84
94
  reply(response: Responder): Stub;
85
95
  /** Answer each matching call with the next response in the list; the last one repeats. */
@@ -87,6 +97,21 @@ export interface RouteBuilder {
87
97
  /** Drop the connection without answering. */
88
98
  networkError(): Stub;
89
99
  }
100
+ /** Builder returned by `stub.graphql()`: `reply()` as usual, or `data()` / `errors()` for a GraphQL answer. */
101
+ export interface GraphQLRouteBuilder extends RouteBuilder {
102
+ times(n: number): GraphQLRouteBuilder;
103
+ once(): GraphQLRouteBuilder;
104
+ delay(ms: number): GraphQLRouteBuilder;
105
+ optional(): GraphQLRouteBuilder;
106
+ /** Answer `{ data }` (a function receives the call, with `call.graphql.variables`). */
107
+ data(data: (call: RecordedCall) => unknown): Stub;
108
+ data(data: unknown): Stub;
109
+ /** Answer `{ errors, data }` with status 200, as GraphQL servers report resolver errors. */
110
+ errors(errors: (string | {
111
+ message: string;
112
+ [k: string]: unknown;
113
+ })[], data?: unknown): Stub;
114
+ }
90
115
  /**
91
116
  * A fake outbound service. The app is pointed at `url`; tests register routes
92
117
  * with `on()` and inspect what the app sent with `calls()`.
@@ -105,6 +130,15 @@ export declare class Stub {
105
130
  * `call.params`) or be a RegExp; `method` may be `*`. Later routes win.
106
131
  */
107
132
  on(method: string, path: string | RegExp, match?: MatchOptions): RouteBuilder;
133
+ /**
134
+ * Answer a GraphQL operation, whatever path the app posts it to:
135
+ * `stub("github").graphql("CreateIssue", { variables: { title: "Bug" } }).data({ createIssue: { issue: { number: 1 } } })`.
136
+ * The operation is `operationName`, or the name in the document when the client sends none.
137
+ */
138
+ graphql(operation: string | RegExp, match?: Omit<MatchOptions, "graphql"> & {
139
+ variables?: unknown;
140
+ path?: string | RegExp;
141
+ }): GraphQLRouteBuilder;
108
142
  /**
109
143
  * Inject faults into this stub's answers for the rest of the scenario:
110
144
  * `stub("payments").chaos({ failFirst: 2 })` to test a retry,
@@ -119,8 +153,15 @@ export declare class Stub {
119
153
  /** Calls received so far, optionally filtered by method, path and conditions (same syntax as `on()`). */
120
154
  calls(method?: string, path?: string | RegExp, match?: MatchOptions): RecordedCall[];
121
155
  unmatched(): RecordedCall[];
156
+ /** Routes registered in this scenario that no call reached, except `optional()` ones. */
157
+ unusedRoutes(): string[];
122
158
  /** Human-readable list of registered routes, for diagnostics. */
123
159
  describeRoutes(): string[];
160
+ /**
161
+ * Why `call` wasn't answered, measured against the registered route it came closest to:
162
+ * `closest route POST /v1/charges: json.amount: expected 100, got "100"`. Undefined without routes.
163
+ */
164
+ explain(call: RecordedCall): string | undefined;
124
165
  reset(): void;
125
166
  /**
126
167
  * Answer calls that no registered route matches, instead of failing with 501.
package/dist/stub.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import http from "node:http";
2
2
  import { isDeepStrictEqual } from "node:util";
3
+ import { describeGraphQL, graphqlErrors, graphqlOf } from "./graphql.js";
4
+ import { timeline } from "./timeline.js";
3
5
  /**
4
6
  * A streaming reply in Server-Sent Events format, as LLM APIs stream (Anthropic, OpenAI):
5
7
  * `stub("anthropic").on("POST", "/v1/messages").reply(sse([["message_start", {...}], ...]))`.
@@ -54,6 +56,28 @@ export class Stub {
54
56
  * `call.params`) or be a RegExp; `method` may be `*`. Later routes win.
55
57
  */
56
58
  on(method, path, match = {}) {
59
+ return this.#on(method, path, match);
60
+ }
61
+ /**
62
+ * Answer a GraphQL operation, whatever path the app posts it to:
63
+ * `stub("github").graphql("CreateIssue", { variables: { title: "Bug" } }).data({ createIssue: { issue: { number: 1 } } })`.
64
+ * The operation is `operationName`, or the name in the document when the client sends none.
65
+ */
66
+ graphql(operation, match = {}) {
67
+ const { variables, path, ...rest } = match;
68
+ const builder = this.#on("*", path ?? /.*/, { ...rest, graphql: { operation, variables } }, `GraphQL ${operation}${path ? ` at ${path}` : ""}`);
69
+ const gql = {
70
+ ...builder,
71
+ times: (n) => (builder.times(n), gql),
72
+ once: () => (builder.once(), gql),
73
+ delay: (ms) => (builder.delay(ms), gql),
74
+ optional: () => (builder.optional(), gql),
75
+ data: (data) => builder.reply(async (call) => ({ status: 200, body: { data: typeof data === "function" ? await data(call) : data } })),
76
+ errors: (errors, data) => builder.reply({ status: 200, body: { errors: graphqlErrors(errors), ...(data === undefined ? {} : { data }) } }),
77
+ };
78
+ return gql;
79
+ }
80
+ #on(method, path, match, label) {
57
81
  const { pattern, paramNames } = compilePath(path);
58
82
  const route = {
59
83
  method: method.toUpperCase(),
@@ -64,6 +88,7 @@ export class Stub {
64
88
  remaining: Infinity,
65
89
  delayMs: 0,
66
90
  hits: 0,
91
+ label,
67
92
  };
68
93
  const add = (respond, extra = {}) => {
69
94
  this.#routes.unshift({ ...route, ...extra, respond });
@@ -73,6 +98,7 @@ export class Stub {
73
98
  times: (n) => ((route.remaining = n), builder),
74
99
  once: () => builder.times(1),
75
100
  delay: (ms) => ((route.delayMs = ms), builder),
101
+ optional: () => ((route.optional = true), builder),
76
102
  reply: (statusOrResponse, body, headers) => add(typeof statusOrResponse === "number" ? { status: statusOrResponse, body, headers } : statusOrResponse),
77
103
  replySequence: (responses) => {
78
104
  if (responses.length === 0)
@@ -162,14 +188,53 @@ export class Stub {
162
188
  unmatched() {
163
189
  return this.#calls.filter((c) => !c.matched);
164
190
  }
191
+ /** Routes registered in this scenario that no call reached, except `optional()` ones. */
192
+ unusedRoutes() {
193
+ return this.#routes.filter((r) => r.hits === 0 && !r.optional).map((r) => r.label ?? `${r.method} ${r.path}`).reverse();
194
+ }
165
195
  /** Human-readable list of registered routes, for diagnostics. */
166
196
  describeRoutes() {
167
197
  return this.#routes.map((r) => {
168
198
  const limit = Number.isFinite(r.remaining + r.hits) ? ` (${r.hits}/${r.remaining + r.hits} used)` : "";
169
- const cond = Object.keys(r.match).length ? ` + ${Object.keys(r.match).join("/")} conditions` : "";
170
- return `${r.method} ${r.path}${cond}${limit}`;
199
+ const keys = Object.keys(r.match).filter((k) => !(k === "graphql" && r.label));
200
+ const cond = keys.length ? ` + ${keys.join("/")} conditions` : "";
201
+ return `${r.label ?? `${r.method} ${r.path}`}${cond}${limit}`;
171
202
  });
172
203
  }
204
+ /**
205
+ * Why `call` wasn't answered, measured against the registered route it came closest to:
206
+ * `closest route POST /v1/charges: json.amount: expected 100, got "100"`. Undefined without routes.
207
+ */
208
+ explain(call) {
209
+ let best;
210
+ for (const route of this.#routes) {
211
+ const reasons = [];
212
+ let score = 0;
213
+ if (route.method !== "*" && route.method !== call.method) {
214
+ reasons.push(`method is ${call.method}, the route takes ${route.method}`);
215
+ score += 1;
216
+ }
217
+ if (!matchPath(route.path, route.pattern, call.path, route.paramNames)) {
218
+ reasons.push(pathHint(route.path, call.path));
219
+ score += 2 + (typeof route.path === "string" ? Math.min(distance(route.path, call.path) / 4, 3) : 1);
220
+ }
221
+ const why = conditionMismatch(route.match, call);
222
+ if (why) {
223
+ reasons.push(why);
224
+ score += 1;
225
+ }
226
+ if (reasons.length === 0 && route.remaining <= 0) {
227
+ reasons.push(`the route already answered its ${route.hits} call(s) (once() / times())`);
228
+ score += 0.5;
229
+ }
230
+ if (!best || score < best.score)
231
+ best = { route, reasons, score };
232
+ }
233
+ if (!best)
234
+ return undefined;
235
+ const label = best.route.label ?? `${best.route.method} ${best.route.path}`;
236
+ return `closest route ${label}: ${best.reasons.join("; ") || "matches now (registered after the call arrived?)"}`;
237
+ }
173
238
  reset() {
174
239
  this.#routes = [];
175
240
  this.#calls = [];
@@ -190,6 +255,7 @@ export class Stub {
190
255
  await new Promise((resolve) => this.#server.close(resolve));
191
256
  }
192
257
  async #handle(req, res) {
258
+ const start = performance.now();
193
259
  const chunks = [];
194
260
  for await (const chunk of req)
195
261
  chunks.push(chunk);
@@ -205,6 +271,9 @@ export class Stub {
205
271
  params: {},
206
272
  matched: false,
207
273
  };
274
+ call.graphql = graphqlOf(call);
275
+ timeline.set(call, { start });
276
+ res.once("close", () => (timeline.get(call).end = performance.now()));
208
277
  this.#calls.push(call);
209
278
  let route;
210
279
  for (const r of this.#routes) {
@@ -236,7 +305,8 @@ export class Stub {
236
305
  }
237
306
  if (!out) {
238
307
  const hint = this.#hint ? ` (${this.#hint})` : "";
239
- res.writeHead(501, { "content-type": "text/plain" }).end(`slicetest: no stub for ${call.method} ${call.path}${hint}`);
308
+ const what = call.graphql ? `${describeGraphQL(call.graphql)} (${call.method} ${call.path})` : `${call.method} ${call.path}`;
309
+ res.writeHead(501, { "content-type": "text/plain" }).end(`slicetest: no stub for ${what}${hint}`);
240
310
  return;
241
311
  }
242
312
  call.matched = true;
@@ -318,8 +388,122 @@ function matchConditions(match, call) {
318
388
  return false;
319
389
  if (match.json !== undefined && !subset(match.json, call.json))
320
390
  return false;
391
+ if (match.graphql) {
392
+ const g = call.graphql;
393
+ if (!g)
394
+ return false;
395
+ const { operation, variables } = match.graphql;
396
+ if (operation !== undefined && !(operation instanceof RegExp ? g.operation !== undefined && operation.test(g.operation) : g.operation === operation))
397
+ return false;
398
+ if (variables !== undefined && !subset(variables, g.variables))
399
+ return false;
400
+ }
321
401
  return true;
322
402
  }
403
+ /** The first condition of `match` that `call` fails, described; undefined when it meets them all. */
404
+ function conditionMismatch(match, call) {
405
+ for (const [k, m] of Object.entries(match.query ?? {})) {
406
+ const v = call.query.get(k) ?? undefined;
407
+ if (!test(m, v))
408
+ return `query ${k}: expected ${show(m)}, got ${v === undefined ? "nothing" : JSON.stringify(v)}`;
409
+ }
410
+ for (const [k, m] of Object.entries(match.headers ?? {})) {
411
+ const raw = call.headers[k.toLowerCase()];
412
+ const v = Array.isArray(raw) ? raw.join(", ") : raw;
413
+ if (!test(m, v))
414
+ return `header ${k.toLowerCase()}: expected ${show(m)}, got ${v === undefined ? "nothing" : JSON.stringify(v)}`;
415
+ }
416
+ if (match.body !== undefined && !test(match.body, call.body))
417
+ return `body: expected ${show(match.body)}, got ${JSON.stringify(call.body.slice(0, 100))}`;
418
+ if (match.json !== undefined) {
419
+ if (call.json === undefined)
420
+ return `json: expected a JSON body, got ${call.body ? JSON.stringify(call.body.slice(0, 100)) : "an empty body"}`;
421
+ const diff = difference(match.json, call.json, "json");
422
+ if (diff)
423
+ return diff;
424
+ }
425
+ if (match.graphql) {
426
+ const g = call.graphql;
427
+ if (!g)
428
+ return "graphql: the call isn't a GraphQL request";
429
+ const { operation, variables } = match.graphql;
430
+ if (operation !== undefined && !(operation instanceof RegExp ? g.operation !== undefined && operation.test(g.operation) : g.operation === operation)) {
431
+ return `graphql operation: expected ${show(operation)}, got ${g.operation ?? "an anonymous operation"}`;
432
+ }
433
+ if (variables !== undefined) {
434
+ const diff = difference(variables, g.variables, "variables");
435
+ if (diff)
436
+ return diff;
437
+ }
438
+ }
439
+ return undefined;
440
+ }
441
+ /** Where `actual` first stops containing `expected` (the rules of `subset`), as `json.items.0.sku: expected "a", got "b"`. */
442
+ function difference(expected, actual, at) {
443
+ if (subset(expected, actual))
444
+ return undefined;
445
+ if (Array.isArray(expected) && Array.isArray(actual)) {
446
+ if (expected.length !== actual.length)
447
+ return `${at}: expected ${expected.length} item(s), got ${actual.length}`;
448
+ for (const [i, e] of expected.entries()) {
449
+ const d = difference(e, actual[i], `${at}.${i}`);
450
+ if (d)
451
+ return d;
452
+ }
453
+ }
454
+ if (expected && typeof expected === "object" && !Array.isArray(expected) && !isAsymmetric(expected) && !(expected instanceof RegExp) && actual && typeof actual === "object" && !Array.isArray(actual)) {
455
+ for (const [k, v] of Object.entries(expected)) {
456
+ if (!(k in actual))
457
+ return `${at}.${k}: expected ${show(v)}, got nothing`;
458
+ const d = difference(v, actual[k], `${at}.${k}`);
459
+ if (d)
460
+ return d;
461
+ }
462
+ }
463
+ return `${at}: expected ${show(expected)}, got ${short(actual)}`;
464
+ }
465
+ function show(m) {
466
+ if (isAsymmetric(m))
467
+ return m.toAsymmetricMatcher?.() ?? String(m);
468
+ if (m instanceof RegExp)
469
+ return String(m);
470
+ if (typeof m === "function")
471
+ return "a value the given function accepts";
472
+ return short(m);
473
+ }
474
+ function short(v) {
475
+ const s = v === undefined ? "nothing" : JSON.stringify(v);
476
+ return s.length > 80 ? `${s.slice(0, 79)}…` : s;
477
+ }
478
+ /** What is off about the path: a trailing slash, letter case, a prefix, or just a different path. */
479
+ function pathHint(expected, actual) {
480
+ if (typeof expected !== "string")
481
+ return `path ${actual} doesn't match ${expected}`;
482
+ const trim = (p) => (p.length > 1 ? p.replace(/\/+$/, "") : p);
483
+ if (trim(expected) === trim(actual))
484
+ return `path is ${actual}, the route is ${expected} (trailing slash)`;
485
+ if (expected.toLowerCase() === actual.toLowerCase())
486
+ return `path is ${actual}, the route is ${expected} (letter case)`;
487
+ if (actual.endsWith(expected))
488
+ return `path is ${actual}, the route is ${expected}: is the base URL's path (${actual.slice(0, -expected.length)}) in the env value?`;
489
+ if (expected.endsWith(actual))
490
+ return `path is ${actual}, the route is ${expected}: the base URL the app was given may lack ${expected.slice(0, -actual.length)}`;
491
+ return `path is ${actual}, the route is ${expected}`;
492
+ }
493
+ /** Levenshtein distance, to find the route whose path is nearest. */
494
+ function distance(a, b) {
495
+ const row = Array.from({ length: b.length + 1 }, (_, i) => i);
496
+ for (let i = 1; i <= a.length; i++) {
497
+ let prev = row[0];
498
+ row[0] = i;
499
+ for (let j = 1; j <= b.length; j++) {
500
+ const tmp = row[j];
501
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
502
+ prev = tmp;
503
+ }
504
+ }
505
+ return row[b.length];
506
+ }
323
507
  function test(m, value) {
324
508
  if (isAsymmetric(m))
325
509
  return m.asymmetricMatch(value);
@@ -0,0 +1,9 @@
1
+ /**
2
+ * When requests to the app and calls to stubs started and ended (`performance.now()`),
3
+ * kept beside the objects rather than on them, so that the public shapes (and snapshots
4
+ * of them) don't change. Used to put a scenario's events in order for `diagram()`.
5
+ */
6
+ export declare const timeline: WeakMap<object, {
7
+ start: number;
8
+ end?: number;
9
+ }>;
@@ -0,0 +1,6 @@
1
+ /**
2
+ * When requests to the app and calls to stubs started and ended (`performance.now()`),
3
+ * kept beside the objects rather than on them, so that the public shapes (and snapshots
4
+ * of them) don't change. Used to put a scenario's events in order for `diagram()`.
5
+ */
6
+ export const timeline = new WeakMap();