slicetest 0.9.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 (59) hide show
  1. package/README.md +118 -30
  2. package/dist/app.js +10 -2
  3. package/dist/auth.js +2 -1
  4. package/dist/cli-run.d.ts +40 -0
  5. package/dist/cli-run.js +65 -0
  6. package/dist/cli.js +20 -34
  7. package/dist/config.d.ts +33 -2
  8. package/dist/config.js +82 -8
  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 +1 -0
  24. package/dist/form.js +50 -6
  25. package/dist/har.js +2 -2
  26. package/dist/http.d.ts +9 -3
  27. package/dist/http.js +40 -5
  28. package/dist/init.js +11 -2
  29. package/dist/list.d.ts +1 -0
  30. package/dist/list.js +16 -3
  31. package/dist/mail.d.ts +1 -1
  32. package/dist/mail.js +7 -7
  33. package/dist/matchers.d.ts +4 -3
  34. package/dist/matchers.js +18 -6
  35. package/dist/openapi.d.ts +2 -0
  36. package/dist/openapi.js +133 -14
  37. package/dist/record-cli.js +2 -1
  38. package/dist/record.js +2 -1
  39. package/dist/recording.d.ts +4 -1
  40. package/dist/recording.js +14 -3
  41. package/dist/request-url.d.ts +1 -0
  42. package/dist/request-url.js +5 -0
  43. package/dist/runtime.d.ts +3 -3
  44. package/dist/runtime.js +132 -18
  45. package/dist/scenario.js +11 -2
  46. package/dist/stub.js +17 -6
  47. package/dist/vitest.d.ts +4 -0
  48. package/dist/vitest.js +6 -2
  49. package/dist/webhook.d.ts +3 -3
  50. package/dist/webhook.js +10 -2
  51. package/dist/yaml-runtime.d.ts +4 -4
  52. package/dist/yaml-runtime.js +96 -34
  53. package/dist/yaml.d.ts +4 -0
  54. package/dist/yaml.js +33 -2
  55. package/docs/guarantees.md +92 -0
  56. package/docs/state-isolation.md +94 -0
  57. package/package.json +4 -1
  58. package/schema/config.schema.json +675 -0
  59. package/schema/scenario.schema.json +27 -3
@@ -1,4 +1,5 @@
1
1
  import pg from "pg";
2
+ import { loadContainers } from "../container-runtime.js";
2
3
  const INT8 = 20;
3
4
  /** int8 columns (bigserial ids, count(*)) come back as numbers when they fit, instead of strings. */
