slicetest 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +131 -35
  2. package/dist/app.js +10 -2
  3. package/dist/auth.js +2 -1
  4. package/dist/cli-run.d.ts +53 -0
  5. package/dist/cli-run.js +80 -0
  6. package/dist/cli.js +27 -29
  7. package/dist/config.d.ts +33 -2
  8. package/dist/config.js +121 -2
  9. package/dist/container-runtime.d.ts +4 -0
  10. package/dist/container-runtime.js +16 -0
  11. package/dist/containers.js +4 -3
  12. package/dist/db.d.ts +3 -1
  13. package/dist/db.js +45 -8
  14. package/dist/deadline.d.ts +7 -0
  15. package/dist/deadline.js +24 -0
  16. package/dist/doctor.js +27 -5
  17. package/dist/drivers/driver.d.ts +6 -0
  18. package/dist/drivers/mysql.js +5 -1
  19. package/dist/drivers/postgres.d.ts +2 -0
  20. package/dist/drivers/postgres.js +20 -1
  21. package/dist/exit.d.ts +1 -0
  22. package/dist/exit.js +28 -0
  23. package/dist/form.d.ts +2 -0
  24. package/dist/form.js +62 -9
  25. package/dist/global-setup.js +3 -6
  26. package/dist/har.js +4 -4
  27. package/dist/http.d.ts +38 -4
  28. package/dist/http.js +135 -10
  29. package/dist/init.js +64 -10
  30. package/dist/list.d.ts +1 -0
  31. package/dist/list.js +16 -3
  32. package/dist/mail.d.ts +1 -1
  33. package/dist/mail.js +7 -7
  34. package/dist/matchers.d.ts +18 -6
  35. package/dist/matchers.js +75 -8
  36. package/dist/openapi.d.ts +2 -5
  37. package/dist/openapi.js +146 -20
  38. package/dist/record-cli.js +2 -1
  39. package/dist/record.js +2 -1
  40. package/dist/recording.d.ts +11 -1
  41. package/dist/recording.js +51 -6
  42. package/dist/request-url.d.ts +1 -0
  43. package/dist/request-url.js +5 -0
  44. package/dist/runtime.d.ts +3 -3
  45. package/dist/runtime.js +144 -18
  46. package/dist/scenario.js +11 -2
  47. package/dist/sql-migrations.d.ts +11 -0
  48. package/dist/sql-migrations.js +38 -0
  49. package/dist/stub.d.ts +10 -3
  50. package/dist/stub.js +64 -16
  51. package/dist/vitest.d.ts +4 -0
  52. package/dist/vitest.js +6 -2
  53. package/dist/webhook.d.ts +3 -3
  54. package/dist/webhook.js +10 -2
  55. package/dist/yaml-runtime.d.ts +4 -4
  56. package/dist/yaml-runtime.js +141 -38
  57. package/dist/yaml.d.ts +12 -2
  58. package/dist/yaml.js +47 -4
  59. package/docs/guarantees.md +92 -0
  60. package/docs/state-isolation.md +94 -0
  61. package/package.json +4 -1
  62. package/schema/config.schema.json +675 -0
  63. package/schema/scenario.schema.json +97 -7
package/dist/init.js CHANGED
@@ -74,6 +74,37 @@ function runnable(dir) {
74
74
  return false;
75
75
  }
76
76
  }
