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
@@ -1,5 +1,6 @@
1
1
  import type { Driver, Engine, Row, Table, TableShape } from "./driver.js";
2
2
  export declare class PostgresDriver implements Driver {
3
+ #private;
3
4
  private readonly client;
4
5
  private constructor();
5
6
  static connect(url: string): Promise<PostgresDriver>;
@@ -14,6 +15,7 @@ export declare class PostgresDriver implements Driver {
14
15
  listTables(schemas: string[], keep: string[]): Promise<Table[]>;
15
16
  truncate(tables: Table[]): Promise<void>;
16
17
  insert(table: string, row: Row): Promise<Row[]>;
18
+ syncSequences(table: string, columns?: string[]): Promise<void>;
17
19
  describe(table: string): Promise<TableShape>;
18
20
  close(): Promise<void>;
19
21
  }
@@ -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,7 +33,9 @@ interface Control {
33
33
  /** For select: its options. */
34
34
  options: {
35
35
  value: string;
36
+ label: string;
36
37
  selected: boolean;
38
+ disabled: boolean;
37
39
  }[];
38
40
  }
39
41
  interface ParsedForm {
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] === "#") {
@@ -37,6 +41,7 @@ export function parseForms(html) {
37
41
  let open;
38
42
  let select;
39
43
  let option;
44
+ let groupDisabled = false;
40
45
  // Controls inside a disabled <fieldset> are disabled too (and not submitted).
41
46
  const fieldsets = [];
42
47
  const add = (control) => {
@@ -51,8 +56,8 @@ export function parseForms(html) {
51
56
  const closeOption = (end) => {
52
57
  if (!option || !select)
53
58
  return;
54
- const value = option.attrs.value ?? textOf(html.slice(option.start, end));
55
- select.options.push({ value, selected: "selected" in option.attrs });
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 });
56
61
  option = undefined;
57
62
  };
58
63
  TAG.lastIndex = 0;
@@ -107,6 +112,7 @@ export function parseForms(html) {
107
112
  if (closing) {
108
113
  closeOption(m.index);
109
114
  select = undefined;
115
+ groupDisabled = false;
110
116
  }
111
117
  else {
112
118
  select = { tag, attrs: parseAttrs(m[3] ?? ""), text: "", options: [] };
@@ -116,6 +122,8 @@ export function parseForms(html) {
116
122
  }
117
123
  if (tag === "option" || tag === "optgroup") {
118
124
  closeOption(m.index);
125
+ if (tag === "optgroup")
126
+ groupDisabled = !closing && "disabled" in parseAttrs(m[3] ?? "");
119
127
  if (tag === "option" && !closing && select)
120
128
  option = { attrs: parseAttrs(m[3] ?? ""), start: TAG.lastIndex };
121
129
  continue;
@@ -188,13 +196,15 @@ export function formRequest(html, opts = {}) {
188
196
  if (value instanceof Blob)
189
197
  files.set(name, value);
190
198
  else
191
- 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)));
192
200
  }
193
201
  // The successful controls, in document order (HTML's "constructing the entry list").
194
202
  // A typed value replaces what the page had: text for a field, the checked state for
195
203
  // checkboxes and radios (true / false, or the values to check), the options of a select.
196
204
  const entries = [];
197
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));
198
208
  for (const c of form.controls) {
199
209
  const name = c.attrs.name;
200
210
  if (c === button && c.attrs.type?.toLowerCase() === "image") {
@@ -221,7 +231,7 @@ export function formRequest(html, opts = {}) {
221
231
  done.add(name);
222
232
  continue;
223
233
  }
224
- const want = typed.get(name);
234
+ const want = type === "checkbox" || type === "radio" || !boxed.has(name) ? typed.get(name) : undefined;
225
235
  if (type === "checkbox" || type === "radio") {
226
236
  const value = c.attrs.value ?? "on";
227
237
  const checked = want === undefined ? "checked" in c.attrs : typeof want === "boolean" ? want : want.includes(value);
@@ -238,13 +248,21 @@ export function formRequest(html, opts = {}) {
238
248
  else if (c.tag === "textarea")
239
249
  entries.push([name, c.text]);
240
250
  else if (c.tag === "select") {
241
- const selected = c.options.filter((o) => o.selected);
242
- const chosen = selected.length ? selected : "multiple" in c.attrs || c.options.length === 0 ? [] : [c.options[0]];
251
+ // With nothing selected, a single select shows its first option that isn't disabled; disabled options are never sent.
252
+ // A single select keeps only the last option marked selected.
253
+ const marked = c.options.filter((o) => o.selected);
254
+ const selected = "multiple" in c.attrs ? marked : marked.slice(-1);
255
+ const first = c.options.find((o) => !o.disabled);
256
+ const chosen = selected.length ? selected : "multiple" in c.attrs || !first ? [] : [first];
243
257
  for (const o of chosen)
244
- entries.push([name, o.value]);
258
+ if (!o.disabled)
259
+ entries.push([name, o.value]);
245
260
  }
246
261
  else
247
- 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"]);
248
266
  }
249
267
  const pick = (attr) => (button && button.attrs[`form${attr}`] !== undefined ? button.attrs[`form${attr}`] : form.attrs[attr]);
250
268
  const method = (pick("method") ?? "get").toUpperCase() === "POST" ? "POST" : "GET";
@@ -254,6 +272,12 @@ export function formRequest(html, opts = {}) {
254
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"`);
255
273
  }
256
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
+ }
257
281
  const plain = () => new URLSearchParams(entries.map(([k, v]) => [k, typeof v === "string" ? v : (v.name ?? "")]));
258
282
  if (method === "GET")
259
283
  return { method, action: `${action.replace(/[?#].*$/, "")}?${plain()}` };
@@ -268,6 +292,35 @@ export function formRequest(html, opts = {}) {
268
292
  }
269
293
  return { method, action, body };
270
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
+ }
271
324
  /** Fields as `application/x-www-form-urlencoded`: lists repeat the key, nested objects use bracket keys (`metadata[order]`). */
272
325
  export function encodeForm(fields) {
273
326
  const out = new URLSearchParams();
@@ -12,6 +12,7 @@ import { engineFor } from "./drivers/index.js";
12
12
  import { coverageCacheFile } from "./gen.js";
13
13
  import { appSpecFile, formatCoverage, formatUsage, OpenApiSpec } from "./openapi.js";
14
14
  import { mergeRecordings } from "./recording.js";
15
+ import { applySqlFiles, sqlMigrationFiles } from "./sql-migrations.js";
15
16
  import "./provided.js";
16
17
  const exec = promisify(execFile);
17
18
  /** Runs once per vitest run: start the database server, migrate a template database, hand its location to the workers. */
@@ -314,14 +315,10 @@ async function applyMigrations(opts, engine, url, m) {
314
315
  return run("atlas", ["migrate", "apply", "--url", engine.atlasUrl(url), "--dir", dir], opts.root);
315
316
  }
316
317
  else if ("sql" in m) {
317
- const target = path.resolve(opts.root, m.sql);
318
- const files = (await stat(target)).isDirectory()
319
- ? (await readdir(target)).filter((f) => f.endsWith(".sql")).sort().map((f) => path.join(target, f))
320
- : [target];
318
+ const files = await sqlMigrationFiles(path.resolve(opts.root, m.sql));
321
319
  const driver = await engine.driver(url);
322
320
  try {
323
- for (const file of files)
324
- await driver.exec(await readFile(file, "utf8"));
321
+ await applySqlFiles(files, (sql) => driver.exec(sql), opts.root);
325
322
  }
326
323
  finally {
327
324
  await driver.close();
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 } 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,15 +26,15 @@ 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
- const request = {
32
+ const request = redactRequest({
33
33
  method,
34
34
  path: url.pathname.slice(prefix.length) || "/",
35
35
  ...(Object.keys(query).length ? { query } : {}),
36
36
  ...(json !== undefined ? { json } : sent ? { body: sent } : {}),
37
- };
37
+ });
38
38
  const content = entry.response.content ?? {};
39
39
  let text = content.text ?? "";
40
40
  if (content.encoding === "base64" && text) {
package/dist/http.d.ts CHANGED
@@ -10,12 +10,24 @@ export interface HttpResponse {
10
10
  json: any;
11
11
  /** Wall-clock time of the request in milliseconds. */
12
12
  durationMs: number;
13
+ /** The events of a `text/event-stream` response (data parsed as JSON when it is JSON); undefined for other responses. */
14
+ events?: ServerEvent[];
13
15
  }
16
+ /** One server-sent event of a response. */
17
+ export interface ServerEvent {
18
+ /** The `event:` field; undefined for unnamed (`message`) events. */
19
+ event?: string;
20
+ /** The `data:` lines joined by newlines, parsed as JSON when they are JSON (`[DONE]` stays a string). */
21
+ data: any;
22
+ id?: string;
23
+ }
24
+ /** Events of a `text/event-stream` body, as the HTML spec's parser dispatches them. */
25
+ export declare function parseEvents(text: string): ServerEvent[];
14
26
  type QueryValue = string | number | boolean;
15
27
  export interface RequestOptions {
16
28
  headers?: Record<string, string>;
17
- /** A list repeats the parameter: `{ tag: ["a", "b"] }` is `?tag=a&tag=b`. */
18
- 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>;
19
31
  /** Follow redirects instead of returning the 3xx response. Default false. */
20
32
  follow?: boolean;
21
33
  /** Fail the request when the app hasn't answered within this many ms, naming it, instead of the whole test timing out. */
@@ -28,7 +40,11 @@ declare class Session {
28
40
  cookies: Map<string, string>;
29
41
  /** The Path each cookie was set for; a cookie without one (added by hand) goes everywhere. */
30
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>;
31
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[];
32
48
  listeners: ((res: HttpResponse) => void)[];
33
49
  }
34
50
  /** HTTP client bound to the app under test. Keeps cookies for the duration of a scenario. */
@@ -81,7 +97,7 @@ export declare class HttpClient {
81
97
  */
82
98
  concurrently(n: number, send: (i: number) => Promise<HttpResponse>): Promise<HttpResponse[]>;
83
99
  /**
84
- * Strings, URLSearchParams, FormData, Blob and byte arrays are sent as-is; anything else is sent as JSON.
100
+ * Strings, URLSearchParams, FormData, Blob, ArrayBuffer and typed arrays are sent as-is; anything else is sent as JSON.
85
101
  * With `follow`, redirects are followed here rather than by fetch, so cookies set along the way are kept
86
102
  * (a login answering 302 + Set-Cookie) and nothing is sent outside the app: a redirect elsewhere is returned.
87
103
  */
@@ -90,14 +106,32 @@ export declare class HttpClient {
90
106
  onResponse(fn: (res: HttpResponse) => void): void;
91
107
  /** Let redirects to `host` continue to the stub at `stubUrl`, as they would to the real host in a browser. */
92
108
  intercept(host: string, stubUrl: string): void;
93
- /** 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. */
94
110
  get cookies(): Map<string, string>;
95
111
  clearCookies(): void;
96
112
  /** Requests made during the current scenario, oldest first (last 20). */
97
113
  get history(): readonly HttpResponse[];
114
+ /** Every request made during the current scenario, oldest first. */
115
+ get requests(): readonly HttpResponse[];
98
116
  /** Start a new scenario: forget cookies and history. */
99
117
  reset(): void;
100
118
  }
119
+ /** A `Set-Cookie` header's cookie and attributes, as `toSetCookie()` and YAML `expect.cookies` compare them. */
120
+ export interface SetCookie {
121
+ name: string;
122
+ value: string;
123
+ httpOnly: boolean;
124
+ secure: boolean;
125
+ /** As sent, e.g. `Lax`; undefined when the attribute is missing. */
126
+ sameSite?: string;
127
+ path?: string;
128
+ domain?: string;
129
+ maxAge?: number;
130
+ expires?: string;
131
+ partitioned: boolean;
132
+ }
133
+ /** The cookies a response sets, by name (the last one wins when a name repeats). */
134
+ export declare function setCookies(res: Pick<HttpResponse, "headers">): Record<string, SetCookie>;
101
135
  /** One line per request, for failure output. */
102
136
  export declare function formatHistory(history: readonly HttpResponse[]): string;
103
137
  export {};
package/dist/http.js CHANGED
@@ -2,6 +2,41 @@ import { encodeForm, formRequest } from "./form.js";
2
2
  import { lookupHost } from "./intercept.js";
3
3
  import { timeline } from "./timeline.js";
4
4
  import { signWebhook, webhookBody } from "./webhook.js";
5
+ /** Events of a `text/event-stream` body, as the HTML spec's parser dispatches them. */
6
+ export function parseEvents(text) {
7
+ const out = [];
8
+ let data = [];
9
+ let event;
10
+ let id;
11
+ for (const line of `${text}\n`.split(/\r\n|\r|\n/)) {
12
+ if (line === "") {
13
+ if (data.length) {
14
+ const raw = data.join("\n");
15
+ let parsed = raw;
16
+ try {
17
+ parsed = JSON.parse(raw);
18
+ }
19
+ catch { }
20
+ out.push({ ...(event ? { event } : {}), data: parsed, ...(id !== undefined ? { id } : {}) });
21
+ }
22
+ data = [];
23
+ event = undefined;
24
+ continue;
25
+ }
26
+ if (line.startsWith(":"))
27
+ continue;
28
+ const colon = line.indexOf(":");
29
+ const field = colon < 0 ? line : line.slice(0, colon);
30
+ const value = colon < 0 ? "" : line.slice(colon + 1).replace(/^ /, "");
31
+ if (field === "data")
32
+ data.push(value);
33
+ else if (field === "event")
34
+ event = value;
35
+ else if (field === "id" && !value.includes("\0"))
36
+ id = value;
37
+ }
38
+ return out;
39
+ }
5
40
  /** Per-scenario state shared by a client and every client derived from it with `with()`. */
6
41
  class Session {
7
42
  /** Stub base URLs of intercepted hosts, which redirects may lead to (an OAuth provider's login page). */
@@ -9,7 +44,11 @@ class Session {
9
44
  cookies = new Map();
10
45
  /** The Path each cookie was set for; a cookie without one (added by hand) goes everywhere. */
11
46
  cookiePaths = new Map();
47
+ /** When a cookie set with Max-Age or Expires stops being sent (epoch ms). */
48
+ cookieExpiry = new Map();
12
49
  history = [];
50
+ /** Every request of the scenario, for trace() and diagrams; `history` keeps only the last few for failure output. */
51
+ requests = [];
13
52
  listeners = [];
14
53
  }
15
54
  const HISTORY = 20;
@@ -90,7 +129,7 @@ export class HttpClient {
90
129
  webhook(path, payload, opts) {
91
130
  const { body, type } = webhookBody(payload, opts.provider);
92
131
  const url = opts.url ?? new URL(path, this.baseUrl).href;
93
- return this.request("POST", path, body, { headers: { "content-type": type, ...signWebhook(body, { ...opts, url }), ...opts.headers } });
132
+ return this.request("POST", path, body, { headers: mergeHeaders({ "content-type": type, ...signWebhook(body, { ...opts, url }) }, opts.headers) });
94
133
  }
95
134
  /**
96
135
  * Send a GraphQL operation to the app: `http.graphql("query { poll(id: 1) { title } }")`,
@@ -117,23 +156,27 @@ export class HttpClient {
117
156
  return all;
118
157
  }
119
158
  /**
120
- * Strings, URLSearchParams, FormData, Blob and byte arrays are sent as-is; anything else is sent as JSON.
159
+ * Strings, URLSearchParams, FormData, Blob, ArrayBuffer and typed arrays are sent as-is; anything else is sent as JSON.
121
160
  * With `follow`, redirects are followed here rather than by fetch, so cookies set along the way are kept
122
161
  * (a login answering 302 + Set-Cookie) and nothing is sent outside the app: a redirect elsewhere is returned.
123
162
  */
124
163
  async request(method, path, body, options = {}) {
125
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();
126
167
  let url = new URL(path, this.baseUrl);
127
168
  if (url.origin !== new URL(this.baseUrl).origin) {
128
169
  throw new Error(`slicetest: http only talks to the app under test; "${path}" resolves to ${url.origin}`);
129
170
  }
130
171
  for (const [k, v] of Object.entries(opts.query ?? {})) {
131
- 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)
132
174
  continue;
133
175
  if (Array.isArray(v)) {
134
176
  url.searchParams.delete(k);
135
177
  for (const item of v)
136
- url.searchParams.append(k, String(item));
178
+ if (item !== null && item !== undefined)
179
+ url.searchParams.append(k, String(item));
137
180
  }
138
181
  else
139
182
  url.searchParams.set(k, String(v));
@@ -147,8 +190,9 @@ export class HttpClient {
147
190
  // Like a browser: on to the app, or to a host a stub stands in for; anywhere else is returned.
148
191
  if (next.origin !== new URL(this.baseUrl).origin && lookupHost(this.#session.external, next.hostname) === undefined)
149
192
  break;
150
- // 307/308 repeat the request as it was; the others turn it into a GET without a body, like browsers do.
151
- 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) {
152
196
  method = "GET";
153
197
  body = undefined;
154
198
  }
@@ -173,7 +217,8 @@ export class HttpClient {
173
217
  body instanceof URLSearchParams ||
174
218
  body instanceof FormData ||
175
219
  body instanceof Blob ||
176
- body instanceof Uint8Array) {
220
+ body instanceof ArrayBuffer ||
221
+ ArrayBuffer.isView(body)) {
177
222
  payload = body;
178
223
  }
179
224
  else {
@@ -196,7 +241,21 @@ export class HttpClient {
196
241
  if (!stub)
197
242
  for (const cookie of res.headers.getSetCookie())
198
243
  this.#storeCookie(cookie, url.pathname);
199
- const text = await res.text();
244
+ let text;
245
+ try {
246
+ text = await res.text();
247
+ }
248
+ catch (e) {
249
+ // The timeout also covers the body: a stream that never ends, or an app that stalls halfway.
250
+ const type = res.headers.get("content-type");
251
+ const failed = { method, url: stub ? url.href : url.pathname + url.search, status: res.status, headers: res.headers, text: "", json: undefined, durationMs: Math.round(performance.now() - started) };
252
+ timeline.set(failed, { start: started, end: performance.now() });
253
+ this.#record(failed);
254
+ const why = e.name === "TimeoutError"
255
+ ? `answered ${res.status}${type ? ` (${type})` : ""} but the body didn't finish within ${opts.timeout}ms (http timeout)${/event-stream/i.test(type ?? "") ? "; the event stream has to end for its events to be checked" : ""}`
256
+ : `the connection broke while reading the ${res.status} response's body: ${e.message}`;
257
+ throw new Error(`slicetest: ${method} ${failed.url}: ${why}`, { cause: e });
258
+ }
200
259
  let json;
201
260
  try {
202
261
  json = text ? JSON.parse(text) : undefined;
@@ -210,6 +269,7 @@ export class HttpClient {
210
269
  text,
211
270
  json,
212
271
  durationMs: Math.round(performance.now() - started),
272
+ ...(/^text\/event-stream\b/i.test(res.headers.get("content-type") ?? "") ? { events: parseEvents(text) } : {}),
213
273
  };
214
274
  timeline.set(out, { start: started, end: performance.now() });
215
275
  this.#record(out);
@@ -224,6 +284,7 @@ export class HttpClient {
224
284
  this.#session.listeners.push(fn);
225
285
  }
226
286
  #record(res) {
287
+ this.#session.requests.push(res);
227
288
  this.#session.history.push(res);
228
289
  if (this.#session.history.length > HISTORY)
229
290
  this.#session.history.shift();
@@ -232,25 +293,44 @@ export class HttpClient {
232
293
  intercept(host, stubUrl) {
233
294
  this.#session.external.set(host.toLowerCase(), stubUrl);
234
295
  }
235
- /** 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. */
236
297
  get cookies() {
298
+ this.#dropExpired();
237
299
  return this.#session.cookies;
238
300
  }
239
301
  clearCookies() {
240
302
  this.#session.cookies.clear();
241
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
+ }
242
316
  }
243
317
  /** Requests made during the current scenario, oldest first (last 20). */
244
318
  get history() {
245
319
  return this.#session.history;
246
320
  }
321
+ /** Every request made during the current scenario, oldest first. */
322
+ get requests() {
323
+ return this.#session.requests;
324
+ }
247
325
  /** Start a new scenario: forget cookies and history. */
248
326
  reset() {
249
327
  this.clearCookies();
250
328
  this.#session.history = [];
329
+ this.#session.requests = [];
251
330
  }
252
331
  /** Cookies whose Path covers `requestPath`, longest Path first as browsers send them (RFC 6265 5.4). */
253
332
  #cookieHeader(requestPath) {
333
+ this.#dropExpired();
254
334
  const { cookies, cookiePaths } = this.#session;
255
335
  return [...cookies]
256
336
  .map(([name, value]) => ({ name, value, path: cookiePaths.get(name) ?? "/" }))
@@ -285,13 +365,53 @@ export class HttpClient {
285
365
  if (expired) {
286
366
  this.#session.cookies.delete(name);
287
367
  this.#session.cookiePaths.delete(name);
368
+ this.#session.cookieExpiry.delete(name);
288
369
  }
289
370
  else {
290
371
  this.#session.cookies.set(name, pair.slice(eq + 1).trim());
291
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);
292
378
  }
293
379
  }
294
380
  }
381
+ /** The cookies a response sets, by name (the last one wins when a name repeats). */
382
+ export function setCookies(res) {
383
+ const out = {};
384
+ for (const header of res.headers.getSetCookie()) {
385
+ const [pair, ...attrs] = header.split(";");
386
+ const eq = pair.indexOf("=");
387
+ if (eq <= 0)
388
+ continue;
389
+ const cookie = { name: pair.slice(0, eq).trim(), value: pair.slice(eq + 1).trim(), httpOnly: false, secure: false, partitioned: false };
390
+ for (const a of attrs) {
391
+ const i = a.indexOf("=");
392
+ const k = (i < 0 ? a : a.slice(0, i)).trim().toLowerCase();
393
+ const v = i < 0 ? "" : a.slice(i + 1).trim();
394
+ if (k === "httponly")
395
+ cookie.httpOnly = true;
396
+ else if (k === "secure")
397
+ cookie.secure = true;
398
+ else if (k === "partitioned")
399
+ cookie.partitioned = true;
400
+ else if (k === "samesite")
401
+ cookie.sameSite = v;
402
+ else if (k === "path")
403
+ cookie.path = v;
404
+ else if (k === "domain")
405
+ cookie.domain = v;
406
+ else if (k === "max-age")
407
+ cookie.maxAge = Number(v);
408
+ else if (k === "expires")
409
+ cookie.expires = v;
410
+ }
411
+ Object.defineProperty(out, cookie.name, { value: cookie, enumerable: true, writable: true, configurable: true });
412
+ }
413
+ return out;
414
+ }
295
415
  /** RFC 6265 5.1.4: the request path's directory, e.g. "/auth" for "/auth/login". */
296
416
  function defaultCookiePath(requestPath) {
297
417
  const slash = requestPath.lastIndexOf("/");
@@ -319,10 +439,15 @@ function merge(a, b) {
319
439
  return {
320
440
  ...a,
321
441
  ...b,
322
- headers: { ...a.headers, ...b.headers },
442
+ headers: mergeHeaders(a.headers, b.headers),
323
443
  query: { ...a.query, ...b.query },
324
444
  };
325
445
  }
446
+ /** Header names are case-insensitive: a later `authorization` replaces an earlier `Authorization` rather than joining it. */
447
+ function mergeHeaders(a = {}, b = {}) {
448
+ const names = new Set(Object.keys(b).map((k) => k.toLowerCase()));
449
+ return { ...Object.fromEntries(Object.entries(a).filter(([k]) => !names.has(k.toLowerCase()))), ...b };
450
+ }
326
451
  /** One line per request, for failure output. */
327
452
  export function formatHistory(history) {
328
453
  return history