4
5
  const types = {
@@ -88,6 +89,24 @@ export class PostgresDriver {
88
89
  : `INSERT INTO ${this.ident(table)} (${cols.map((c) => this.ident(c)).join(", ")}) VALUES (${cols.map((_, i) => `$${i + 1}`).join(", ")}) RETURNING *`;
89
90
  return this.query(sql, Object.values(row));
90
91
  }
92
+ /** Columns backed by a sequence, per table: looked up once, since most inserts don't give their ids. */
93
+ #sequenced = new Map();
94
+ async syncSequences(table, columns) {
95
+ const name = this.ident(table);
96
+ let owned = this.#sequenced.get(name);
97
+ if (!owned) {
98
+ owned = await this.query(`SELECT a.attname AS col, pg_get_serial_sequence($1, a.attname) AS seq
99
+ FROM pg_attribute a
100
+ WHERE a.attrelid = to_regclass($1) AND a.attnum > 0 AND NOT a.attisdropped AND pg_get_serial_sequence($1, a.attname) IS NOT NULL`, [name]);
101
+ this.#sequenced.set(name, owned);
102
+ }
103
+ // An empty table restarts at 1; otherwise the next value follows the largest id.
104
+ for (const { col, seq } of owned) {
105
+ if (columns && !columns.includes(col))
106
+ continue;
107
+ await this.query(`SELECT setval($1, COALESCE(MAX(${this.column(col)}), 1), MAX(${this.column(col)}) IS NOT NULL) FROM ${name}`, [seq]);
108
+ }
109
+ }
91
110
  async describe(table) {
92
111
  const [{ oid } = { oid: null }] = await this.query("SELECT to_regclass($1)::oid AS oid", [this.ident(table)]);
93
112
  if (oid === null)
@@ -168,7 +187,7 @@ export const postgres = {
168
187
  name: "postgres",
169
188
  defaultImage: "postgres:17-alpine",
170
189
  async startContainer(image, reuse) {
171
- const { PostgreSqlContainer } = await import("@testcontainers/postgresql");
190
+ const { PostgreSqlContainer } = await loadContainers(() => import("@testcontainers/postgresql"));
172
191
  const definition = new PostgreSqlContainer(image);
173
192
  // A reused container is left running and found again by its configuration on the next run.
174
193
  if (reuse)
package/dist/exit.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function onProcessExit(hook: () => void): void;
package/dist/exit.js ADDED
@@ -0,0 +1,28 @@
1
+ // One listener per process, not per copy of the package (many Vitest projects ended in Node's "11 exit
2
+ // listeners" warning). Vitest ends a worker with SIGTERM, which skips `exit` and left worker-scope apps running.
3
+ const KEY = Symbol.for("slicetest.exitHooks");
4
+ export function onProcessExit(hook) {
5
+ const g = globalThis;
6
+ if (!g[KEY]) {
7
+ const hooks = (g[KEY] = new Set());
8
+ const run = () => {
9
+ for (const h of hooks) {
10
+ try {
11
+ h();
12
+ }
13
+ catch { }
14
+ }
15
+ hooks.clear();
16
+ };
17
+ process.once("exit", run);
18
+ // Sent again only when nobody else listens: its default action then ends the process as before.
19
+ for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"]) {
20
+ process.once(signal, () => {
21
+ run();
22
+ if (process.listenerCount(signal) === 0)
23
+ process.kill(process.pid, signal);
24
+ });
25
+ }
26
+ }
27
+ g[KEY].add(hook);
28
+ }
package/dist/form.d.ts CHANGED
@@ -33,6 +33,7 @@ interface Control {
33
33
  /** For select: its options. */
34
34
  options: {
35
35
  value: string;
36
+ label: string;
36
37
  selected: boolean;
37
38
  disabled: boolean;
38
39
  }[];
package/dist/form.js CHANGED
@@ -8,7 +8,11 @@
8
8
  const TAG = /<!--[\s\S]*?-->|<(\/?)([a-zA-Z][\w-]*)((?:\s+[^\s=>/]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?)*)\s*\/?>/g;
9
9
  const ATTR = /([^\s=>/]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
10
10
  const RAW_TEXT = new Set(["script", "style", "template"]);
11
- const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", "#39": "'" };
11
+ const ENTITIES = {
12
+ amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", "#39": "'",
13
+ copy: "©", reg: "®", trade: "™", hellip: "…", mdash: "—", ndash: "–", laquo: "«", raquo: "»",
14
+ lsquo: "‘", rsquo: "’", ldquo: "“", rdquo: "”", yen: "¥", euro: "€", middot: "·", times: "×",
15
+ };
12
16
  export function decodeEntities(s) {
13
17
  return s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+\d*);/gi, (m, e) => {
14
18
  if (e[0] === "#") {
@@ -52,8 +56,8 @@ export function parseForms(html) {
52
56
  const closeOption = (end) => {
53
57
  if (!option || !select)
54
58
  return;
55
- const value = option.attrs.value ?? textOf(html.slice(option.start, end));
56
- select.options.push({ value, selected: "selected" in option.attrs, disabled: "disabled" in option.attrs || groupDisabled });
59
+ const label = textOf(html.slice(option.start, end));
60
+ select.options.push({ value: option.attrs.value ?? label, label, selected: "selected" in option.attrs, disabled: "disabled" in option.attrs || groupDisabled });
57
61
  option = undefined;
58
62
  };
59
63
  TAG.lastIndex = 0;
@@ -192,13 +196,15 @@ export function formRequest(html, opts = {}) {
192
196
  if (value instanceof Blob)
193
197
  files.set(name, value);
194
198
  else
195
- typed.set(name, typeof value === "boolean" ? value : (Array.isArray(value) ? value : [value]).map(String));
199
+ typed.set(name, typeof value === "boolean" ? value : offered(form, name, (Array.isArray(value) ? value : [value]).map(String)));
196
200
  }
197
201
  // The successful controls, in document order (HTML's "constructing the entry list").
198
202
  // A typed value replaces what the page had: text for a field, the checked state for
199
203
  // checkboxes and radios (true / false, or the values to check), the options of a select.
200
204
  const entries = [];
201
205
  const done = new Set();
206
+ // A name shared by checkboxes and a hidden field (Rails' check_box sends `0` unless the box is checked): typing checks the boxes.
207
+ const boxed = new Set(form.controls.filter((c) => c.tag === "input" && ["checkbox", "radio"].includes(c.attrs.type?.toLowerCase() ?? "")).map((c) => c.attrs.name));
202
208
  for (const c of form.controls) {
203
209
  const name = c.attrs.name;
204
210
  if (c === button && c.attrs.type?.toLowerCase() === "image") {
@@ -225,7 +231,7 @@ export function formRequest(html, opts = {}) {
225
231
  done.add(name);
226
232
  continue;
227
233
  }
228
- const want = typed.get(name);
234
+ const want = type === "checkbox" || type === "radio" || !boxed.has(name) ? typed.get(name) : undefined;
229
235
  if (type === "checkbox" || type === "radio") {
230
236
  const value = c.attrs.value ?? "on";
231
237
  const checked = want === undefined ? "checked" in c.attrs : typeof want === "boolean" ? want : want.includes(value);
@@ -253,7 +259,10 @@ export function formRequest(html, opts = {}) {
253
259
  entries.push([name, o.value]);
254
260
  }
255
261
  else
256
- entries.push([name, c.attrs.value ?? ""]);
262
+ entries.push([name, type === "hidden" && name === "_charset_" ? "UTF-8" : (c.attrs.value ?? "")]);
263
+ // Text controls with a `dirname` also send their direction; left-to-right is what a test page has.
264
+ if (c.attrs.dirname && (c.tag === "textarea" || ["text", "search", "tel", "url", "email"].includes(type)))
265
+ entries.push([c.attrs.dirname, "ltr"]);
257
266
  }
258
267
  const pick = (attr) => (button && button.attrs[`form${attr}`] !== undefined ? button.attrs[`form${attr}`] : form.attrs[attr]);
259
268
  const method = (pick("method") ?? "get").toUpperCase() === "POST" ? "POST" : "GET";
@@ -263,6 +272,12 @@ export function formRequest(html, opts = {}) {
263
272
  throw new Error(`slicetest: submit: the form sends ${method === "GET" ? "a GET" : "urlencoded"} data, which can't carry files; a browser would send only the file name. Upload forms need method="post" enctype="multipart/form-data"`);
264
273
  }
265
274
  // Outside multipart, a file field is sent as its file name (empty when none was chosen), as browsers do.
275
+ // Browsers send line breaks as CRLF; URLSearchParams would keep a bare "\n".
276
+ for (const e of entries) {
277
+ e[0] = crlf(e[0]);
278
+ if (typeof e[1] === "string")
279
+ e[1] = crlf(e[1]);
280
+ }
266
281
  const plain = () => new URLSearchParams(entries.map(([k, v]) => [k, typeof v === "string" ? v : (v.name ?? "")]));
267
282
  if (method === "GET")
268
283
  return { method, action: `${action.replace(/[?#].*$/, "")}?${plain()}` };
@@ -277,6 +292,35 @@ export function formRequest(html, opts = {}) {
277
292
  }
278
293
  return { method, action, body };
279
294
  }
295
+ const crlf = (s) => s.replace(/\r\n|\r|\n/g, "\r\n");
296
+ /**
297
+ * Typed values for a select, radios or checkboxes, checked against what the page offers: a browser can't pick
298
+ * anything else. A select option may be named by its label too; it is sent as its value.
299
+ */
300
+ function offered(form, name, values) {
301
+ const controls = form.controls.filter((c) => c.attrs.name === name && !("disabled" in c.attrs));
302
+ const select = controls.find((c) => c.tag === "select");
303
+ if (select) {
304
+ const usable = select.options.filter((o) => !o.disabled);
305
+ return values.map((v) => {
306
+ const o = usable.find((x) => x.value === v) ?? usable.find((x) => x.label === v);
307
+ if (o)
308
+ return o.value;
309
+ const off = select.options.filter((x) => x.disabled).map((x) => x.value);
310
+ throw new Error(`slicetest: submit: the select "${name}" has no option ${JSON.stringify(v)} (options: ${usable.map((x) => `${x.value} ${JSON.stringify(x.label)}`).join(", ") || "none"}${off.length ? `; disabled: ${off.join(", ")}` : ""})`);
311
+ });
312
+ }
313
+ for (const type of ["radio", "checkbox"]) {
314
+ const boxes = controls.filter((c) => c.tag === "input" && c.attrs.type?.toLowerCase() === type);
315
+ if (!boxes.length)
316
+ continue;
317
+ const have = boxes.map((c) => c.attrs.value ?? "on");
318
+ const bad = values.find((v) => !have.includes(v));
319
+ if (bad !== undefined)
320
+ throw new Error(`slicetest: submit: no ${type === "radio" ? "radio button" : "checkbox"} "${name}" has the value ${JSON.stringify(bad)} (values: ${have.join(", ")})`);
321
+ }
322
+ return values;
323
+ }
280
324
  /** Fields as `application/x-www-form-urlencoded`: lists repeat the key, nested objects use bracket keys (`metadata[order]`). */
281
325
  export function encodeForm(fields) {
282
326
  const out = new URLSearchParams();
package/dist/har.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { mergeRecordings, redactRequest } from "./recording.js";
3
+ import { mergeRecordings, queryOf, redactRequest } from "./recording.js";
4
4
  const KEPT_HEADERS = ["content-type", "location", "retry-after", "link", "etag"];
5
5
  const TEXTUAL = /json|text|xml|javascript|x-www-form-urlencoded|graphql/i;
6
6
  export function harEntries(har, file = "the HAR file") {
@@ -26,7 +26,7 @@ export function toRecording(entry, upstream) {
26
26
  // Preflights are the browser's; aborted and blocked requests (status 0) have no answer to replay.
27
27
  if (method === "OPTIONS" || !entry.response || !entry.response.status)
28
28
  return "skipped";
29
- const query = Object.fromEntries([...url.searchParams.entries()].sort(([a], [b]) => a.localeCompare(b)));
29
+ const query = queryOf(url.searchParams);
30
30
  const sent = entry.request.postData?.text;
31
31
  const json = parse(sent);
32
32
  const request = redactRequest({
package/dist/http.d.ts CHANGED
@@ -26,8 +26,8 @@ export declare function parseEvents(text: string): ServerEvent[];
26
26
  type QueryValue = string | number | boolean;
27
27
  export interface RequestOptions {
28
28
  headers?: Record<string, string>;
29
- /** A list repeats the parameter: `{ tag: ["a", "b"] }` is `?tag=a&tag=b`. */
30
- query?: Record<string, QueryValue | QueryValue[] | undefined>;
29
+ /** A list repeats the parameter: `{ tag: ["a", "b"] }` is `?tag=a&tag=b`. `null` and `undefined` are left out. */
30
+ query?: Record<string, QueryValue | null | (QueryValue | null)[] | undefined>;
31
31
  /** Follow redirects instead of returning the 3xx response. Default false. */
32
32
  follow?: boolean;
33
33
  /** Fail the request when the app hasn't answered within this many ms, naming it, instead of the whole test timing out. */
@@ -40,7 +40,11 @@ declare class Session {
40
40
  cookies: Map<string, string>;
41
41
  /** The Path each cookie was set for; a cookie without one (added by hand) goes everywhere. */
42
42
  cookiePaths: Map<string, string>;
43
+ /** When a cookie set with Max-Age or Expires stops being sent (epoch ms). */
44
+ cookieExpiry: Map<string, number>;
43
45
  history: HttpResponse[];
46
+ /** Every request of the scenario, for trace() and diagrams; `history` keeps only the last few for failure output. */
47
+ requests: HttpResponse[];
44
48
  listeners: ((res: HttpResponse) => void)[];
45
49
  }
46
50
  /** HTTP client bound to the app under test. Keeps cookies for the duration of a scenario. */
@@ -102,11 +106,13 @@ export declare class HttpClient {
102
106
  onResponse(fn: (res: HttpResponse) => void): void;
103
107
  /** Let redirects to `host` continue to the stub at `stubUrl`, as they would to the real host in a browser. */
104
108
  intercept(host: string, stubUrl: string): void;
105
- /** Cookies the app has set during this scenario. Mutations are sent with later requests. */
109
+ /** Cookies the app has set during this scenario (minus expired ones). Mutations are sent with later requests. */
106
110
  get cookies(): Map<string, string>;
107
111
  clearCookies(): void;
108
112
  /** Requests made during the current scenario, oldest first (last 20). */
109
113
  get history(): readonly HttpResponse[];
114
+ /** Every request made during the current scenario, oldest first. */
115
+ get requests(): readonly HttpResponse[];
110
116
  /** Start a new scenario: forget cookies and history. */
111
117
  reset(): void;
112
118
  }
package/dist/http.js CHANGED
@@ -44,7 +44,11 @@ class Session {
44
44
  cookies = new Map();
45
45
  /** The Path each cookie was set for; a cookie without one (added by hand) goes everywhere. */
46
46
  cookiePaths = new Map();
47
+ /** When a cookie set with Max-Age or Expires stops being sent (epoch ms). */
48
+ cookieExpiry = new Map();
47
49
  history = [];
50
+ /** Every request of the scenario, for trace() and diagrams; `history` keeps only the last few for failure output. */
51
+ requests = [];
48
52
  listeners = [];
49
53
  }
50
54
  const HISTORY = 20;
@@ -158,17 +162,21 @@ export class HttpClient {
158
162
  */
159
163
  async request(method, path, body, options = {}) {
160
164
  const opts = merge(this.defaults, options);
165
+ // Fetch upper-cases get and put but not patch, so `request("patch", …)` went out as "patch".
166
+ method = method.toUpperCase();
161
167
  let url = new URL(path, this.baseUrl);
162
168
  if (url.origin !== new URL(this.baseUrl).origin) {
163
169
  throw new Error(`slicetest: http only talks to the app under test; "${path}" resolves to ${url.origin}`);
164
170
  }
165
171
  for (const [k, v] of Object.entries(opts.query ?? {})) {
166
- if (v === undefined)
172
+ // `null` too: a value captured from JSON (`next_cursor: null`) would otherwise go out as the text "null".
173
+ if (v === undefined || v === null)
167
174
  continue;
168
175
  if (Array.isArray(v)) {
169
176
  url.searchParams.delete(k);
170
177
  for (const item of v)
171
- url.searchParams.append(k, String(item));
178
+ if (item !== null && item !== undefined)
179
+ url.searchParams.append(k, String(item));
172
180
  }
173
181
  else
174
182
  url.searchParams.set(k, String(v));
@@ -182,8 +190,9 @@ export class HttpClient {
182
190
  // Like a browser: on to the app, or to a host a stub stands in for; anywhere else is returned.
183
191
  if (next.origin !== new URL(this.baseUrl).origin && lookupHost(this.#session.external, next.hostname) === undefined)
184
192
  break;
185
- // 307/308 repeat the request as it was; the others turn it into a GET without a body, like browsers do.
186
- if (res.status !== 307 && res.status !== 308 && method !== "HEAD") {
193
+ // Fetch's rules: 303 makes anything but HEAD a GET, 301/302 only a POST; the rest repeat the request as it was.
194
+ const toGet = res.status === 303 ? method !== "HEAD" : (res.status === 301 || res.status === 302) && method === "POST";
195
+ if (toGet) {
187
196
  method = "GET";
188
197
  body = undefined;
189
198
  }
@@ -275,6 +284,7 @@ export class HttpClient {
275
284
  this.#session.listeners.push(fn);
276
285
  }
277
286
  #record(res) {
287
+ this.#session.requests.push(res);
278
288
  this.#session.history.push(res);
279
289
  if (this.#session.history.length > HISTORY)
280
290
  this.#session.history.shift();
@@ -283,25 +293,44 @@ export class HttpClient {
283
293
  intercept(host, stubUrl) {
284
294
  this.#session.external.set(host.toLowerCase(), stubUrl);
285
295
  }
286
- /** Cookies the app has set during this scenario. Mutations are sent with later requests. */
296
+ /** Cookies the app has set during this scenario (minus expired ones). Mutations are sent with later requests. */
287
297
  get cookies() {
298
+ this.#dropExpired();
288
299
  return this.#session.cookies;
289
300
  }
290
301
  clearCookies() {
291
302
  this.#session.cookies.clear();
292
303
  this.#session.cookiePaths.clear();
304
+ this.#session.cookieExpiry.clear();
305
+ }
306
+ #dropExpired() {
307
+ const { cookies, cookiePaths, cookieExpiry } = this.#session;
308
+ const now = Date.now();
309
+ for (const [name, at] of cookieExpiry) {
310
+ if (at > now)
311
+ continue;
312
+ cookies.delete(name);
313
+ cookiePaths.delete(name);
314
+ cookieExpiry.delete(name);
315
+ }
293
316
  }
294
317
  /** Requests made during the current scenario, oldest first (last 20). */
295
318
  get history() {
296
319
  return this.#session.history;
297
320
  }
321
+ /** Every request made during the current scenario, oldest first. */
322
+ get requests() {
323
+ return this.#session.requests;
324
+ }
298
325
  /** Start a new scenario: forget cookies and history. */
299
326
  reset() {
300
327
  this.clearCookies();
301
328
  this.#session.history = [];
329
+ this.#session.requests = [];
302
330
  }
303
331
  /** Cookies whose Path covers `requestPath`, longest Path first as browsers send them (RFC 6265 5.4). */
304
332
  #cookieHeader(requestPath) {
333
+ this.#dropExpired();
305
334
  const { cookies, cookiePaths } = this.#session;
306
335
  return [...cookies]
307
336
  .map(([name, value]) => ({ name, value, path: cookiePaths.get(name) ?? "/" }))
@@ -336,10 +365,16 @@ export class HttpClient {
336
365
  if (expired) {
337
366
  this.#session.cookies.delete(name);
338
367
  this.#session.cookiePaths.delete(name);
368
+ this.#session.cookieExpiry.delete(name);
339
369
  }
340
370
  else {
341
371
  this.#session.cookies.set(name, pair.slice(eq + 1).trim());
342
372
  this.#session.cookiePaths.set(name, cookiePath ?? defaultCookiePath(requestPath));
373
+ const until = maxAge !== undefined && !Number.isNaN(maxAge) ? Date.now() + maxAge * 1000 : expires !== undefined && !Number.isNaN(expires) ? expires : undefined;
374
+ if (until === undefined)
375
+ this.#session.cookieExpiry.delete(name);
376
+ else
377
+ this.#session.cookieExpiry.set(name, until);
343
378
  }
344
379
  }
345
380
  }
package/dist/init.js CHANGED
@@ -114,11 +114,19 @@ export async function detect(root) {
114
114
  const appRoot = path.join(root, appDir);
115
115
  const has = (p) => existsSync(path.join(appRoot, p));
116
116
  const read = async (p) => (has(p) ? readFile(path.join(appRoot, p), "utf8") : "");
117
- 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;
118
126
  const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
119
127
  const python = `${await read("requirements.txt")}\n${await read("pyproject.toml")}`.toLowerCase();
120
128
  const gemfile = await read("Gemfile");
121
- const composer = has("composer.json") ? JSON.parse(await read("composer.json")) : undefined;
129
+ const composer = has("composer.json") ? (await readJson("composer.json")) : undefined;
122
130
  const php = { ...composer?.require, ...composer?.["require-dev"] };
123
131
  const laravel = !!php["laravel/framework"] && has("artisan");
124
132
  const symfony = !laravel && !!php["symfony/framework-bundle"] && has("bin/console");
@@ -609,6 +617,7 @@ export async function init(root, { force = false } = {}) {
609
617
  }
610
618
  const { config, notes } = await detect(root);
611
619
  const header = [
620
+ "# yaml-language-server: $schema=https://unpkg.com/slicetest/schema/config.schema.json",
612
621
  "# slicetest config, generated by `npx slicetest init`. Paths are relative to this file.",
613
622
  "# Everything the Vitest plugin accepts works here: https://github.com/revo1290/slicetest#configuration-reference",
614
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) {
@@ -32,8 +32,8 @@ interface SlicetestMatchers<R = unknown> {
32
32
  toSetCookie(name: string, attributes?: Record<string, unknown>): R;
33
33
  /** The response arrived within `ms` milliseconds (measured from sending the request to reading the whole body). */
34
34
  toRespondWithin(ms: number): R;
35
- /** An array of responses has exactly these status counts, e.g. `{ 201: 1, 409: 9 }`. */
36
- toHaveStatuses(counts: Record<number, 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;
37
37
  /** Async: the table has at least one row matching `where` (`count` for an exact number). */
38
38
  toHaveRow(table: string, where?: Where, count?: number): Promise<void>;
39
39
  }
@@ -44,5 +44,6 @@ declare module "vitest" {
44
44
  /** A status code, a class (`"2xx"`), or a list of either: `[200, 204]`, `["2xx", 304]`. */
45
45
  export type ExpectedStatus = number | string | (number | string)[];
46
46
  export declare function statusMatches(expected: ExpectedStatus, actual: number): boolean;
47
- export declare function statusCounts(responses: HttpResponse[]): Record<number, number>;
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>;
48
49
  export {};
package/dist/matchers.js CHANGED
@@ -41,14 +41,20 @@ export function statusMatches(expected, actual) {
41
41
  function describeStatus(expected) {
42
42
  return Array.isArray(expected) ? `one of ${expected.join(", ")}` : String(expected);
43
43
  }
44
- export function statusCounts(responses) {
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 = []) {
45
46
  const out = {};
46
- for (const r of responses)
47
- 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
+ }
48
52
  return out;
49
53
  }
50
54
  function fmtCounts(c) {
51
- 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(", ")} }`;
52
58
  }
53
59
  /** One example response per status, which is usually what explains the odd one out. */
54
60
  function describeResponses(responses) {
@@ -210,8 +216,14 @@ expect.extend({
210
216
  toHaveStatuses(received, counts) {
211
217
  if (!Array.isArray(received))
212
218
  throw new TypeError("slicetest: toHaveStatuses expects an array of responses, e.g. from http.concurrently()");
213
- const actual = statusCounts(received);
214
- 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]));
215
227
  const pass = this.equals(actual, expected);
216
228
  return {
217
229
  pass,
package/dist/openapi.d.ts CHANGED
@@ -30,6 +30,8 @@ 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
36
  export declare class OpenApiSpec {
35
37
  #private;