slicetest 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/form.js CHANGED
@@ -37,7 +37,12 @@ export function parseForms(html) {
37
37
  let open;
38
38
  let select;
39
39
  let option;
40
+ let groupDisabled = false;
41
+ // Controls inside a disabled <fieldset> are disabled too (and not submitted).
42
+ const fieldsets = [];
40
43
  const add = (control) => {
44
+ if (fieldsets.includes(true))
45
+ control.attrs.disabled ??= "";
41
46
  const formId = control.attrs.form;
42
47
  if (formId !== undefined)
43
48
  orphans.push({ formId, control });
@@ -48,7 +53,7 @@ export function parseForms(html) {
48
53
  if (!option || !select)
49
54
  return;
50
55
  const value = option.attrs.value ?? textOf(html.slice(option.start, end));
51
- select.options.push({ value, selected: "selected" in option.attrs });
56
+ select.options.push({ value, selected: "selected" in option.attrs, disabled: "disabled" in option.attrs || groupDisabled });
52
57
  option = undefined;
53
58
  };
54
59
  TAG.lastIndex = 0;
@@ -62,6 +67,13 @@ export function parseForms(html) {
62
67
  TAG.lastIndex = end < 0 ? html.length : end;
63
68
  continue;
64
69
  }
70
+ if (tag === "fieldset") {
71
+ if (closing)
72
+ fieldsets.pop();
73
+ else
74
+ fieldsets.push("disabled" in parseAttrs(m[3] ?? ""));
75
+ continue;
76
+ }
65
77
  if (tag === "form") {
66
78
  if (closing)
67
79
  current = undefined;
@@ -96,6 +108,7 @@ export function parseForms(html) {
96
108
  if (closing) {
97
109
  closeOption(m.index);
98
110
  select = undefined;
111
+ groupDisabled = false;
99
112
  }
100
113
  else {
101
114
  select = { tag, attrs: parseAttrs(m[3] ?? ""), text: "", options: [] };
@@ -105,6 +118,8 @@ export function parseForms(html) {
105
118
  }
106
119
  if (tag === "option" || tag === "optgroup") {
107
120
  closeOption(m.index);
121
+ if (tag === "optgroup")
122
+ groupDisabled = !closing && "disabled" in parseAttrs(m[3] ?? "");
108
123
  if (tag === "option" && !closing && select)
109
124
  option = { attrs: parseAttrs(m[3] ?? ""), start: TAG.lastIndex };
110
125
  continue;
@@ -162,12 +177,22 @@ export function formRequest(html, opts = {}) {
162
177
  throw new Error(`slicetest: submit: the button ${JSON.stringify(label(button))} is disabled`);
163
178
  const names = new Set(form.controls.filter((c) => c.attrs.name !== undefined && !isSubmit(c) && c.tag !== "button").map((c) => c.attrs.name));
164
179
  const typed = new Map();
180
+ const files = new Map();
181
+ const fileInputs = new Set(form.controls.filter((c) => c.tag === "input" && c.attrs.type?.toLowerCase() === "file").map((c) => c.attrs.name));
165
182
  for (const [name, value] of Object.entries(opts.fields ?? {})) {
166
183
  if (!names.has(name)) {
167
184
  const shown = [...names].filter((n) => !n.startsWith("$ACTION_"));
168
185
  throw new Error(`slicetest: submit: the form has no field "${name}". Fields: ${shown.join(", ") || "(none)"}`);
169
186
  }
170
- typed.set(name, typeof value === "boolean" ? value : (Array.isArray(value) ? value : [value]).map(String));
187
+ if (fileInputs.has(name) !== value instanceof Blob) {
188
+ throw new Error(fileInputs.has(name)
189
+ ? `slicetest: submit: "${name}" is a file input; give it a file (a Blob or File, or { file: path } in YAML)`
190
+ : `slicetest: submit: "${name}" isn't a file input, so it can't take a file`);
191
+ }
192
+ if (value instanceof Blob)
193
+ files.set(name, value);
194
+ else
195
+ typed.set(name, typeof value === "boolean" ? value : (Array.isArray(value) ? value : [value]).map(String));
171
196
  }
172
197
  // The successful controls, in document order (HTML's "constructing the entry list").
173
198
  // A typed value replaces what the page had: text for a field, the checked state for
@@ -176,6 +201,12 @@ export function formRequest(html, opts = {}) {
176
201
  const done = new Set();
177
202
  for (const c of form.controls) {
178
203
  const name = c.attrs.name;
204
+ if (c === button && c.attrs.type?.toLowerCase() === "image") {
205
+ // An image button sends the click position, as name.x / name.y (x / y without a name).
206
+ const prefix = name ? `${name}.` : "";
207
+ entries.push([`${prefix}x`, "0"], [`${prefix}y`, "0"]);
208
+ continue;
209
+ }
179
210
  if (name === undefined || "disabled" in c.attrs)
180
211
  continue;
181
212
  if (c.tag === "button" || isSubmit(c)) {
@@ -184,8 +215,16 @@ export function formRequest(html, opts = {}) {
184
215
  continue;
185
216
  }
186
217
  const type = c.tag === "input" ? (c.attrs.type ?? "text").toLowerCase() : c.tag;
187
- if (type === "reset" || type === "button" || type === "file")
218
+ if (type === "reset" || type === "button")
219
+ continue;
220
+ if (type === "file") {
221
+ // With no file chosen, browsers still send the field: an empty file without a name.
222
+ const file = files.get(name);
223
+ if (!done.has(name))
224
+ entries.push([name, file ?? new File([], "", { type: "application/octet-stream" })]);
225
+ done.add(name);
188
226
  continue;
227
+ }
189
228
  const want = typed.get(name);
190
229
  if (type === "checkbox" || type === "radio") {
191
230
  const value = c.attrs.value ?? "on";
@@ -203,10 +242,15 @@ export function formRequest(html, opts = {}) {
203
242
  else if (c.tag === "textarea")
204
243
  entries.push([name, c.text]);
205
244
  else if (c.tag === "select") {
206
- const selected = c.options.filter((o) => o.selected);
207
- const chosen = selected.length ? selected : "multiple" in c.attrs || c.options.length === 0 ? [] : [c.options[0]];
245
+ // With nothing selected, a single select shows its first option that isn't disabled; disabled options are never sent.
246
+ // A single select keeps only the last option marked selected.
247
+ const marked = c.options.filter((o) => o.selected);
248
+ const selected = "multiple" in c.attrs ? marked : marked.slice(-1);
249
+ const first = c.options.find((o) => !o.disabled);
250
+ const chosen = selected.length ? selected : "multiple" in c.attrs || !first ? [] : [first];
208
251
  for (const o of chosen)
209
- entries.push([name, o.value]);
252
+ if (!o.disabled)
253
+ entries.push([name, o.value]);
210
254
  }
211
255
  else
212
256
  entries.push([name, c.attrs.value ?? ""]);
@@ -214,13 +258,43 @@ export function formRequest(html, opts = {}) {
214
258
  const pick = (attr) => (button && button.attrs[`form${attr}`] !== undefined ? button.attrs[`form${attr}`] : form.attrs[attr]);
215
259
  const method = (pick("method") ?? "get").toUpperCase() === "POST" ? "POST" : "GET";
216
260
  const action = pick("action") ?? "";
261
+ const multipart = method === "POST" && (pick("enctype") ?? "").toLowerCase() === "multipart/form-data";
262
+ if (files.size && !multipart) {
263
+ 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
+ }
265
+ // Outside multipart, a file field is sent as its file name (empty when none was chosen), as browsers do.
266
+ const plain = () => new URLSearchParams(entries.map(([k, v]) => [k, typeof v === "string" ? v : (v.name ?? "")]));
217
267
  if (method === "GET")
218
- return { method, action: `${action.replace(/[?#].*$/, "")}?${new URLSearchParams(entries)}` };
219
- const multipart = (pick("enctype") ?? "").toLowerCase() === "multipart/form-data";
268
+ return { method, action: `${action.replace(/[?#].*$/, "")}?${plain()}` };
220
269
  if (!multipart)
221
- return { method, action, body: new URLSearchParams(entries) };
270
+ return { method, action, body: plain() };
222
271
  const body = new FormData();
223
- for (const [k, v] of entries)
224
- body.append(k, v);
272
+ for (const [k, v] of entries) {
273
+ if (typeof v === "string")
274
+ body.append(k, v);
275
+ else
276
+ body.append(k, v, v.name ?? "blob");
277
+ }
225
278
  return { method, action, body };
226
279
  }
280
+ /** Fields as `application/x-www-form-urlencoded`: lists repeat the key, nested objects use bracket keys (`metadata[order]`). */
281
+ export function encodeForm(fields) {
282
+ const out = new URLSearchParams();
283
+ const add = (key, v) => {
284
+ if (v === null || v === undefined)
285
+ return;
286
+ if (Array.isArray(v)) {
287
+ const nested = v.some((x) => x !== null && typeof x === "object");
288
+ v.forEach((x, i) => add(nested ? `${key}[${i}]` : key, x));
289
+ }
290
+ else if (typeof v === "object" && !(v instanceof Date)) {
291
+ for (const [k, x] of Object.entries(v))
292
+ add(`${key}[${k}]`, x);
293
+ }
294
+ else
295
+ out.append(key, v instanceof Date ? v.toISOString() : String(v));
296
+ };
297
+ for (const [k, v] of Object.entries(fields))
298
+ add(k, v);
299
+ return out;
300
+ }
@@ -3,6 +3,11 @@ import type { ResolvedOptions } from "./config.js";
3
3
  import "./provided.js";
4
4
  /** Runs once per vitest run: start the database server, migrate a template database, hand its location to the workers. */
5
5
  export default function setup(project: TestProject): Promise<() => Promise<void>>;
6
+ /**
7
+ * Why this run covers only part of the suite (a file or name filter, tags, a shard), or undefined for a full run.
8
+ * Coverage then says little about the suite, so `minCoverage` isn't enforced and the cache for `gen --uncovered` is kept.
9
+ */
10
+ export declare function partialRun(project: TestProject): Promise<string | undefined>;
6
11
  /**
7
12
  * A hash of everything that determines the migrated schema, or undefined when
8
13
  * that can't be known (a migration command without `inputs`).
@@ -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. */
@@ -38,7 +39,7 @@ export default async function setup(project) {
38
39
  }
39
40
  finally {
40
41
  if (coverageDir)
41
- await reportCoverage(opts, coverageDir);
42
+ await reportCoverage(opts, coverageDir, await partialRun(project));
42
43
  if (usageDir)
43
44
  await reportUsage(opts, usageDir);
44
45
  if (recordDir)
@@ -102,7 +103,30 @@ async function startDatabase(opts, builds) {
102
103
  },
103
104
  };
104
105
  }
105
- async function reportCoverage(opts, dir) {
106
+ /**
107
+ * Why this run covers only part of the suite (a file or name filter, tags, a shard), or undefined for a full run.
108
+ * Coverage then says little about the suite, so `minCoverage` isn't enforced and the cache for `gen --uncovered` is kept.
109
+ */
110
+ export async function partialRun(project) {
111
+ try {
112
+ const config = project.vitest.config;
113
+ if (config.testNamePattern)
114
+ return `only scenarios matching ${config.testNamePattern}`;
115
+ if (process.env.SLICETEST_TAGS)
116
+ return `only tags ${process.env.SLICETEST_TAGS}`;
117
+ if (config.shard)
118
+ return "one shard";
119
+ const all = (await project.globTestFiles()).testFiles.length;
120
+ const ran = project.vitest.state.getFiles().filter((f) => f.projectName === project.name).length;
121
+ if (ran > 0 && ran < all)
122
+ return `${ran} of ${all} files`;
123
+ }
124
+ catch {
125
+ // An API this Vitest version lacks: treat the run as complete, as before.
126
+ }
127
+ return undefined;
128
+ }
129
+ async function reportCoverage(opts, dir, partial) {
106
130
  try {
107
131
  const hits = new Set();
108
132
  for (const file of await readdir(dir)) {
@@ -117,11 +141,12 @@ async function reportCoverage(opts, dir) {
117
141
  const report = formatCoverage(spec, hits);
118
142
  // For `slicetest gen --uncovered`.
119
143
  const cache = coverageCacheFile(opts.root);
120
- await mkdir(path.dirname(cache), { recursive: true }).then(() => writeFile(cache, JSON.stringify([...hits]))).catch(() => { });
121
- console.log(`\n${report.text}\n`);
144
+ if (!partial)
145
+ await mkdir(path.dirname(cache), { recursive: true }).then(() => writeFile(cache, JSON.stringify([...hits]))).catch(() => { });
146
+ console.log(`\n${report.text}${partial ? `\n(partial run: ${partial}; openapi.minCoverage is checked on full runs)` : ""}\n`);
122
147
  await appendSummary(report.markdown);
123
148
  const min = opts.openapi.minCoverage;
124
- if (min !== undefined && report.percent < min) {
149
+ if (min !== undefined && report.percent < min && !partial) {
125
150
  // Not thrown: Vitest reports teardown errors as a crash. The failing exit code is what CI needs.
126
151
  const message = `OpenAPI coverage ${report.percent}% is below openapi.minCoverage (${min}%)`;
127
152
  console.error(`slicetest: ${message}\n`);
@@ -290,14 +315,10 @@ async function applyMigrations(opts, engine, url, m) {
290
315
  return run("atlas", ["migrate", "apply", "--url", engine.atlasUrl(url), "--dir", dir], opts.root);
291
316
  }
292
317
  else if ("sql" in m) {
293
- const target = path.resolve(opts.root, m.sql);
294
- const files = (await stat(target)).isDirectory()
295
- ? (await readdir(target)).filter((f) => f.endsWith(".sql")).sort().map((f) => path.join(target, f))
296
- : [target];
318
+ const files = await sqlMigrationFiles(path.resolve(opts.root, m.sql));
297
319
  const driver = await engine.driver(url);
298
320
  try {
299
- for (const file of files)
300
- await driver.exec(await readFile(file, "utf8"));
321
+ await applySqlFiles(files, (sql) => driver.exec(sql), opts.root);
301
322
  }
302
323
  finally {
303
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, 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") {
@@ -29,12 +29,12 @@ export function toRecording(entry, upstream) {
29
29
  const query = Object.fromEntries([...url.searchParams.entries()].sort(([a], [b]) => a.localeCompare(b)));
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,28 @@ 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[];
26
+ type QueryValue = string | number | boolean;
14
27
  export interface RequestOptions {
15
28
  headers?: Record<string, string>;
16
- query?: Record<string, string | number | boolean | undefined>;
29
+ /** A list repeats the parameter: `{ tag: ["a", "b"] }` is `?tag=a&tag=b`. */
30
+ query?: Record<string, QueryValue | QueryValue[] | undefined>;
17
31
  /** Follow redirects instead of returning the 3xx response. Default false. */
18
32
  follow?: boolean;
33
+ /** Fail the request when the app hasn't answered within this many ms, naming it, instead of the whole test timing out. */
34
+ timeout?: number;
19
35
  }
20
36
  /** Per-scenario state shared by a client and every client derived from it with `with()`. */
21
37
  declare class Session {
@@ -35,8 +51,12 @@ export declare class HttpClient {
35
51
  constructor(baseUrl: string, defaults?: RequestOptions, session?: Session);
36
52
  /** A client that adds `opts` (e.g. an auth header) to every request, sharing this one's cookies. */
37
53
  with(opts: RequestOptions): HttpClient;
38
- /** Wrap fields to be sent as `application/x-www-form-urlencoded`. */
39
- form(fields: Record<string, string | number | boolean>): URLSearchParams;
54
+ /**
55
+ * Wrap fields to be sent as `application/x-www-form-urlencoded`. A list repeats the key (`to=a&to=b`,
56
+ * as an HTML form sends it), and nested objects use bracket keys (`metadata[order]=7`,
57
+ * `items[0][price]=p_1`), as Rails, PHP and Stripe read them. `null` / `undefined` are left out.
58
+ */
59
+ form(fields: Record<string, unknown>): URLSearchParams;
40
60
  get(path: string, opts?: RequestOptions): Promise<HttpResponse>;
41
61
  head(path: string, opts?: RequestOptions): Promise<HttpResponse>;
42
62
  delete(path: string, opts?: RequestOptions): Promise<HttpResponse>;
@@ -73,7 +93,7 @@ export declare class HttpClient {
73
93
  */
74
94
  concurrently(n: number, send: (i: number) => Promise<HttpResponse>): Promise<HttpResponse[]>;
75
95
  /**
76
- * Strings, URLSearchParams, FormData, Blob and byte arrays are sent as-is; anything else is sent as JSON.
96
+ * Strings, URLSearchParams, FormData, Blob, ArrayBuffer and typed arrays are sent as-is; anything else is sent as JSON.
77
97
  * With `follow`, redirects are followed here rather than by fetch, so cookies set along the way are kept
78
98
  * (a login answering 302 + Set-Cookie) and nothing is sent outside the app: a redirect elsewhere is returned.
79
99
  */
@@ -90,6 +110,22 @@ export declare class HttpClient {
90
110
  /** Start a new scenario: forget cookies and history. */
91
111
  reset(): void;
92
112
  }
113
+ /** A `Set-Cookie` header's cookie and attributes, as `toSetCookie()` and YAML `expect.cookies` compare them. */
114
+ export interface SetCookie {
115
+ name: string;
116
+ value: string;
117
+ httpOnly: boolean;
118
+ secure: boolean;
119
+ /** As sent, e.g. `Lax`; undefined when the attribute is missing. */
120
+ sameSite?: string;
121
+ path?: string;
122
+ domain?: string;
123
+ maxAge?: number;
124
+ expires?: string;
125
+ partitioned: boolean;
126
+ }
127
+ /** The cookies a response sets, by name (the last one wins when a name repeats). */
128
+ export declare function setCookies(res: Pick<HttpResponse, "headers">): Record<string, SetCookie>;
93
129
  /** One line per request, for failure output. */
94
130
  export declare function formatHistory(history: readonly HttpResponse[]): string;
95
131
  export {};
package/dist/http.js CHANGED
@@ -1,7 +1,42 @@
1
- import { formRequest } from "./form.js";
1
+ 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). */
@@ -34,9 +69,13 @@ export class HttpClient {
34
69
  with(opts) {
35
70
  return new HttpClient(this.baseUrl, merge(this.defaults, opts), this.#session);
36
71
  }
37
- /** Wrap fields to be sent as `application/x-www-form-urlencoded`. */
72
+ /**
73
+ * Wrap fields to be sent as `application/x-www-form-urlencoded`. A list repeats the key (`to=a&to=b`,
74
+ * as an HTML form sends it), and nested objects use bracket keys (`metadata[order]=7`,
75
+ * `items[0][price]=p_1`), as Rails, PHP and Stripe read them. `null` / `undefined` are left out.
76
+ */
38
77
  form(fields) {
39
- return new URLSearchParams(Object.entries(fields).map(([k, v]) => [k, String(v)]));
78
+ return encodeForm(fields);
40
79
  }
41
80
  get(path, opts) {
42
81
  return this.request("GET", path, undefined, opts);
@@ -84,8 +123,9 @@ export class HttpClient {
84
123
  * `{ invalidSignature: true }` and `{ stale: true }` make deliveries the app must refuse.
85
124
  */
86
125
  webhook(path, payload, opts) {
87
- const { body, type } = webhookBody(payload);
88
- return this.request("POST", path, body, { headers: { "content-type": type, ...signWebhook(body, opts), ...opts.headers } });
126
+ const { body, type } = webhookBody(payload, opts.provider);
127
+ const url = opts.url ?? new URL(path, this.baseUrl).href;
128
+ return this.request("POST", path, body, { headers: mergeHeaders({ "content-type": type, ...signWebhook(body, { ...opts, url }) }, opts.headers) });
89
129
  }
90
130
  /**
91
131
  * Send a GraphQL operation to the app: `http.graphql("query { poll(id: 1) { title } }")`,
@@ -112,7 +152,7 @@ export class HttpClient {
112
152
  return all;
113
153
  }
114
154
  /**
115
- * Strings, URLSearchParams, FormData, Blob and byte arrays are sent as-is; anything else is sent as JSON.
155
+ * Strings, URLSearchParams, FormData, Blob, ArrayBuffer and typed arrays are sent as-is; anything else is sent as JSON.
116
156
  * With `follow`, redirects are followed here rather than by fetch, so cookies set along the way are kept
117
157
  * (a login answering 302 + Set-Cookie) and nothing is sent outside the app: a redirect elsewhere is returned.
118
158
  */
@@ -122,9 +162,17 @@ export class HttpClient {
122
162
  if (url.origin !== new URL(this.baseUrl).origin) {
123
163
  throw new Error(`slicetest: http only talks to the app under test; "${path}" resolves to ${url.origin}`);
124
164
  }
125
- for (const [k, v] of Object.entries(opts.query ?? {}))
126
- if (v !== undefined)
165
+ for (const [k, v] of Object.entries(opts.query ?? {})) {
166
+ if (v === undefined)
167
+ continue;
168
+ if (Array.isArray(v)) {
169
+ url.searchParams.delete(k);
170
+ for (const item of v)
171
+ url.searchParams.append(k, String(item));
172
+ }
173
+ else
127
174
  url.searchParams.set(k, String(v));
175
+ }
128
176
  let res = await this.#send(method, url, body, opts);
129
177
  for (let hops = 0; opts.follow && REDIRECTS.has(res.status) && hops < 20; hops++) {
130
178
  const location = res.headers.get("location");
@@ -160,7 +208,8 @@ export class HttpClient {
160
208
  body instanceof URLSearchParams ||
161
209
  body instanceof FormData ||
162
210
  body instanceof Blob ||
163
- body instanceof Uint8Array) {
211
+ body instanceof ArrayBuffer ||
212
+ ArrayBuffer.isView(body)) {
164
213
  payload = body;
165
214
  }
166
215
  else {
@@ -171,19 +220,33 @@ export class HttpClient {
171
220
  const started = performance.now();
172
221
  let res;
173
222
  try {
174
- res = await fetch(target, { method, headers, body: payload, redirect: "manual" });
223
+ res = await fetch(target, { method, headers, body: payload, redirect: "manual", signal: opts.timeout ? AbortSignal.timeout(opts.timeout) : undefined });
175
224
  }
176
225
  catch (e) {
177
226
  const cause = e.cause?.code;
178
227
  const failed = { method, url: url.pathname + url.search, status: 0, headers: new Headers(), text: `${e}${cause ? ` (${cause})` : ""}`, json: undefined, durationMs: Math.round(performance.now() - started) };
179
228
  timeline.set(failed, { start: started, end: performance.now() });
180
229
  this.#record(failed);
181
- throw e;
230
+ throw new Error(`slicetest: ${method} ${failed.url}: ${requestFailure(e, cause, opts.timeout)}`, { cause: e });
182
231
  }
183
232
  if (!stub)
184
233
  for (const cookie of res.headers.getSetCookie())
185
234
  this.#storeCookie(cookie, url.pathname);
186
- const text = await res.text();
235
+ let text;
236
+ try {
237
+ text = await res.text();
238
+ }
239
+ catch (e) {
240
+ // The timeout also covers the body: a stream that never ends, or an app that stalls halfway.
241
+ const type = res.headers.get("content-type");
242
+ 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) };
243
+ timeline.set(failed, { start: started, end: performance.now() });
244
+ this.#record(failed);
245
+ const why = e.name === "TimeoutError"
246
+ ? `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" : ""}`
247
+ : `the connection broke while reading the ${res.status} response's body: ${e.message}`;
248
+ throw new Error(`slicetest: ${method} ${failed.url}: ${why}`, { cause: e });
249
+ }
187
250
  let json;
188
251
  try {
189
252
  json = text ? JSON.parse(text) : undefined;
@@ -197,6 +260,7 @@ export class HttpClient {
197
260
  text,
198
261
  json,
199
262
  durationMs: Math.round(performance.now() - started),
263
+ ...(/^text\/event-stream\b/i.test(res.headers.get("content-type") ?? "") ? { events: parseEvents(text) } : {}),
200
264
  };
201
265
  timeline.set(out, { start: started, end: performance.now() });
202
266
  this.#record(out);
@@ -279,6 +343,40 @@ export class HttpClient {
279
343
  }
280
344
  }
281
345
  }
346
+ /** The cookies a response sets, by name (the last one wins when a name repeats). */
347
+ export function setCookies(res) {
348
+ const out = {};
349
+ for (const header of res.headers.getSetCookie()) {
350
+ const [pair, ...attrs] = header.split(";");
351
+ const eq = pair.indexOf("=");
352
+ if (eq <= 0)
353
+ continue;
354
+ const cookie = { name: pair.slice(0, eq).trim(), value: pair.slice(eq + 1).trim(), httpOnly: false, secure: false, partitioned: false };
355
+ for (const a of attrs) {
356
+ const i = a.indexOf("=");
357
+ const k = (i < 0 ? a : a.slice(0, i)).trim().toLowerCase();
358
+ const v = i < 0 ? "" : a.slice(i + 1).trim();
359
+ if (k === "httponly")
360
+ cookie.httpOnly = true;
361
+ else if (k === "secure")
362
+ cookie.secure = true;
363
+ else if (k === "partitioned")
364
+ cookie.partitioned = true;
365
+ else if (k === "samesite")
366
+ cookie.sameSite = v;
367
+ else if (k === "path")
368
+ cookie.path = v;
369
+ else if (k === "domain")
370
+ cookie.domain = v;
371
+ else if (k === "max-age")
372
+ cookie.maxAge = Number(v);
373
+ else if (k === "expires")
374
+ cookie.expires = v;
375
+ }
376
+ Object.defineProperty(out, cookie.name, { value: cookie, enumerable: true, writable: true, configurable: true });
377
+ }
378
+ return out;
379
+ }
282
380
  /** RFC 6265 5.1.4: the request path's directory, e.g. "/auth" for "/auth/login". */
283
381
  function defaultCookiePath(requestPath) {
284
382
  const slash = requestPath.lastIndexOf("/");
@@ -292,14 +390,29 @@ function pathMatches(requestPath, cookiePath) {
292
390
  return false;
293
391
  return cookiePath.endsWith("/") || requestPath[cookiePath.length] === "/";
294
392
  }
393
+ /** Why a request got no response, in terms of what to look at. */
394
+ function requestFailure(e, code, timeout) {
395
+ if (e.name === "TimeoutError")
396
+ return `no response within ${timeout}ms (http timeout); the app may be waiting on a stub with a delay, a lock or a slow query`;
397
+ if (code === "ECONNREFUSED")
398
+ return "connection refused: the app isn't listening (it may have crashed; its output is below)";
399
+ if (code === "ECONNRESET" || code === "UND_ERR_SOCKET")
400
+ return "the app closed the connection without answering (did it crash or exit?)";
401
+ return `${e.message}${code ? ` (${code})` : ""}`;
402
+ }
295
403
  function merge(a, b) {
296
404
  return {
297
405
  ...a,
298
406
  ...b,
299
- headers: { ...a.headers, ...b.headers },
407
+ headers: mergeHeaders(a.headers, b.headers),
300
408
  query: { ...a.query, ...b.query },
301
409
  };
302
410
  }
411
+ /** Header names are case-insensitive: a later `authorization` replaces an earlier `Authorization` rather than joining it. */
412
+ function mergeHeaders(a = {}, b = {}) {
413
+ const names = new Set(Object.keys(b).map((k) => k.toLowerCase()));
414
+ return { ...Object.fromEntries(Object.entries(a).filter(([k]) => !names.has(k.toLowerCase()))), ...b };
415
+ }
303
416
  /** One line per request, for failure output. */
304
417
  export function formatHistory(history) {
305
418
  return history
package/dist/index.d.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  export { scenario } from "./scenario.js";
2
+ export type { ScenarioOptions } from "./scenario.js";
2
3
  export { mask } from "./trace.js";
3
4
  export type { Trace, MaskOptions } from "./trace.js";
4
5
  export type { ScenarioContext } from "./runtime.js";
5
- export type { Db, Row, Where, RowsOptions, Changes, TableChanges } from "./db.js";
6
- export type { Stub, RecordedCall, StubResponse, Responder, MatchOptions, RouteBuilder, GraphQLRouteBuilder, ChaosOptions, ServerSentEvent } from "./stub.js";
6
+ export type { Db, Row, Where, RowsOptions, Changes, TableChanges, ChangesOptions } from "./db.js";
7
+ export type { Stub, RecordedCall, StubResponse, Responder, MatchOptions, RouteBuilder, GraphQLRouteBuilder, ChaosOptions, ServerSentEvent, UploadedFile } from "./stub.js";
7
8
  export type { GraphQLCall } from "./graphql.js";
8
9
  export { sse } from "./stub.js";
9
10
  export type { HttpClient, HttpResponse, RequestOptions } from "./http.js";
@@ -15,4 +16,4 @@ export type { Issuer, TokenOptions, AuthOptions } from "./auth.js";
15
16
  export { normalizeSql, type Query, type QueryList } from "./query-log.js";
16
17
  export { signWebhook, type WebhookOptions, type WebhookProvider, type HmacScheme } from "./webhook.js";
17
18
  export type { SlicetestOptions, ContainerOptions } from "./config.js";
18
- export type {} from "./matchers.js";
19
+ export type { OrderedCall } from "./matchers.js";