slicetest 0.8.0 → 0.9.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.
@@ -1,5 +1,5 @@
1
1
  import { type Where } from "./db.js";
2
- import type { HttpResponse } from "./http.js";
2
+ import { type HttpResponse } from "./http.js";
3
3
  import { type MatchOptions } from "./stub.js";
4
4
  /** One expected call for `toHaveReceivedInOrder`: `[stub, method, path, match?]`. */
5
5
  export type OrderedCall = [stub: string, method: string, path: string | RegExp, match?: MatchOptions];
@@ -22,8 +22,16 @@ interface SlicetestMatchers<R = unknown> {
22
22
  * relative to the working directory with an optional pointer, `openapi.yaml#/components/schemas/Poll`.
23
23
  */
24
24
  toMatchSchema(schema: object | string): R;
25
- /** The response has this status; the failure message shows the response body. */
26
- toHaveStatus(status: number): R;
25
+ /** The response has this status, a status of this class (`"2xx"`) or one of a list (`[200, 204]`); the failure message shows the response body. */
26
+ toHaveStatus(status: number | `${1 | 2 | 3 | 4 | 5}xx` | (number | `${1 | 2 | 3 | 4 | 5}xx`)[]): R;
27
+ /**
28
+ * The response sets cookie `name`, with these attributes as a subset (`value`, `httpOnly`,
29
+ * `secure`, `sameSite`, `path`, `domain`, `maxAge`, `expires`, `partitioned`):
30
+ * `expect(res).toSetCookie("sid", { httpOnly: true, secure: true, sameSite: "Lax" })`.
31
+ */
32
+ toSetCookie(name: string, attributes?: Record<string, unknown>): R;
33
+ /** The response arrived within `ms` milliseconds (measured from sending the request to reading the whole body). */
34
+ toRespondWithin(ms: number): R;
27
35
  /** An array of responses has exactly these status counts, e.g. `{ 201: 1, 409: 9 }`. */
28
36
  toHaveStatuses(counts: Record<number, number>): R;
29
37
  /** Async: the table has at least one row matching `where` (`count` for an exact number). */
@@ -33,5 +41,8 @@ declare module "vitest" {
33
41
  interface Matchers<R extends void | Promise<void> = void | Promise<void>, T = unknown> extends SlicetestMatchers<R> {
34
42
  }
35
43
  }
44
+ /** A status code, a class (`"2xx"`), or a list of either: `[200, 204]`, `["2xx", 304]`. */
45
+ export type ExpectedStatus = number | string | (number | string)[];
46
+ export declare function statusMatches(expected: ExpectedStatus, actual: number): boolean;
36
47
  export declare function statusCounts(responses: HttpResponse[]): Record<number, number>;
37
48
  export {};
package/dist/matchers.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { expect } from "vitest";
2
2
  import { Db } from "./db.js";
3
+ import { setCookies } from "./http.js";
3
4
  import { describeGraphQL } from "./graphql.js";
4
5
  import { schemaProblems } from "./schema.js";
5
6
  import { Stub, subset } from "./stub.js";
@@ -22,6 +23,24 @@ function assertStub(received) {
22
23
  if (!(received instanceof Stub))
23
24
  throw new TypeError("slicetest: expected a stub, e.g. expect(stub(\"slack\"))");
24
25
  }
26
+ export function statusMatches(expected, actual) {
27
+ if (Array.isArray(expected)) {
28
+ if (expected.length === 0)
29
+ throw new TypeError("slicetest: toHaveStatus needs at least one status in the list");
30
+ return expected.some((e) => statusMatches(e, actual));
31
+ }
32
+ if (typeof expected === "number")
33
+ return expected === actual;
34
+ const s = String(expected).trim().toLowerCase();
35
+ if (/^\d{3}$/.test(s))
36
+ return Number(s) === actual;
37
+ if (/^[1-5]xx$/.test(s))
38
+ return Math.floor(actual / 100) === Number(s[0]);
39
+ throw new TypeError(`slicetest: a status is a code (201), a class ("2xx") or a list of them, got ${JSON.stringify(expected)}`);
40
+ }
41
+ function describeStatus(expected) {
42
+ return Array.isArray(expected) ? `one of ${expected.join(", ")}` : String(expected);
43
+ }
25
44
  export function statusCounts(responses) {
26
45
  const out = {};
27
46
  for (const r of responses)
@@ -140,18 +159,54 @@ expect.extend({
140
159
  };
141
160
  },
142
161
  toHaveStatus(received, status) {
143
- const pass = received?.status === status;
162
+ if (!received || typeof received !== "object" || typeof received.status !== "number") {
163
+ throw new TypeError(`slicetest: toHaveStatus expects a response from http, got ${this.utils.stringify(received)}`);
164
+ }
165
+ const pass = statusMatches(status, received.status);
144
166
  return {
145
167
  pass,
146
168
  message: () => {
147
169
  const body = received.text.length > 1000 ? `${received.text.slice(0, 1000)}…` : received.text;
148
- return (`expected ${received.method} ${received.url} ${this.isNot ? "not " : ""}to respond ${status}, got ${received.status}\n` +
170
+ return (`expected ${received.method} ${received.url} ${this.isNot ? "not " : ""}to respond ${describeStatus(status)}, got ${received.status}\n` +
149
171
  `Response body:\n ${body || "(empty)"}`);
150
172
  },
151
173
  actual: received?.status,
152
174
  expected: status,
153
175
  };
154
176
  },
177
+ toSetCookie(received, name, attributes = {}) {
178
+ if (!received || typeof received !== "object" || !(received.headers instanceof Headers)) {
179
+ throw new TypeError(`slicetest: toSetCookie expects a response from http, got ${this.utils.stringify(received)}`);
180
+ }
181
+ const cookies = setCookies(received);
182
+ const cookie = Object.hasOwn(cookies, name) ? cookies[name] : undefined;
183
+ // SameSite values are case-insensitive: "Lax" and "lax" are the same attribute. A matcher sees the value as sent.
184
+ const plain = typeof attributes.sameSite === "string";
185
+ const want = plain ? { ...attributes, sameSite: attributes.sameSite.toLowerCase() } : attributes;
186
+ const pass = !!cookie && subset(want, plain ? { ...cookie, sameSite: cookie.sameSite?.toLowerCase() } : cookie);
187
+ const describe = (c) => `${c.name}=${c.value.length > 40 ? `${c.value.slice(0, 40)}…` : c.value} ${JSON.stringify(Object.fromEntries(Object.entries(c).filter(([k, v]) => k !== "name" && k !== "value" && v !== undefined && v !== false)))}`;
188
+ return {
189
+ pass,
190
+ message: () => {
191
+ const head = `expected ${received.method} ${received.url} ${this.isNot ? "not " : ""}to set cookie ${name}${Object.keys(attributes).length ? ` with ${this.utils.stringify(attributes, 10, { min: true })}` : ""}`;
192
+ const all = Object.values(cookies);
193
+ return `${head}\n${all.length ? `Cookies it set:\n${all.map((c) => ` ${describe(c)}`).join("\n")}` : "It set no cookies."}`;
194
+ },
195
+ };
196
+ },
197
+ toRespondWithin(received, ms) {
198
+ if (!received || typeof received !== "object" || typeof received.durationMs !== "number") {
199
+ throw new TypeError(`slicetest: toRespondWithin expects a response from http, got ${this.utils.stringify(received)}`);
200
+ }
201
+ if (!(typeof ms === "number" && ms > 0))
202
+ throw new TypeError(`slicetest: toRespondWithin needs a positive number of milliseconds, got ${this.utils.stringify(ms)}`);
203
+ return {
204
+ pass: received.durationMs <= ms,
205
+ message: () => `expected ${received.method} ${received.url} ${this.isNot ? "not " : ""}to respond within ${ms}ms, it took ${received.durationMs}ms`,
206
+ actual: received.durationMs,
207
+ expected: ms,
208
+ };
209
+ },
155
210
  toHaveStatuses(received, counts) {
156
211
  if (!Array.isArray(received))
157
212
  throw new TypeError("slicetest: toHaveStatuses expects an array of responses, e.g. from http.concurrently()");
package/dist/openapi.d.ts CHANGED
@@ -31,11 +31,6 @@ export interface Message {
31
31
  body: unknown;
32
32
  query?: URLSearchParams;
33
33
  }
34
- /**
35
- * An OpenAPI 3.0 / 3.1 document, used to check that real traffic matches it:
36
- * the app's responses against the app's own spec, and the app's calls to a
37
- * stubbed service (and the stub's canned replies) against that service's spec.
38
- */
39
34
  export declare class OpenApiSpec {
40
35
  #private;
41
36
  readonly file: string;
package/dist/openapi.js CHANGED
@@ -5,11 +5,17 @@ import addFormatsModule from "ajv-formats";
5
5
  import { parse } from "yaml";
6
6
  const addFormats = (addFormatsModule.default ?? addFormatsModule);
7
7
  const METHODS = ["get", "put", "post", "delete", "options", "head", "patch", "trace"];
8
- /**
9
- * An OpenAPI 3.0 / 3.1 document, used to check that real traffic matches it:
10
- * the app's responses against the app's own spec, and the app's calls to a
11
- * stubbed service (and the stub's canned replies) against that service's spec.
12
- */
8
+ /** A server's URL with its `{variables}` filled in: every `enum` value, else the default. */
9
+ function serverUrls(server) {
10
+ let urls = [server.url ?? "/"];
11
+ for (const [name, v] of Object.entries(server.variables ?? {})) {
12
+ const values = [...new Set([...(v.default === undefined ? [] : [v.default]), ...(v.enum ?? [])].map(String))];
13
+ if (values.length === 0 || !urls[0].includes(`{${name}}`))
14
+ continue;
15
+ urls = urls.flatMap((u) => values.map((value) => u.replaceAll(`{${name}}`, value))).slice(0, 50);
16
+ }
17
+ return urls;
18
+ }
13
19
  export class OpenApiSpec {
14
20
  file;
15
21
  doc;
@@ -45,7 +51,8 @@ export class OpenApiSpec {
45
51
  // Paths in the spec are relative to the server URL, e.g. /v1 for https://api.example.com/v1.
46
52
  this.#basePaths = [
47
53
  ...new Set((doc.servers ?? [])
48
- .map((s) => new URL(s.url ?? "/", "http://x").pathname.replace(/\/$/, ""))
54
+ .flatMap((s) => serverUrls(s))
55
+ .map((url) => new URL(url, "http://x").pathname.replace(/\/$/, ""))
49
56
  .filter(Boolean)),
50
57
  ];
51
58
  }
@@ -38,6 +38,13 @@ export declare class Recorder {
38
38
  /** Recordings made by this worker, to be merged into the file when the run ends. */
39
39
  added(): Recording[];
40
40
  }
41
+ export declare const REDACTED = "[redacted]";
42
+ /**
43
+ * A request with credential values replaced by `[redacted]`, so recordings can be committed and
44
+ * replay whatever key the test environment uses. Applied to recordings and to calls alike before
45
+ * they are compared.
46
+ */
47
+ export declare function redactRequest(r: Recording["request"]): Recording["request"];
41
48
  export declare function readRecordings(file: string): Promise<Recording[]>;
42
49
  /** Append new recordings to `file`, skipping exact duplicates, keeping the order they were made in. */
43
50
  export declare function mergeRecordings(file: string, upstream: string, added: Recording[], source?: string): Promise<void>;
package/dist/recording.js CHANGED
@@ -90,14 +90,48 @@ export class Recorder {
90
90
  }
91
91
  function requestOf(call) {
92
92
  const query = Object.fromEntries([...call.query.entries()].sort(([a], [b]) => a.localeCompare(b)));
93
- return {
93
+ return redactRequest({
94
94
  method: call.method,
95
95
  path: call.path,
96
96
  ...(Object.keys(query).length ? { query } : {}),
97
97
  ...(call.json !== undefined ? { json: call.json } : call.body ? { body: call.body } : {}),
98
- };
98
+ });
99
99
  }
100
- function keyOf(r) {
100
+ /** Credentials sent in a query, a form or JSON body: `api_key`, `key`, `access_token`, `client_secret`, `password`, `appid`, signatures. */
101
+ const SECRET = /^(api[-_]?key|apikey|key|(access|refresh|id|auth)[-_]?token|token|client[-_]?secret|app[-_]?secret|secret|password|passwd|appid|signature|sig|x-amz-(signature|credential|security-token))$/i;
102
+ /** In bodies, a bare `key` is usually data (a KV store's key, a record's key) that tells requests apart, not a credential. */
103
+ const bodySecret = (k) => SECRET.test(k) && k.toLowerCase() !== "key";
104
+ export const REDACTED = "[redacted]";
105
+ const FORM_BODY = /^[^\s=&]+=[^\s&]*(&[^\s=&]+=[^\s&]*)*$/;
106
+ /**
107
+ * A request with credential values replaced by `[redacted]`, so recordings can be committed and
108
+ * replay whatever key the test environment uses. Applied to recordings and to calls alike before
109
+ * they are compared.
110
+ */
111
+ export function redactRequest(r) {
112
+ const out = { ...r };
113
+ if (r.query)
114
+ out.query = Object.fromEntries(Object.entries(r.query).map(([k, v]) => [k, SECRET.test(k) ? REDACTED : v]));
115
+ if (r.json !== undefined)
116
+ out.json = redactJson(r.json);
117
+ if (r.body && FORM_BODY.test(r.body)) {
118
+ const form = new URLSearchParams(r.body);
119
+ if ([...form.keys()].some(bodySecret)) {
120
+ out.body = [...form].map(([k, v]) => `${encodeURIComponent(k)}=${bodySecret(k) ? REDACTED : encodeURIComponent(v)}`).join("&");
121
+ }
122
+ }
123
+ return out;
124
+ }
125
+ function redactJson(v) {
126
+ if (Array.isArray(v))
127
+ return v.map(redactJson);
128
+ if (v && typeof v === "object")
129
+ return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, typeof x === "string" && bodySecret(k) ? REDACTED : redactJson(x)]));
130
+ return v;
131
+ }
132
+ function keyOf(request) {
133
+ // Recordings made before redaction still match: both sides are compared redacted.
134
+ const r = redactRequest(request);
101
135
  return JSON.stringify([r.method.toUpperCase(), r.path, sortKeys(r.query ?? {}), sortKeys(r.json ?? null), r.body ?? ""]);
102
136
  }
103
137
  function sortKeys(v) {
@@ -143,6 +177,6 @@ export async function mergeRecordings(file, upstream, added, source = `Recorded
143
177
  if (!entries.some((x) => isDeepStrictEqual(x, e)))
144
178
  entries.push(e);
145
179
  await mkdir(path.dirname(file), { recursive: true });
146
- const header = `# ${source}. Review before committing: request bodies are stored as sent.\n`;
180
+ const header = `# ${source}. Review before committing: credentials in queries and bodies (api_key, access_token, client_secret, password, …) are replaced by ${REDACTED}, but other values are stored as sent.\n`;
147
181
  await writeFile(file, header + YAML.stringify(entries, { lineWidth: 0 }));
148
182
  }
package/dist/runtime.js CHANGED
@@ -302,6 +302,9 @@ export class Runtime {
302
302
  this.#assertAlive();
303
303
  if (this.interceptor?.blocked.size)
304
304
  throw new Error(blockedHint([...this.interceptor.blocked]));
305
+ const thrown = this.#handlerErrors();
306
+ if (thrown.length > 0)
307
+ 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
308
  const unmatched = this.#unmatched();
306
309
  if (unmatched.length > 0) {
307
310
  throw new Error(`slicetest: the app called stubbed services with no matching route:\n${unmatched.join("\n")}`);
@@ -347,6 +350,12 @@ export class Runtime {
347
350
  }
348
351
  return out;
349
352
  }
353
+ #handlerErrors() {
354
+ return [...this.stubs.values()].flatMap((s) => s.handlerErrors().map(({ call, error }) => {
355
+ const where = error?.stack?.split("\n").find((l) => /^\s+at /.test(l) && !l.includes("node_modules") && !l.includes("node:"))?.trim();
356
+ return ` ${s.name}: ${call.method} ${call.path}: ${error instanceof Error ? error.message : String(error)}${where ? `\n ${where}` : ""}`;
357
+ }));
358
+ }
350
359
  #unmatched() {
351
360
  return [...this.stubs.values()].flatMap((s) => {
352
361
  const calls = s.unmatched();
@@ -401,6 +410,9 @@ export class Runtime {
401
410
  if (exit)
402
411
  sections.push(`the ${p.label} exited (code ${exit.code}, signal ${exit.signal}); it will be restarted for the next scenario`);
403
412
  }
413
+ const thrown = this.#handlerErrors();
414
+ if (thrown.length > 0)
415
+ sections.push(`stub replies that threw:\n${thrown.join("\n")}`);
404
416
  const unmatched = this.#unmatched();
405
417
  if (unmatched.length > 0)
406
418
  sections.push(`stub calls with no matching route:\n${unmatched.join("\n")}`);
@@ -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
@@ -30,6 +30,8 @@ export class Stub {
30
30
  /** Appended to the 501 answer for a call nothing could answer. */
31
31
  #hint;
32
32
  #chaos;
33
+ /** Reply functions (and fallbacks) that threw: the app got a 500 / 502, the scenario must fail with the error. */
34
+ #errors = [];
33
35
  url = "";
34
36
  constructor(name) {
35
37
  this.name = name;
@@ -54,6 +56,7 @@ export class Stub {
54
56
  /**
55
57
  * Answer `method path`. `path` may contain `:name` segments (captured into
56
58
  * `call.params`) or be a RegExp; `method` may be `*`. Later routes win.
59
+ * A query in `path` (`/search?q=tea`) is a condition on those parameters; others may come along.
57
60
  */
58
61
  on(method, path, match = {}) {
59
62
  return this.#on(method, path, match);
@@ -77,7 +80,8 @@ export class Stub {
77
80
  };
78
81
  return gql;
79
82
  }
80
- #on(method, path, match, label) {
83
+ #on(method, fullPath, fullMatch, label) {
84
+ const { path, match } = splitQuery(fullPath, fullMatch);
81
85
  const { pattern, paramNames } = compilePath(path);
82
86
  const route = {
83
87
  method: method.toUpperCase(),
@@ -179,12 +183,17 @@ export class Stub {
179
183
  this.#send(call, res, { status: fault.status, headers: retry, body: { error: "slicetest chaos", status: fault.status } });
180
184
  }
181
185
  /** Calls received so far, optionally filtered by method, path and conditions (same syntax as `on()`). */
182
- calls(method, path, match = {}) {
186
+ calls(method, fullPath, fullMatch = {}) {
187
+ const { path, match } = fullPath === undefined ? { path: undefined, match: fullMatch } : splitQuery(fullPath, fullMatch);
183
188
  const compiled = path === undefined ? undefined : compilePath(path);
184
189
  return this.#calls.filter((c) => (!method || method === "*" || c.method === method.toUpperCase()) &&
185
190
  (!compiled || matchPath(path, compiled.pattern, c.path) !== undefined) &&
186
191
  matchConditions(match, c));
187
192
  }
193
+ /** Calls whose reply function or fallback threw, with the error; the app got a 500 (502 for a fallback). */
194
+ handlerErrors() {
195
+ return this.#errors;
196
+ }
188
197
  unmatched() {
189
198
  return this.#calls.filter((c) => !c.matched);
190
199
  }
@@ -239,6 +248,7 @@ export class Stub {
239
248
  this.#routes = [];
240
249
  this.#calls = [];
241
250
  this.#chaos = undefined;
251
+ this.#errors = [];
242
252
  }
243
253
  /**
244
254
  * Answer calls that no registered route matches, instead of failing with 501.
@@ -303,6 +313,7 @@ export class Stub {
303
313
  out = await this.#fallback?.(call);
304
314
  }
305
315
  catch (e) {
316
+ this.#errors.push({ call, error: e });
306
317
  res.writeHead(502, { "content-type": "text/plain" }).end(e.message);
307
318
  return;
308
319
  }
@@ -330,10 +341,15 @@ export class Stub {
330
341
  this.#send(call, res, typeof route.respond === "function" ? await route.respond(call) : route.respond);
331
342
  }
332
343
  catch (e) {
344
+ this.#errors.push({ call, error: e });
333
345
  res.writeHead(500).end(`slicetest: stub handler threw: ${e}`);
334
346
  }
335
347
  }
336
348
  #send(call, res, out) {
349
+ if (out.body instanceof ArrayBuffer || (ArrayBuffer.isView(out.body) && !(out.body instanceof Uint8Array))) {
350
+ const view = out.body;
351
+ out = { ...out, body: view instanceof ArrayBuffer ? new Uint8Array(view) : new Uint8Array(view.buffer, view.byteOffset, view.byteLength) };
352
+ }
337
353
  const raw = out.body === undefined || typeof out.body === "string" || out.body instanceof Uint8Array;
338
354
  const headers = { ...out.headers };
339
355
  if (!raw && !Object.keys(headers).some((h) => h.toLowerCase() === "content-type")) {
@@ -349,6 +365,15 @@ export class Stub {
349
365
  res.end(payload);
350
366
  }
351
367
  }
368
+ /** `/search?q=tea` is the path `/search` with the query condition `q: "tea"`; `match.query` wins over the path's. */
369
+ function splitQuery(path, match) {
370
+ if (typeof path !== "string" || !path.includes("?"))
371
+ return { path, match };
372
+ const [bare, search] = [path.slice(0, path.indexOf("?")), path.slice(path.indexOf("?") + 1)];
373
+ const params = new URLSearchParams(search);
374
+ const query = Object.fromEntries([...new Set(params.keys())].map((k) => [k, params.getAll(k).length > 1 ? params.getAll(k) : params.get(k)]));
375
+ return { path: bare || "/", match: { ...match, query: { ...query, ...match.query } } };
376
+ }
352
377
  function compilePath(path) {
353
378
  if (typeof path !== "string" || !path.includes("/:"))
354
379
  return { pattern: undefined, paramNames: [] };
@@ -388,7 +413,7 @@ function decodeSegment(segment) {
388
413
  }
389
414
  function matchConditions(match, call) {
390
415
  for (const [k, m] of Object.entries(match.query ?? {})) {
391
- if (!test(m, call.query.get(k) ?? undefined))
416
+ if (!testQuery(m, call.query, k))
392
417
  return false;
393
418
  }
394
419
  for (const [k, m] of Object.entries(match.headers ?? {})) {
@@ -417,9 +442,11 @@ function matchConditions(match, call) {
417
442
  /** The first condition of `match` that `call` fails, described; undefined when it meets them all. */
418
443
  function conditionMismatch(match, call) {
419
444
  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)}`;
445
+ if (testQuery(m, call.query, k))
446
+ continue;
447
+ const all = call.query.getAll(k);
448
+ const got = all.length === 0 ? "nothing" : Array.isArray(m) || all.length > 1 ? JSON.stringify(all) : JSON.stringify(all[0]);
449
+ return `query ${k}: expected ${Array.isArray(m) ? `[${m.map(show).join(", ")}]` : show(m)}, got ${got}`;
423
450
  }
424
451
  for (const [k, m] of Object.entries(match.headers ?? {})) {
425
452
  const raw = call.headers[k.toLowerCase()];
@@ -525,6 +552,13 @@ function distance(a, b) {
525
552
  }
526
553
  return row[b.length];
527
554
  }
555
+ /** A query condition: one matcher for the first value, or a list for every value of a repeated parameter, in order. */
556
+ function testQuery(m, query, key) {
557
+ if (!Array.isArray(m))
558
+ return test(m, query.get(key) ?? undefined);
559
+ const values = query.getAll(key);
560
+ return values.length === m.length && m.every((x, i) => test(x, values[i]));
561
+ }
528
562
  function test(m, value) {
529
563
  if (isAsymmetric(m))
530
564
  return m.asymmetricMatch(value);
@@ -558,6 +592,8 @@ function isAsymmetric(m) {
558
592
  function isForm(contentType) {
559
593
  return !!contentType && contentType.split(";")[0].trim().toLowerCase() === "application/x-www-form-urlencoded";
560
594
  }
595
+ /** Keys kept as plain fields rather than nested into, so a body can't reach `Object.prototype`. */
596
+ const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
561
597
  /**
562
598
  * A form body as nested fields, the way Rack, PHP and Stripe read bracket keys: `a[b]=1` is
563
599
  * `{ a: { b: "1" } }`, `a[]=1&a[]=2` and `a[0]=1&a[1]=2` are arrays, and a plain key sent
@@ -567,12 +603,13 @@ export function parseForm(body) {
567
603
  const out = {};
568
604
  for (const [key, value] of new URLSearchParams(body)) {
569
605
  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];
606
+ const path = m?.[2] ? [m[1], ...[...m[2].matchAll(/\[([^\]]*)\]/g)].map((x) => x[1])] : undefined;
607
+ // `__proto__[x]=1` is sent by whatever the app forwards; it must not reach Object.prototype.
608
+ if (!path || path.some((seg) => UNSAFE_KEYS.has(seg))) {
609
+ const prev = Object.hasOwn(out, key) ? out[key] : undefined;
610
+ Object.defineProperty(out, key, { value: prev === undefined ? value : Array.isArray(prev) ? [...prev, value] : [prev, value], enumerable: true, writable: true, configurable: true });
573
611
  continue;
574
612
  }
575
- const path = [m[1], ...[...m[2].matchAll(/\[([^\]]*)\]/g)].map((x) => x[1])];
576
613
  let node = out;
577
614
  for (let i = 0; i < path.length; i++) {
578
615
  const seg = path[i];
@@ -235,7 +235,7 @@ async function runStep(step, ctx, vars, base) {
235
235
  follow: step.follow,
236
236
  });
237
237
  verifyResponse(res, step.expect, vars, base);
238
- capture(step.capture, { status: res.status, json: res.json, text: res.text, headers: Object.fromEntries(res.headers) }, vars);
238
+ capture(step.capture, { status: res.status, json: res.json, text: res.text, headers: Object.fromEntries(res.headers), events: res.events }, vars);
239
239
  return;
240
240
  }
241
241
  if ("request" in step) {
@@ -296,7 +296,7 @@ async function runStep(step, ctx, vars, base) {
296
296
  }
297
297
  }
298
298
  verify(res);
299
- capture(step.capture, { status: res.status, json: res.json, text: res.text, headers: Object.fromEntries(res.headers) }, vars);
299
+ capture(step.capture, { status: res.status, json: res.json, text: res.text, headers: Object.fromEntries(res.headers), events: res.events }, vars);
300
300
  return;
301
301
  }
302
302
  if ("insert" in step) {
@@ -551,18 +551,23 @@ const FORMATS = {
551
551
  uri: /^[a-z][a-z0-9+.-]*:\/\/\S+$/i,
552
552
  integer: /^-?\d+$/,
553
553
  };
554
- const MATCHERS = ["$type", "$regex", "$contains", "$any", "$gt", "$gte", "$lt", "$lte", "$len", "$not", "$oneOf", "$format"];
554
+ const MATCHERS = ["$type", "$regex", "$contains", "$any", "$gt", "$gte", "$lt", "$lte", "$closeTo", "$len", "$not", "$oneOf", "$format"];
555
555
  /** An asymmetric matcher Vitest's `toEqual` / `toMatchObject` call, with a readable name in diffs. */
556
556
  function matcher(name, test) {
557
557
  return { asymmetricMatch: test, toAsymmetricMatcher: () => name, toString: () => name };
558
558
  }
559
- /** Numbers compare as numbers; strings (ISO dates, versions) compare as strings; anything else never matches. */
559
+ /** A decimal as Postgres `numeric` and MySQL `DECIMAL` columns come back, to keep their precision: `"12.50"`. */
560
+ const DECIMAL = /^-?\d+(\.\d+)?$/;
561
+ /**
562
+ * Numbers compare as numbers, also with decimal strings from `numeric` / `DECIMAL` columns; strings
563
+ * (ISO dates, versions) compare as strings; anything else never matches.
564
+ */
560
565
  function compare(op, arg, holds) {
561
566
  if (typeof arg !== "number" && typeof arg !== "string")
562
567
  throw new Error(`${op} takes a number or a string, got ${JSON.stringify(arg)}`);
563
568
  return matcher(`${op} ${JSON.stringify(arg)}`, (v) => {
564
569
  if (typeof arg === "number")
565
- return typeof v === "number" && holds(v - arg);
570
+ return (typeof v === "number" || (typeof v === "string" && DECIMAL.test(v))) && holds(Number(v) - arg);
566
571
  return typeof v === "string" && holds(v < arg ? -1 : v > arg ? 1 : 0);
567
572
  });
568
573
  }
@@ -604,6 +609,13 @@ function single(key, arg) {
604
609
  return compare(key, arg, (c) => c < 0);
605
610
  case "$lte":
606
611
  return compare(key, arg, (c) => c <= 0);
612
+ case "$closeTo": {
613
+ // `$closeTo: 9.99` allows ±0.005 (two decimals, like toBeCloseTo); `$closeTo: [9.99, 0.1]` sets the tolerance.
614
+ const [target, tolerance = 0.005] = Array.isArray(arg) ? arg : [arg];
615
+ if (typeof target !== "number" || typeof tolerance !== "number" || !(tolerance >= 0))
616
+ throw new Error(`$closeTo takes a number or [number, tolerance], got ${JSON.stringify(arg)}`);
617
+ return matcher(`$closeTo ${JSON.stringify(arg)}`, (v) => (typeof v === "number" || (typeof v === "string" && DECIMAL.test(v))) && Math.abs(Number(v) - target) <= tolerance + Number.EPSILON);
618
+ }
607
619
  case "$len": {
608
620
  const want = toMatchers(arg);
609
621
  return matcher(`$len ${JSON.stringify(arg)}`, (v) => (typeof v === "string" || Array.isArray(v)) && equalsMatcher(want, v.length));
@@ -669,6 +681,35 @@ function verifyResponse(res, e, vars, base) {
669
681
  check(res.json, e.json, vars, "response JSON");
670
682
  if (e?.text !== undefined)
671
683
  check(res.text, e.text, vars, "response text");
684
+ if (e?.events !== undefined) {
685
+ if (!res.events)
686
+ throw new Error(`${res.method} ${res.url}: expected server-sent events, but the response is ${res.headers.get("content-type") ?? "without a content type"}, not text/event-stream`);
687
+ const wanted = toMatchers(interpolate(e.events, vars));
688
+ let at = 0;
689
+ for (const [i, want] of wanted.entries()) {
690
+ const found = res.events.findIndex((ev, j) => j >= at && subsetEquals(want, ev));
691
+ if (found < 0) {
692
+ const shown = res.events.slice(0, 20).map((ev, j) => ` ${j}: ${JSON.stringify(ev).slice(0, 200)}`).join("\n");
693
+ throw new Error(`${res.method} ${res.url}: expected event ${i + 1} of ${wanted.length}, ${JSON.stringify(e.events[i])}, ${at > 0 ? `after event ${at - 1}` : "in the stream"}; ${res.events.length} event(s) arrived:\n${shown || " (none)"}${res.events.length > 20 ? "\n …" : ""}`);
694
+ }
695
+ at = found + 1;
696
+ }
697
+ }
698
+ if (e?.cookies !== undefined) {
699
+ for (const [name, attrs] of Object.entries(e.cookies)) {
700
+ if (attrs === null)
701
+ expect(res).not.toSetCookie(name);
702
+ else
703
+ expect(res).toSetCookie(name, toMatchers(interpolate(attrs, vars)));
704
+ }
705
+ }
706
+ if (e?.duration !== undefined) {
707
+ const want = interpolate(e.duration, vars);
708
+ if (typeof want === "number")
709
+ expect(res).toRespondWithin(want);
710
+ else
711
+ check(res.durationMs, want, vars, `${res.method} ${res.url} response time (${res.durationMs}ms)`);
712
+ }
672
713
  if (e?.schema !== undefined) {
673
714
  const problems = schemaProblems(e.schema, res.json, base);
674
715
  if (problems.length)
package/dist/yaml.d.ts CHANGED
@@ -132,7 +132,10 @@ export interface RequestStep {
132
132
  /** `queries`: at most this many SQL statements (not counting BEGIN/COMMIT), with `db.queries` on. */
133
133
  /** `schema`: a JSON Schema for the response JSON, inline or `file#/pointer` relative to the scenario file. */
134
134
  expect?: {
135
- status?: number;
135
+ events?: Record<string, unknown>[];
136
+ cookies?: Record<string, Record<string, unknown> | null>;
137
+ duration?: number | Record<string, unknown>;
138
+ status?: number | string | (number | string)[];
136
139
  statuses?: Record<string, number>;
137
140
  headers?: Record<string, unknown>;
138
141
  json?: unknown;
@@ -156,7 +159,10 @@ export interface SubmitStep {
156
159
  headers?: Record<string, string>;
157
160
  follow?: boolean;
158
161
  expect?: {
159
- status?: number;
162
+ events?: Record<string, unknown>[];
163
+ cookies?: Record<string, Record<string, unknown> | null>;
164
+ duration?: number | Record<string, unknown>;
165
+ status?: number | string | (number | string)[];
160
166
  headers?: Record<string, unknown>;
161
167
  json?: unknown;
162
168
  text?: unknown;