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.
- package/README.md +131 -35
- package/dist/app.js +10 -2
- package/dist/auth.js +2 -1
- package/dist/cli-run.d.ts +53 -0
- package/dist/cli-run.js +80 -0
- package/dist/cli.js +27 -29
- package/dist/config.d.ts +33 -2
- package/dist/config.js +121 -2
- package/dist/container-runtime.d.ts +4 -0
- package/dist/container-runtime.js +16 -0
- package/dist/containers.js +4 -3
- package/dist/db.d.ts +3 -1
- package/dist/db.js +45 -8
- package/dist/deadline.d.ts +7 -0
- package/dist/deadline.js +24 -0
- package/dist/doctor.js +27 -5
- package/dist/drivers/driver.d.ts +6 -0
- package/dist/drivers/mysql.js +5 -1
- package/dist/drivers/postgres.d.ts +2 -0
- package/dist/drivers/postgres.js +20 -1
- package/dist/exit.d.ts +1 -0
- package/dist/exit.js +28 -0
- package/dist/form.d.ts +2 -0
- package/dist/form.js +62 -9
- package/dist/global-setup.js +3 -6
- package/dist/har.js +4 -4
- package/dist/http.d.ts +38 -4
- package/dist/http.js +135 -10
- package/dist/init.js +64 -10
- package/dist/list.d.ts +1 -0
- package/dist/list.js +16 -3
- package/dist/mail.d.ts +1 -1
- package/dist/mail.js +7 -7
- package/dist/matchers.d.ts +18 -6
- package/dist/matchers.js +75 -8
- package/dist/openapi.d.ts +2 -5
- package/dist/openapi.js +146 -20
- package/dist/record-cli.js +2 -1
- package/dist/record.js +2 -1
- package/dist/recording.d.ts +11 -1
- package/dist/recording.js +51 -6
- package/dist/request-url.d.ts +1 -0
- package/dist/request-url.js +5 -0
- package/dist/runtime.d.ts +3 -3
- package/dist/runtime.js +144 -18
- package/dist/scenario.js +11 -2
- package/dist/sql-migrations.d.ts +11 -0
- package/dist/sql-migrations.js +38 -0
- package/dist/stub.d.ts +10 -3
- package/dist/stub.js +64 -16
- package/dist/vitest.d.ts +4 -0
- package/dist/vitest.js +6 -2
- package/dist/webhook.d.ts +3 -3
- package/dist/webhook.js +10 -2
- package/dist/yaml-runtime.d.ts +4 -4
- package/dist/yaml-runtime.js +141 -38
- package/dist/yaml.d.ts +12 -2
- package/dist/yaml.js +47 -4
- package/docs/guarantees.md +92 -0
- package/docs/state-isolation.md +94 -0
- package/package.json +4 -1
- package/schema/config.schema.json +675 -0
- 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
|
}
|
package/dist/drivers/postgres.js
CHANGED
|
@@ -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
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 = {
|
|
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
|
|
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
|
-
|
|
242
|
-
|
|
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
|
-
|
|
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();
|
package/dist/global-setup.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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 }),
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
151
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|