77
+ /** Where plain SQL migrations usually live (golang-migrate, goose, sqlx, Diesel, dbmate). */
78
+ const SQL_MIGRATION_DIRS = ["migrations", "db/migrations", "sql/migrations", "database/migrations"];
79
+ /** `.sql` files, or Diesel's directories with an `up.sql`. */
80
+ function hasSqlMigrations(dir) {
81
+ if (!existsSync(dir))
82
+ return false;
83
+ return readdirSync(dir, { withFileTypes: true }).some((e) => (e.isFile() && e.name.endsWith(".sql")) || (e.isDirectory() && existsSync(path.join(dir, e.name, "up.sql"))));
84
+ }
85
+ /**
86
+ * The Go package to run: `.` when the root is package main, else the one command in `cmd/*`
87
+ * (or the one named server, api, app, web or like the module). Undefined when there is none.
88
+ */
89
+ function goMain(root, goMod) {
90
+ const isMain = (dir) => {
91
+ try {
92
+ return readdirSync(dir).some((f) => f.endsWith(".go") && !f.endsWith("_test.go") && /^\s*package\s+main\b/m.test(readFileSync(path.join(dir, f), "utf8")));
93
+ }
94
+ catch {
95
+ return false;
96
+ }
97
+ };
98
+ if (isMain(root))
99
+ return ".";
100
+ const cmd = path.join(root, "cmd");
101
+ if (!existsSync(cmd))
102
+ return undefined;
103
+ const mains = readdirSync(cmd, { withFileTypes: true }).filter((e) => e.isDirectory() && isMain(path.join(cmd, e.name))).map((e) => e.name).sort();
104
+ const module = /^module\s+(\S+)/m.exec(goMod)?.[1]?.split("/").pop();
105
+ const pick = mains.length === 1 ? mains[0] : ["server", "api", "app", "web", "http", module].find((n) => n && mains.includes(n));
106
+ return pick ? `./cmd/${pick}` : undefined;
107
+ }
77
108
  export async function detect(root) {
78
109
  const notes = [];
79
110
  // The app may live in a subdirectory (backend/, server/, …); everything about the app is read there.
@@ -83,11 +114,19 @@ export async function detect(root) {
83
114
  const appRoot = path.join(root, appDir);
84
115
  const has = (p) => existsSync(path.join(appRoot, p));
85
116
  const read = async (p) => (has(p) ? readFile(path.join(appRoot, p), "utf8") : "");
86
- const pkg = has("package.json") ? JSON.parse(await read("package.json")) : undefined;
117
+ const readJson = async (name) => {
118
+ try {
119
+ return JSON.parse(await read(name));
120
+ }
121
+ catch (e) {
122
+ throw new Error(`slicetest init: ${name} isn't valid JSON (${e.message}); fix it, or run init from the folder of the app`);
123
+ }
124
+ };
125
+ const pkg = has("package.json") ? (await readJson("package.json")) : undefined;
87
126
  const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
88
127
  const python = `${await read("requirements.txt")}\n${await read("pyproject.toml")}`.toLowerCase();
89
128
  const gemfile = await read("Gemfile");
90
- const composer = has("composer.json") ? JSON.parse(await read("composer.json")) : undefined;
129
+ const composer = has("composer.json") ? (await readJson("composer.json")) : undefined;
91
130
  const php = { ...composer?.require, ...composer?.["require-dev"] };
92
131
  const laravel = !!php["laravel/framework"] && has("artisan");
93
132
  const symfony = !laravel && !!php["symfony/framework-bundle"] && has("bin/console");
@@ -238,19 +277,33 @@ export async function detect(root) {
238
277
  }
239
278
  }
240
279
  else if (has("go.mod")) {
241
- command = "go run .";
242
- notes.push("app: Go (go.mod). It must listen on $PORT.");
280
+ const main = goMain(appRoot, await read("go.mod"));
281
+ // Built once up front, so the workers' `go run` find everything compiled instead of compiling at once.
282
+ build = "go build ./...";
283
+ command = `go run ${main ?? "."}`;
284
+ readyTimeout = 60_000;
285
+ notes.push(main && main !== "."
286
+ ? `app: Go (go.mod), the main package in ${main.slice(2)}/. It must listen on $PORT.`
287
+ : main
288
+ ? "app: Go (go.mod). It must listen on $PORT."
289
+ : "app: Go (go.mod), but no main package at the root or in cmd/*/: set the package in app.command (`go run ./cmd/server`). It must listen on $PORT.");
243
290
  }
244
291
  else if (has("Cargo.toml")) {
245
- command = "cargo run";
246
- notes.push("app: Rust (Cargo.toml). It must listen on $PORT.");
292
+ const cargo = await read("Cargo.toml");
293
+ // Compiled once before the workers start; `cargo run` then only checks that nothing changed.
294
+ build = "cargo build -q";
295
+ command = "cargo run -q";
296
+ readyTimeout = 120_000;
297
+ notes.push(/^\[workspace\]/m.test(cargo) && !/^\[package\]/m.test(cargo)
298
+ ? "app: Rust workspace (Cargo.toml): add `-p <crate>` to app.command for the server's crate. It must listen on $PORT."
299
+ : "app: Rust (Cargo.toml), built once with `cargo build`. It must listen on $PORT.");
247
300
  }
248
301
  else {
249
302
  notes.push("app: couldn't tell how to start the app. Set app.command.");
250
303
  }
251
304
  // --- migrations ---
252
305
  let migrate;
253
- const migrationsSql = has("migrations") && (await readdir(path.join(appRoot, "migrations"))).some((f) => f.endsWith(".sql"));
306
+ const migrationsDir = SQL_MIGRATION_DIRS.find((d) => hasSqlMigrations(path.join(appRoot, d)));
254
307
  const atlas = await findAtlas(root, appDir);
255
308
  if (atlas) {
256
309
  migrate = { atlas: { dir: `file://${atlas}` } };
@@ -304,9 +357,9 @@ export async function detect(root) {
304
357
  migrate = { command: "npx knex migrate:latest", inputs: ["migrations"] };
305
358
  notes.push("db: Knex migrations");
306
359
  }
307
- else if (migrationsSql) {
308
- migrate = { sql: "migrations" };
309
- notes.push("db: plain SQL files in migrations/, applied in name order");
360
+ else if (migrationsDir) {
361
+ migrate = { sql: migrationsDir };
362
+ notes.push(`db: SQL migrations in ${migrationsDir}/, applied in version order (down migrations left out)`);
310
363
  }
311
364
  else if (has("schema.sql")) {
312
365
  migrate = { sql: "schema.sql" };
@@ -564,6 +617,7 @@ export async function init(root, { force = false } = {}) {
564
617
  }
565
618
  const { config, notes } = await detect(root);
566
619
  const header = [
620
+ "# yaml-language-server: $schema=https://unpkg.com/slicetest/schema/config.schema.json",
567
621
  "# slicetest config, generated by `npx slicetest init`. Paths are relative to this file.",
568
622
  "# Everything the Vitest plugin accepts works here: https://github.com/revo1290/slicetest#configuration-reference",
569
623
  "#",
package/dist/list.d.ts CHANGED
@@ -18,6 +18,7 @@ export declare function listScenarios(root: string, opts?: {
18
18
  filters?: string[];
19
19
  name?: string;
20
20
  tags?: string;
21
+ include?: string[];
21
22
  }): Promise<{
22
23
  scenarios: ListedScenario[];
23
24
  errors: string[];
package/dist/list.js CHANGED
@@ -1,7 +1,13 @@
1
1
  import { readdir, readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { tagsSelected } from "./scenario.js";
4
- import { parseScenarioFile } from "./yaml.js";
4
+ import { parseScenarioFile, scenarioTitle } from "./yaml.js";
5
+ function matchesGlob(file, glob) {
6
+ const match = path.matchesGlob;
7
+ if (!match)
8
+ throw new Error("slicetest list: the config's include needs Node.js 20.17 or later (path.matchesGlob)");
9
+ return match(file, glob.replace(/^\.\//, ""));
10
+ }
5
11
  const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", "target", "vendor", ".venv", "venv"]);
6
12
  async function scenarioFiles(dir, out = []) {
7
13
  for (const entry of await readdir(dir, { withFileTypes: true })) {
@@ -25,20 +31,27 @@ export async function listScenarios(root, opts = {}) {
25
31
  const pattern = opts.name ? new RegExp(opts.name) : undefined;
26
32
  for (const file of await scenarioFiles(root)) {
27
33
  const rel = path.relative(root, file).split(path.sep).join("/");
34
+ if (opts.include && !opts.include.some((glob) => matchesGlob(rel, glob)))
35
+ continue;
28
36
  if (opts.filters?.length && !opts.filters.some((f) => rel.includes(f)))
29
37
  continue;
30
38
  try {
31
39
  const doc = parseScenarioFile(await readFile(file, "utf8"), rel);
40
+ // Vitest's `.only` runs only the focused tests of that file; one the tag filter leaves out is registered as skipped.
41
+ const focused = doc.scenarios.some((sc) => sc.only && !sc.skip && tagsSelected(sc.tags ?? [], opts.tags));
32
42
  for (const sc of doc.scenarios) {
33
43
  const tags = sc.tags ?? [];
34
- const selected = tagsSelected(tags, opts.tags) && (!pattern || pattern.test(sc.name));
44
+ // `-t` is matched against the title each row runs under (`vote {{c}}` → `vote a`).
45
+ const titles = sc.each ? sc.each.map((row, i) => scenarioTitle(sc.name, row, i)) : [sc.name];
46
+ const rows = pattern ? titles.filter((t) => pattern.test(t)).length : titles.length;
47
+ const selected = tagsSelected(tags, opts.tags) && rows > 0 && (!focused || !!sc.only);
35
48
  scenarios.push({
36
49
  file: rel,
37
50
  line: sc.line,
38
51
  name: sc.name,
39
52
  tags,
40
53
  status: sc.skip ? "skip" : !selected ? "filtered" : sc.only ? "only" : "run",
41
- rows: sc.each?.length ?? 1,
54
+ rows: selected ? rows : titles.length,
42
55
  steps: doc.setup.length + sc.steps.length,
43
56
  });
44
57
  }
package/dist/mail.d.ts CHANGED
@@ -47,7 +47,7 @@ export declare class Mailbox {
47
47
  * The first message matching `filter`, waiting up to `within` ms for the app
48
48
  * to send it. Fails with the messages that did arrive.
49
49
  */
50
- waitFor(filter?: MailFilter, { within }?: {
50
+ waitFor(filter?: MailFilter, { within: wanted }?: {
51
51
  within?: number | undefined;
52
52
  }): Promise<Mail>;
53
53
  /** One line per message received this scenario, for failure output. */
package/dist/mail.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { waitBudget } from "./deadline.js";
2
+ import { decodeEntities } from "./form.js";
1
3
  import net from "node:net";
2
4
  export class Mailbox {
3
5
  server;
@@ -37,7 +39,8 @@ export class Mailbox {
37
39
  * The first message matching `filter`, waiting up to `within` ms for the app
38
40
  * to send it. Fails with the messages that did arrive.
39
41
  */
40
- async waitFor(filter = {}, { within = 5000 } = {}) {
42
+ async waitFor(filter = {}, { within: wanted = 5000 } = {}) {
43
+ const { ms: within, note } = waitBudget(wanted);
41
44
  const deadline = Date.now() + within;
42
45
  for (;;) {
43
46
  const found = this.messages(filter)[0];
@@ -45,7 +48,7 @@ export class Mailbox {
45
48
  return found;
46
49
  const left = deadline - Date.now();
47
50
  if (left <= 0)
48
- throw new Error(`slicetest: no mail matching ${describeFilter(filter)} within ${within}ms\n${this.describe()}`);
51
+ throw new Error(`slicetest: no mail matching ${describeFilter(filter)} within ${within}ms${note}\n${this.describe()}`);
49
52
  await new Promise((resolve) => {
50
53
  const done = () => {
51
54
  clearTimeout(timer);
@@ -283,11 +286,8 @@ function htmlToText(html) {
283
286
  .replace(/<(script|style)[\s\S]*?<\/\1>/gi, "")
284
287
  .replace(/<br\s*\/?>|<\/p>/gi, "\n")
285
288
  .replace(/<[^>]+>/g, "")
286
- .replace(/&amp;/g, "&")
287
- .replace(/&lt;/g, "<")
288
- .replace(/&gt;/g, ">")
289
- .replace(/&quot;/g, '"')
290
- .replace(/&#39;/g, "'")
289
+ // One pass: replacing `&amp;` first turned the text `&lt;` (sent as `&amp;lt;`) into `<`.
290
+ .replace(/&[^;\s]+;/g, (e) => decodeEntities(e))
291
291
  .trim();
292
292
  }
293
293
  function links(s) {
@@ -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,10 +22,18 @@ 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;
27
- /** An array of responses has exactly these status counts, e.g. `{ 201: 1, 409: 9 }`. */
28
- toHaveStatuses(counts: Record<number, 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;
35
+ /** An array of responses has exactly these status counts, e.g. `{ 201: 1, 409: 9 }`; classes count the rest: `{ 201: 1, "4xx": 9 }`. */
36
+ toHaveStatuses(counts: Partial<Record<number | `${1 | 2 | 3 | 4 | 5}xx`, number>>): R;
29
37
  /** Async: the table has at least one row matching `where` (`count` for an exact number). */
30
38
  toHaveRow(table: string, where?: Where, count?: number): Promise<void>;
31
39
  }
@@ -33,5 +41,9 @@ declare module "vitest" {
33
41
  interface Matchers<R extends void | Promise<void> = void | Promise<void>, T = unknown> extends SlicetestMatchers<R> {
34
42
  }
35
43
  }
36
- export declare function statusCounts(responses: HttpResponse[]): Record<number, number>;
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;
47
+ /** Responses counted under their code when `keys` lists it, else under their class (`4xx`) when that is listed. */
48
+ export declare function statusCounts(responses: HttpResponse[], keys?: string[]): Record<string, number>;
37
49
  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,14 +23,38 @@ 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
  }
25
- export function statusCounts(responses) {
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
+ }
44
+ /** Responses counted under their code when `keys` lists it, else under their class (`4xx`) when that is listed. */
45
+ export function statusCounts(responses, keys = []) {
26
46
  const out = {};
27
- for (const r of responses)
28
- out[r.status] = (out[r.status] ?? 0) + 1;
47
+ for (const r of responses) {
48
+ const code = String(r.status);
49
+ const key = keys.includes(code) ? code : keys.includes(`${code[0]}xx`) ? `${code[0]}xx` : code;
50
+ out[key] = (out[key] ?? 0) + 1;
51
+ }
29
52
  return out;
30
53
  }
31
54
  function fmtCounts(c) {
32
- return `{ ${Object.entries(c).map(([k, n]) => `${k}: ${n}`).join(", ")} }`;
55
+ // In status order, a class before its codes; object order would put every code before every class.
56
+ const rank = (k) => (/xx$/.test(k) ? Number(k[0]) * 100 - 0.5 : Number(k));
57
+ return `{ ${Object.entries(c).sort(([a], [b]) => rank(a) - rank(b)).map(([k, n]) => `${k}: ${n}`).join(", ")} }`;
33
58
  }
34
59
  /** One example response per status, which is usually what explains the odd one out. */
35
60
  function describeResponses(responses) {
@@ -140,23 +165,65 @@ expect.extend({
140
165
  };
141
166
  },
142
167
  toHaveStatus(received, status) {
143
- const pass = received?.status === status;
168
+ if (!received || typeof received !== "object" || typeof received.status !== "number") {
169
+ throw new TypeError(`slicetest: toHaveStatus expects a response from http, got ${this.utils.stringify(received)}`);
170
+ }
171
+ const pass = statusMatches(status, received.status);
144
172
  return {
145
173
  pass,
146
174
  message: () => {
147
175
  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` +
176
+ return (`expected ${received.method} ${received.url} ${this.isNot ? "not " : ""}to respond ${describeStatus(status)}, got ${received.status}\n` +
149
177
  `Response body:\n ${body || "(empty)"}`);
150
178
  },
151
179
  actual: received?.status,
152
180
  expected: status,
153
181
  };
154
182
  },
183
+ toSetCookie(received, name, attributes = {}) {
184
+ if (!received || typeof received !== "object" || !(received.headers instanceof Headers)) {
185
+ throw new TypeError(`slicetest: toSetCookie expects a response from http, got ${this.utils.stringify(received)}`);
186
+ }
187
+ const cookies = setCookies(received);
188
+ const cookie = Object.hasOwn(cookies, name) ? cookies[name] : undefined;
189
+ // SameSite values are case-insensitive: "Lax" and "lax" are the same attribute. A matcher sees the value as sent.
190
+ const plain = typeof attributes.sameSite === "string";
191
+ const want = plain ? { ...attributes, sameSite: attributes.sameSite.toLowerCase() } : attributes;
192
+ const pass = !!cookie && subset(want, plain ? { ...cookie, sameSite: cookie.sameSite?.toLowerCase() } : cookie);
193
+ 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)))}`;
194
+ return {
195
+ pass,
196
+ message: () => {
197
+ 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 })}` : ""}`;
198
+ const all = Object.values(cookies);
199
+ return `${head}\n${all.length ? `Cookies it set:\n${all.map((c) => ` ${describe(c)}`).join("\n")}` : "It set no cookies."}`;
200
+ },
201
+ };
202
+ },
203
+ toRespondWithin(received, ms) {
204
+ if (!received || typeof received !== "object" || typeof received.durationMs !== "number") {
205
+ throw new TypeError(`slicetest: toRespondWithin expects a response from http, got ${this.utils.stringify(received)}`);
206
+ }
207
+ if (!(typeof ms === "number" && ms > 0))
208
+ throw new TypeError(`slicetest: toRespondWithin needs a positive number of milliseconds, got ${this.utils.stringify(ms)}`);
209
+ return {
210
+ pass: received.durationMs <= ms,
211
+ message: () => `expected ${received.method} ${received.url} ${this.isNot ? "not " : ""}to respond within ${ms}ms, it took ${received.durationMs}ms`,
212
+ actual: received.durationMs,
213
+ expected: ms,
214
+ };
215
+ },
155
216
  toHaveStatuses(received, counts) {
156
217
  if (!Array.isArray(received))
157
218
  throw new TypeError("slicetest: toHaveStatuses expects an array of responses, e.g. from http.concurrently()");
158
- const actual = statusCounts(received);
159
- const expected = Object.fromEntries(Object.entries(counts).filter(([, n]) => n > 0).map(([k, n]) => [Number(k), n]));
219
+ const keys = Object.keys(counts).map((k) => {
220
+ const key = k.trim().toLowerCase();
221
+ if (!/^\d{3}$|^[1-5]xx$/.test(key))
222
+ throw new TypeError(`slicetest: a status is a code (201) or a class ("2xx"), got ${JSON.stringify(k)}`);
223
+ return key;
224
+ });
225
+ const actual = statusCounts(received, keys);
226
+ const expected = Object.fromEntries(Object.entries(counts).filter((entry) => (entry[1] ?? 0) > 0).map(([k, n]) => [k.trim().toLowerCase(), n]));
160
227
  const pass = this.equals(actual, expected);
161
228
  return {
162
229
  pass,
package/dist/openapi.d.ts CHANGED
@@ -30,12 +30,9 @@ export interface Message {
30
30
  /** Parsed JSON when the body is JSON, else the raw text. */
31
31
  body: unknown;
32
32
  query?: URLSearchParams;
33
+ /** Header names in lower case, as Node delivers them; undefined skips the check for required header parameters. */
34
+ headers?: Record<string, string | string[] | undefined>;
33
35
  }
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
36
  export declare class OpenApiSpec {
40
37
  #private;
41
38
  readonly file: string;