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/README.md +83 -17
- package/dist/cli-run.d.ts +13 -0
- package/dist/cli-run.js +15 -0
- package/dist/cli.js +32 -2
- package/dist/config.d.ts +6 -0
- package/dist/config.js +48 -0
- package/dist/db.d.ts +12 -1
- package/dist/db.js +33 -6
- package/dist/form.d.ts +8 -2
- package/dist/form.js +85 -11
- package/dist/global-setup.d.ts +5 -0
- package/dist/global-setup.js +32 -11
- package/dist/har.js +3 -3
- package/dist/http.d.ts +40 -4
- package/dist/http.js +126 -13
- package/dist/index.d.ts +4 -3
- package/dist/init.js +53 -8
- package/dist/list.d.ts +26 -0
- package/dist/list.js +71 -0
- package/dist/matchers.d.ts +21 -3
- package/dist/matchers.js +91 -2
- package/dist/openapi.d.ts +0 -5
- package/dist/openapi.js +13 -6
- package/dist/record.d.ts +1 -1
- package/dist/record.js +3 -1
- package/dist/recording.d.ts +7 -0
- package/dist/recording.js +38 -4
- package/dist/runtime.js +13 -0
- package/dist/scenario.d.ts +14 -4
- package/dist/scenario.js +36 -20
- package/dist/sql-migrations.d.ts +11 -0
- package/dist/sql-migrations.js +38 -0
- package/dist/stub.d.ts +32 -3
- package/dist/stub.js +148 -8
- package/dist/webhook.d.ts +16 -7
- package/dist/webhook.js +47 -7
- package/dist/yaml-runtime.d.ts +3 -2
- package/dist/yaml-runtime.js +287 -36
- package/dist/yaml.d.ts +51 -4
- package/dist/yaml.js +105 -14
- package/package.json +1 -1
- package/schema/scenario.schema.json +431 -6
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
|
-
|
|
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"
|
|
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
|
-
|
|
207
|
-
|
|
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
|
-
|
|
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(/[?#].*$/, "")}?${
|
|
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:
|
|
270
|
+
return { method, action, body: plain() };
|
|
222
271
|
const body = new FormData();
|
|
223
|
-
for (const [k, v] of entries)
|
|
224
|
-
|
|
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
|
+
}
|
package/dist/global-setup.d.ts
CHANGED
|
@@ -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`).
|
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. */
|
|
@@ -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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
39
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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";
|