slicetest 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +118 -30
- package/dist/app.js +10 -2
- package/dist/auth.js +2 -1
- package/dist/cli-run.d.ts +40 -0
- package/dist/cli-run.js +65 -0
- package/dist/cli.js +20 -34
- package/dist/config.d.ts +33 -2
- package/dist/config.js +82 -8
- 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 +1 -0
- package/dist/form.js +50 -6
- package/dist/har.js +2 -2
- package/dist/http.d.ts +9 -3
- package/dist/http.js +40 -5
- package/dist/init.js +11 -2
- 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 +4 -3
- package/dist/matchers.js +18 -6
- package/dist/openapi.d.ts +2 -0
- package/dist/openapi.js +133 -14
- package/dist/record-cli.js +2 -1
- package/dist/record.js +2 -1
- package/dist/recording.d.ts +4 -1
- package/dist/recording.js +14 -3
- 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 +132 -18
- package/dist/scenario.js +11 -2
- package/dist/stub.js +17 -6
- 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 +96 -34
- package/dist/yaml.d.ts +4 -0
- package/dist/yaml.js +33 -2
- 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 +27 -3
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] === "#") {
|
|
@@ -52,8 +56,8 @@ export function parseForms(html) {
|
|
|
52
56
|
const closeOption = (end) => {
|
|
53
57
|
if (!option || !select)
|
|
54
58
|
return;
|
|
55
|
-
const
|
|
56
|
-
select.options.push({ value, selected: "selected" in option.attrs, disabled: "disabled" in option.attrs || groupDisabled });
|
|
59
|
+
const label = textOf(html.slice(option.start, end));
|
|
60
|
+
select.options.push({ value: option.attrs.value ?? label, label, selected: "selected" in option.attrs, disabled: "disabled" in option.attrs || groupDisabled });
|
|
57
61
|
option = undefined;
|
|
58
62
|
};
|
|
59
63
|
TAG.lastIndex = 0;
|
|
@@ -192,13 +196,15 @@ export function formRequest(html, opts = {}) {
|
|
|
192
196
|
if (value instanceof Blob)
|
|
193
197
|
files.set(name, value);
|
|
194
198
|
else
|
|
195
|
-
typed.set(name, typeof value === "boolean" ? value : (Array.isArray(value) ? value : [value]).map(String));
|
|
199
|
+
typed.set(name, typeof value === "boolean" ? value : offered(form, name, (Array.isArray(value) ? value : [value]).map(String)));
|
|
196
200
|
}
|
|
197
201
|
// The successful controls, in document order (HTML's "constructing the entry list").
|
|
198
202
|
// A typed value replaces what the page had: text for a field, the checked state for
|
|
199
203
|
// checkboxes and radios (true / false, or the values to check), the options of a select.
|
|
200
204
|
const entries = [];
|
|
201
205
|
const done = new Set();
|
|
206
|
+
// A name shared by checkboxes and a hidden field (Rails' check_box sends `0` unless the box is checked): typing checks the boxes.
|
|
207
|
+
const boxed = new Set(form.controls.filter((c) => c.tag === "input" && ["checkbox", "radio"].includes(c.attrs.type?.toLowerCase() ?? "")).map((c) => c.attrs.name));
|
|
202
208
|
for (const c of form.controls) {
|
|
203
209
|
const name = c.attrs.name;
|
|
204
210
|
if (c === button && c.attrs.type?.toLowerCase() === "image") {
|
|
@@ -225,7 +231,7 @@ export function formRequest(html, opts = {}) {
|
|
|
225
231
|
done.add(name);
|
|
226
232
|
continue;
|
|
227
233
|
}
|
|
228
|
-
const want = typed.get(name);
|
|
234
|
+
const want = type === "checkbox" || type === "radio" || !boxed.has(name) ? typed.get(name) : undefined;
|
|
229
235
|
if (type === "checkbox" || type === "radio") {
|
|
230
236
|
const value = c.attrs.value ?? "on";
|
|
231
237
|
const checked = want === undefined ? "checked" in c.attrs : typeof want === "boolean" ? want : want.includes(value);
|
|
@@ -253,7 +259,10 @@ export function formRequest(html, opts = {}) {
|
|
|
253
259
|
entries.push([name, o.value]);
|
|
254
260
|
}
|
|
255
261
|
else
|
|
256
|
-
entries.push([name, c.attrs.value ?? ""]);
|
|
262
|
+
entries.push([name, type === "hidden" && name === "_charset_" ? "UTF-8" : (c.attrs.value ?? "")]);
|
|
263
|
+
// Text controls with a `dirname` also send their direction; left-to-right is what a test page has.
|
|
264
|
+
if (c.attrs.dirname && (c.tag === "textarea" || ["text", "search", "tel", "url", "email"].includes(type)))
|
|
265
|
+
entries.push([c.attrs.dirname, "ltr"]);
|
|
257
266
|
}
|
|
258
267
|
const pick = (attr) => (button && button.attrs[`form${attr}`] !== undefined ? button.attrs[`form${attr}`] : form.attrs[attr]);
|
|
259
268
|
const method = (pick("method") ?? "get").toUpperCase() === "POST" ? "POST" : "GET";
|
|
@@ -263,6 +272,12 @@ export function formRequest(html, opts = {}) {
|
|
|
263
272
|
throw new Error(`slicetest: submit: the form sends ${method === "GET" ? "a GET" : "urlencoded"} data, which can't carry files; a browser would send only the file name. Upload forms need method="post" enctype="multipart/form-data"`);
|
|
264
273
|
}
|
|
265
274
|
// Outside multipart, a file field is sent as its file name (empty when none was chosen), as browsers do.
|
|
275
|
+
// Browsers send line breaks as CRLF; URLSearchParams would keep a bare "\n".
|
|
276
|
+
for (const e of entries) {
|
|
277
|
+
e[0] = crlf(e[0]);
|
|
278
|
+
if (typeof e[1] === "string")
|
|
279
|
+
e[1] = crlf(e[1]);
|
|
280
|
+
}
|
|
266
281
|
const plain = () => new URLSearchParams(entries.map(([k, v]) => [k, typeof v === "string" ? v : (v.name ?? "")]));
|
|
267
282
|
if (method === "GET")
|
|
268
283
|
return { method, action: `${action.replace(/[?#].*$/, "")}?${plain()}` };
|
|
@@ -277,6 +292,35 @@ export function formRequest(html, opts = {}) {
|
|
|
277
292
|
}
|
|
278
293
|
return { method, action, body };
|
|
279
294
|
}
|
|
295
|
+
const crlf = (s) => s.replace(/\r\n|\r|\n/g, "\r\n");
|
|
296
|
+
/**
|
|
297
|
+
* Typed values for a select, radios or checkboxes, checked against what the page offers: a browser can't pick
|
|
298
|
+
* anything else. A select option may be named by its label too; it is sent as its value.
|
|
299
|
+
*/
|
|
300
|
+
function offered(form, name, values) {
|
|
301
|
+
const controls = form.controls.filter((c) => c.attrs.name === name && !("disabled" in c.attrs));
|
|
302
|
+
const select = controls.find((c) => c.tag === "select");
|
|
303
|
+
if (select) {
|
|
304
|
+
const usable = select.options.filter((o) => !o.disabled);
|
|
305
|
+
return values.map((v) => {
|
|
306
|
+
const o = usable.find((x) => x.value === v) ?? usable.find((x) => x.label === v);
|
|
307
|
+
if (o)
|
|
308
|
+
return o.value;
|
|
309
|
+
const off = select.options.filter((x) => x.disabled).map((x) => x.value);
|
|
310
|
+
throw new Error(`slicetest: submit: the select "${name}" has no option ${JSON.stringify(v)} (options: ${usable.map((x) => `${x.value} ${JSON.stringify(x.label)}`).join(", ") || "none"}${off.length ? `; disabled: ${off.join(", ")}` : ""})`);
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
for (const type of ["radio", "checkbox"]) {
|
|
314
|
+
const boxes = controls.filter((c) => c.tag === "input" && c.attrs.type?.toLowerCase() === type);
|
|
315
|
+
if (!boxes.length)
|
|
316
|
+
continue;
|
|
317
|
+
const have = boxes.map((c) => c.attrs.value ?? "on");
|
|
318
|
+
const bad = values.find((v) => !have.includes(v));
|
|
319
|
+
if (bad !== undefined)
|
|
320
|
+
throw new Error(`slicetest: submit: no ${type === "radio" ? "radio button" : "checkbox"} "${name}" has the value ${JSON.stringify(bad)} (values: ${have.join(", ")})`);
|
|
321
|
+
}
|
|
322
|
+
return values;
|
|
323
|
+
}
|
|
280
324
|
/** Fields as `application/x-www-form-urlencoded`: lists repeat the key, nested objects use bracket keys (`metadata[order]`). */
|
|
281
325
|
export function encodeForm(fields) {
|
|
282
326
|
const out = new URLSearchParams();
|
package/dist/har.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { mergeRecordings, redactRequest } from "./recording.js";
|
|
3
|
+
import { mergeRecordings, queryOf, redactRequest } from "./recording.js";
|
|
4
4
|
const KEPT_HEADERS = ["content-type", "location", "retry-after", "link", "etag"];
|
|
5
5
|
const TEXTUAL = /json|text|xml|javascript|x-www-form-urlencoded|graphql/i;
|
|
6
6
|
export function harEntries(har, file = "the HAR file") {
|
|
@@ -26,7 +26,7 @@ export function toRecording(entry, upstream) {
|
|
|
26
26
|
// Preflights are the browser's; aborted and blocked requests (status 0) have no answer to replay.
|
|
27
27
|
if (method === "OPTIONS" || !entry.response || !entry.response.status)
|
|
28
28
|
return "skipped";
|
|
29
|
-
const query =
|
|
29
|
+
const query = queryOf(url.searchParams);
|
|
30
30
|
const sent = entry.request.postData?.text;
|
|
31
31
|
const json = parse(sent);
|
|
32
32
|
const request = redactRequest({
|
package/dist/http.d.ts
CHANGED
|
@@ -26,8 +26,8 @@ export declare function parseEvents(text: string): ServerEvent[];
|
|
|
26
26
|
type QueryValue = string | number | boolean;
|
|
27
27
|
export interface RequestOptions {
|
|
28
28
|
headers?: Record<string, string>;
|
|
29
|
-
/** A list repeats the parameter: `{ tag: ["a", "b"] }` is `?tag=a&tag=b`. */
|
|
30
|
-
query?: Record<string, QueryValue | QueryValue[] | undefined>;
|
|
29
|
+
/** A list repeats the parameter: `{ tag: ["a", "b"] }` is `?tag=a&tag=b`. `null` and `undefined` are left out. */
|
|
30
|
+
query?: Record<string, QueryValue | null | (QueryValue | null)[] | undefined>;
|
|
31
31
|
/** Follow redirects instead of returning the 3xx response. Default false. */
|
|
32
32
|
follow?: boolean;
|
|
33
33
|
/** Fail the request when the app hasn't answered within this many ms, naming it, instead of the whole test timing out. */
|
|
@@ -40,7 +40,11 @@ declare class Session {
|
|
|
40
40
|
cookies: Map<string, string>;
|
|
41
41
|
/** The Path each cookie was set for; a cookie without one (added by hand) goes everywhere. */
|
|
42
42
|
cookiePaths: Map<string, string>;
|
|
43
|
+
/** When a cookie set with Max-Age or Expires stops being sent (epoch ms). */
|
|
44
|
+
cookieExpiry: Map<string, number>;
|
|
43
45
|
history: HttpResponse[];
|
|
46
|
+
/** Every request of the scenario, for trace() and diagrams; `history` keeps only the last few for failure output. */
|
|
47
|
+
requests: HttpResponse[];
|
|
44
48
|
listeners: ((res: HttpResponse) => void)[];
|
|
45
49
|
}
|
|
46
50
|
/** HTTP client bound to the app under test. Keeps cookies for the duration of a scenario. */
|
|
@@ -102,11 +106,13 @@ export declare class HttpClient {
|
|
|
102
106
|
onResponse(fn: (res: HttpResponse) => void): void;
|
|
103
107
|
/** Let redirects to `host` continue to the stub at `stubUrl`, as they would to the real host in a browser. */
|
|
104
108
|
intercept(host: string, stubUrl: string): void;
|
|
105
|
-
/** Cookies the app has set during this scenario. Mutations are sent with later requests. */
|
|
109
|
+
/** Cookies the app has set during this scenario (minus expired ones). Mutations are sent with later requests. */
|
|
106
110
|
get cookies(): Map<string, string>;
|
|
107
111
|
clearCookies(): void;
|
|
108
112
|
/** Requests made during the current scenario, oldest first (last 20). */
|
|
109
113
|
get history(): readonly HttpResponse[];
|
|
114
|
+
/** Every request made during the current scenario, oldest first. */
|
|
115
|
+
get requests(): readonly HttpResponse[];
|
|
110
116
|
/** Start a new scenario: forget cookies and history. */
|
|
111
117
|
reset(): void;
|
|
112
118
|
}
|
package/dist/http.js
CHANGED
|
@@ -44,7 +44,11 @@ class Session {
|
|
|
44
44
|
cookies = new Map();
|
|
45
45
|
/** The Path each cookie was set for; a cookie without one (added by hand) goes everywhere. */
|
|
46
46
|
cookiePaths = new Map();
|
|
47
|
+
/** When a cookie set with Max-Age or Expires stops being sent (epoch ms). */
|
|
48
|
+
cookieExpiry = new Map();
|
|
47
49
|
history = [];
|
|
50
|
+
/** Every request of the scenario, for trace() and diagrams; `history` keeps only the last few for failure output. */
|
|
51
|
+
requests = [];
|
|
48
52
|
listeners = [];
|
|
49
53
|
}
|
|
50
54
|
const HISTORY = 20;
|
|
@@ -158,17 +162,21 @@ export class HttpClient {
|
|
|
158
162
|
*/
|
|
159
163
|
async request(method, path, body, options = {}) {
|
|
160
164
|
const opts = merge(this.defaults, options);
|
|
165
|
+
// Fetch upper-cases get and put but not patch, so `request("patch", …)` went out as "patch".
|
|
166
|
+
method = method.toUpperCase();
|
|
161
167
|
let url = new URL(path, this.baseUrl);
|
|
162
168
|
if (url.origin !== new URL(this.baseUrl).origin) {
|
|
163
169
|
throw new Error(`slicetest: http only talks to the app under test; "${path}" resolves to ${url.origin}`);
|
|
164
170
|
}
|
|
165
171
|
for (const [k, v] of Object.entries(opts.query ?? {})) {
|
|
166
|
-
|
|
172
|
+
// `null` too: a value captured from JSON (`next_cursor: null`) would otherwise go out as the text "null".
|
|
173
|
+
if (v === undefined || v === null)
|
|
167
174
|
continue;
|
|
168
175
|
if (Array.isArray(v)) {
|
|
169
176
|
url.searchParams.delete(k);
|
|
170
177
|
for (const item of v)
|
|
171
|
-
|
|
178
|
+
if (item !== null && item !== undefined)
|
|
179
|
+
url.searchParams.append(k, String(item));
|
|
172
180
|
}
|
|
173
181
|
else
|
|
174
182
|
url.searchParams.set(k, String(v));
|
|
@@ -182,8 +190,9 @@ export class HttpClient {
|
|
|
182
190
|
// Like a browser: on to the app, or to a host a stub stands in for; anywhere else is returned.
|
|
183
191
|
if (next.origin !== new URL(this.baseUrl).origin && lookupHost(this.#session.external, next.hostname) === undefined)
|
|
184
192
|
break;
|
|
185
|
-
//
|
|
186
|
-
|
|
193
|
+
// Fetch's rules: 303 makes anything but HEAD a GET, 301/302 only a POST; the rest repeat the request as it was.
|
|
194
|
+
const toGet = res.status === 303 ? method !== "HEAD" : (res.status === 301 || res.status === 302) && method === "POST";
|
|
195
|
+
if (toGet) {
|
|
187
196
|
method = "GET";
|
|
188
197
|
body = undefined;
|
|
189
198
|
}
|
|
@@ -275,6 +284,7 @@ export class HttpClient {
|
|
|
275
284
|
this.#session.listeners.push(fn);
|
|
276
285
|
}
|
|
277
286
|
#record(res) {
|
|
287
|
+
this.#session.requests.push(res);
|
|
278
288
|
this.#session.history.push(res);
|
|
279
289
|
if (this.#session.history.length > HISTORY)
|
|
280
290
|
this.#session.history.shift();
|
|
@@ -283,25 +293,44 @@ export class HttpClient {
|
|
|
283
293
|
intercept(host, stubUrl) {
|
|
284
294
|
this.#session.external.set(host.toLowerCase(), stubUrl);
|
|
285
295
|
}
|
|
286
|
-
/** Cookies the app has set during this scenario. Mutations are sent with later requests. */
|
|
296
|
+
/** Cookies the app has set during this scenario (minus expired ones). Mutations are sent with later requests. */
|
|
287
297
|
get cookies() {
|
|
298
|
+
this.#dropExpired();
|
|
288
299
|
return this.#session.cookies;
|
|
289
300
|
}
|
|
290
301
|
clearCookies() {
|
|
291
302
|
this.#session.cookies.clear();
|
|
292
303
|
this.#session.cookiePaths.clear();
|
|
304
|
+
this.#session.cookieExpiry.clear();
|
|
305
|
+
}
|
|
306
|
+
#dropExpired() {
|
|
307
|
+
const { cookies, cookiePaths, cookieExpiry } = this.#session;
|
|
308
|
+
const now = Date.now();
|
|
309
|
+
for (const [name, at] of cookieExpiry) {
|
|
310
|
+
if (at > now)
|
|
311
|
+
continue;
|
|
312
|
+
cookies.delete(name);
|
|
313
|
+
cookiePaths.delete(name);
|
|
314
|
+
cookieExpiry.delete(name);
|
|
315
|
+
}
|
|
293
316
|
}
|
|
294
317
|
/** Requests made during the current scenario, oldest first (last 20). */
|
|
295
318
|
get history() {
|
|
296
319
|
return this.#session.history;
|
|
297
320
|
}
|
|
321
|
+
/** Every request made during the current scenario, oldest first. */
|
|
322
|
+
get requests() {
|
|
323
|
+
return this.#session.requests;
|
|
324
|
+
}
|
|
298
325
|
/** Start a new scenario: forget cookies and history. */
|
|
299
326
|
reset() {
|
|
300
327
|
this.clearCookies();
|
|
301
328
|
this.#session.history = [];
|
|
329
|
+
this.#session.requests = [];
|
|
302
330
|
}
|
|
303
331
|
/** Cookies whose Path covers `requestPath`, longest Path first as browsers send them (RFC 6265 5.4). */
|
|
304
332
|
#cookieHeader(requestPath) {
|
|
333
|
+
this.#dropExpired();
|
|
305
334
|
const { cookies, cookiePaths } = this.#session;
|
|
306
335
|
return [...cookies]
|
|
307
336
|
.map(([name, value]) => ({ name, value, path: cookiePaths.get(name) ?? "/" }))
|
|
@@ -336,10 +365,16 @@ export class HttpClient {
|
|
|
336
365
|
if (expired) {
|
|
337
366
|
this.#session.cookies.delete(name);
|
|
338
367
|
this.#session.cookiePaths.delete(name);
|
|
368
|
+
this.#session.cookieExpiry.delete(name);
|
|
339
369
|
}
|
|
340
370
|
else {
|
|
341
371
|
this.#session.cookies.set(name, pair.slice(eq + 1).trim());
|
|
342
372
|
this.#session.cookiePaths.set(name, cookiePath ?? defaultCookiePath(requestPath));
|
|
373
|
+
const until = maxAge !== undefined && !Number.isNaN(maxAge) ? Date.now() + maxAge * 1000 : expires !== undefined && !Number.isNaN(expires) ? expires : undefined;
|
|
374
|
+
if (until === undefined)
|
|
375
|
+
this.#session.cookieExpiry.delete(name);
|
|
376
|
+
else
|
|
377
|
+
this.#session.cookieExpiry.set(name, until);
|
|
343
378
|
}
|
|
344
379
|
}
|
|
345
380
|
}
|
package/dist/init.js
CHANGED
|
@@ -114,11 +114,19 @@ export async function detect(root) {
|
|
|
114
114
|
const appRoot = path.join(root, appDir);
|
|
115
115
|
const has = (p) => existsSync(path.join(appRoot, p));
|
|
116
116
|
const read = async (p) => (has(p) ? readFile(path.join(appRoot, p), "utf8") : "");
|
|
117
|
-
const
|
|
117
|
+
const readJson = async (name) => {
|
|
118
|
+
try {
|
|
119
|
+
return JSON.parse(await read(name));
|
|
120
|
+
}
|
|
121
|
+
catch (e) {
|
|
122
|
+
throw new Error(`slicetest init: ${name} isn't valid JSON (${e.message}); fix it, or run init from the folder of the app`);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
const pkg = has("package.json") ? (await readJson("package.json")) : undefined;
|
|
118
126
|
const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
|
|
119
127
|
const python = `${await read("requirements.txt")}\n${await read("pyproject.toml")}`.toLowerCase();
|
|
120
128
|
const gemfile = await read("Gemfile");
|
|
121
|
-
const composer = has("composer.json") ?
|
|
129
|
+
const composer = has("composer.json") ? (await readJson("composer.json")) : undefined;
|
|
122
130
|
const php = { ...composer?.require, ...composer?.["require-dev"] };
|
|
123
131
|
const laravel = !!php["laravel/framework"] && has("artisan");
|
|
124
132
|
const symfony = !laravel && !!php["symfony/framework-bundle"] && has("bin/console");
|
|
@@ -609,6 +617,7 @@ export async function init(root, { force = false } = {}) {
|
|
|
609
617
|
}
|
|
610
618
|
const { config, notes } = await detect(root);
|
|
611
619
|
const header = [
|
|
620
|
+
"# yaml-language-server: $schema=https://unpkg.com/slicetest/schema/config.schema.json",
|
|
612
621
|
"# slicetest config, generated by `npx slicetest init`. Paths are relative to this file.",
|
|
613
622
|
"# Everything the Vitest plugin accepts works here: https://github.com/revo1290/slicetest#configuration-reference",
|
|
614
623
|
"#",
|
package/dist/list.d.ts
CHANGED
package/dist/list.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { readdir, readFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { tagsSelected } from "./scenario.js";
|
|
4
|
-
import { parseScenarioFile } from "./yaml.js";
|
|
4
|
+
import { parseScenarioFile, scenarioTitle } from "./yaml.js";
|
|
5
|
+
function matchesGlob(file, glob) {
|
|
6
|
+
const match = path.matchesGlob;
|
|
7
|
+
if (!match)
|
|
8
|
+
throw new Error("slicetest list: the config's include needs Node.js 20.17 or later (path.matchesGlob)");
|
|
9
|
+
return match(file, glob.replace(/^\.\//, ""));
|
|
10
|
+
}
|
|
5
11
|
const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", "target", "vendor", ".venv", "venv"]);
|
|
6
12
|
async function scenarioFiles(dir, out = []) {
|
|
7
13
|
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
@@ -25,20 +31,27 @@ export async function listScenarios(root, opts = {}) {
|
|
|
25
31
|
const pattern = opts.name ? new RegExp(opts.name) : undefined;
|
|
26
32
|
for (const file of await scenarioFiles(root)) {
|
|
27
33
|
const rel = path.relative(root, file).split(path.sep).join("/");
|
|
34
|
+
if (opts.include && !opts.include.some((glob) => matchesGlob(rel, glob)))
|
|
35
|
+
continue;
|
|
28
36
|
if (opts.filters?.length && !opts.filters.some((f) => rel.includes(f)))
|
|
29
37
|
continue;
|
|
30
38
|
try {
|
|
31
39
|
const doc = parseScenarioFile(await readFile(file, "utf8"), rel);
|
|
40
|
+
// Vitest's `.only` runs only the focused tests of that file; one the tag filter leaves out is registered as skipped.
|
|
41
|
+
const focused = doc.scenarios.some((sc) => sc.only && !sc.skip && tagsSelected(sc.tags ?? [], opts.tags));
|
|
32
42
|
for (const sc of doc.scenarios) {
|
|
33
43
|
const tags = sc.tags ?? [];
|
|
34
|
-
|
|
44
|
+
// `-t` is matched against the title each row runs under (`vote {{c}}` → `vote a`).
|
|
45
|
+
const titles = sc.each ? sc.each.map((row, i) => scenarioTitle(sc.name, row, i)) : [sc.name];
|
|
46
|
+
const rows = pattern ? titles.filter((t) => pattern.test(t)).length : titles.length;
|
|
47
|
+
const selected = tagsSelected(tags, opts.tags) && rows > 0 && (!focused || !!sc.only);
|
|
35
48
|
scenarios.push({
|
|
36
49
|
file: rel,
|
|
37
50
|
line: sc.line,
|
|
38
51
|
name: sc.name,
|
|
39
52
|
tags,
|
|
40
53
|
status: sc.skip ? "skip" : !selected ? "filtered" : sc.only ? "only" : "run",
|
|
41
|
-
rows:
|
|
54
|
+
rows: selected ? rows : titles.length,
|
|
42
55
|
steps: doc.setup.length + sc.steps.length,
|
|
43
56
|
});
|
|
44
57
|
}
|
package/dist/mail.d.ts
CHANGED
|
@@ -47,7 +47,7 @@ export declare class Mailbox {
|
|
|
47
47
|
* The first message matching `filter`, waiting up to `within` ms for the app
|
|
48
48
|
* to send it. Fails with the messages that did arrive.
|
|
49
49
|
*/
|
|
50
|
-
waitFor(filter?: MailFilter, { within }?: {
|
|
50
|
+
waitFor(filter?: MailFilter, { within: wanted }?: {
|
|
51
51
|
within?: number | undefined;
|
|
52
52
|
}): Promise<Mail>;
|
|
53
53
|
/** One line per message received this scenario, for failure output. */
|
package/dist/mail.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { waitBudget } from "./deadline.js";
|
|
2
|
+
import { decodeEntities } from "./form.js";
|
|
1
3
|
import net from "node:net";
|
|
2
4
|
export class Mailbox {
|
|
3
5
|
server;
|
|
@@ -37,7 +39,8 @@ export class Mailbox {
|
|
|
37
39
|
* The first message matching `filter`, waiting up to `within` ms for the app
|
|
38
40
|
* to send it. Fails with the messages that did arrive.
|
|
39
41
|
*/
|
|
40
|
-
async waitFor(filter = {}, { within = 5000 } = {}) {
|
|
42
|
+
async waitFor(filter = {}, { within: wanted = 5000 } = {}) {
|
|
43
|
+
const { ms: within, note } = waitBudget(wanted);
|
|
41
44
|
const deadline = Date.now() + within;
|
|
42
45
|
for (;;) {
|
|
43
46
|
const found = this.messages(filter)[0];
|
|
@@ -45,7 +48,7 @@ export class Mailbox {
|
|
|
45
48
|
return found;
|
|
46
49
|
const left = deadline - Date.now();
|
|
47
50
|
if (left <= 0)
|
|
48
|
-
throw new Error(`slicetest: no mail matching ${describeFilter(filter)} within ${within}ms\n${this.describe()}`);
|
|
51
|
+
throw new Error(`slicetest: no mail matching ${describeFilter(filter)} within ${within}ms${note}\n${this.describe()}`);
|
|
49
52
|
await new Promise((resolve) => {
|
|
50
53
|
const done = () => {
|
|
51
54
|
clearTimeout(timer);
|
|
@@ -283,11 +286,8 @@ function htmlToText(html) {
|
|
|
283
286
|
.replace(/<(script|style)[\s\S]*?<\/\1>/gi, "")
|
|
284
287
|
.replace(/<br\s*\/?>|<\/p>/gi, "\n")
|
|
285
288
|
.replace(/<[^>]+>/g, "")
|
|
286
|
-
|
|
287
|
-
.replace(/&
|
|
288
|
-
.replace(/>/g, ">")
|
|
289
|
-
.replace(/"/g, '"')
|
|
290
|
-
.replace(/'/g, "'")
|
|
289
|
+
// One pass: replacing `&` first turned the text `<` (sent as `&lt;`) into `<`.
|
|
290
|
+
.replace(/&[^;\s]+;/g, (e) => decodeEntities(e))
|
|
291
291
|
.trim();
|
|
292
292
|
}
|
|
293
293
|
function links(s) {
|
package/dist/matchers.d.ts
CHANGED
|
@@ -32,8 +32,8 @@ interface SlicetestMatchers<R = unknown> {
|
|
|
32
32
|
toSetCookie(name: string, attributes?: Record<string, unknown>): R;
|
|
33
33
|
/** The response arrived within `ms` milliseconds (measured from sending the request to reading the whole body). */
|
|
34
34
|
toRespondWithin(ms: number): R;
|
|
35
|
-
/** An array of responses has exactly these status counts, e.g. `{ 201: 1, 409: 9 }`. */
|
|
36
|
-
toHaveStatuses(counts: Record<number
|
|
35
|
+
/** An array of responses has exactly these status counts, e.g. `{ 201: 1, 409: 9 }`; classes count the rest: `{ 201: 1, "4xx": 9 }`. */
|
|
36
|
+
toHaveStatuses(counts: Partial<Record<number | `${1 | 2 | 3 | 4 | 5}xx`, number>>): R;
|
|
37
37
|
/** Async: the table has at least one row matching `where` (`count` for an exact number). */
|
|
38
38
|
toHaveRow(table: string, where?: Where, count?: number): Promise<void>;
|
|
39
39
|
}
|
|
@@ -44,5 +44,6 @@ declare module "vitest" {
|
|
|
44
44
|
/** A status code, a class (`"2xx"`), or a list of either: `[200, 204]`, `["2xx", 304]`. */
|
|
45
45
|
export type ExpectedStatus = number | string | (number | string)[];
|
|
46
46
|
export declare function statusMatches(expected: ExpectedStatus, actual: number): boolean;
|
|
47
|
-
|
|
47
|
+
/** Responses counted under their code when `keys` lists it, else under their class (`4xx`) when that is listed. */
|
|
48
|
+
export declare function statusCounts(responses: HttpResponse[], keys?: string[]): Record<string, number>;
|
|
48
49
|
export {};
|
package/dist/matchers.js
CHANGED
|
@@ -41,14 +41,20 @@ export function statusMatches(expected, actual) {
|
|
|
41
41
|
function describeStatus(expected) {
|
|
42
42
|
return Array.isArray(expected) ? `one of ${expected.join(", ")}` : String(expected);
|
|
43
43
|
}
|
|
44
|
-
|
|
44
|
+
/** Responses counted under their code when `keys` lists it, else under their class (`4xx`) when that is listed. */
|
|
45
|
+
export function statusCounts(responses, keys = []) {
|
|
45
46
|
const out = {};
|
|
46
|
-
for (const r of responses)
|
|
47
|
-
|
|
47
|
+
for (const r of responses) {
|
|
48
|
+
const code = String(r.status);
|
|
49
|
+
const key = keys.includes(code) ? code : keys.includes(`${code[0]}xx`) ? `${code[0]}xx` : code;
|
|
50
|
+
out[key] = (out[key] ?? 0) + 1;
|
|
51
|
+
}
|
|
48
52
|
return out;
|
|
49
53
|
}
|
|
50
54
|
function fmtCounts(c) {
|
|
51
|
-
|
|
55
|
+
// In status order, a class before its codes; object order would put every code before every class.
|
|
56
|
+
const rank = (k) => (/xx$/.test(k) ? Number(k[0]) * 100 - 0.5 : Number(k));
|
|
57
|
+
return `{ ${Object.entries(c).sort(([a], [b]) => rank(a) - rank(b)).map(([k, n]) => `${k}: ${n}`).join(", ")} }`;
|
|
52
58
|
}
|
|
53
59
|
/** One example response per status, which is usually what explains the odd one out. */
|
|
54
60
|
function describeResponses(responses) {
|
|
@@ -210,8 +216,14 @@ expect.extend({
|
|
|
210
216
|
toHaveStatuses(received, counts) {
|
|
211
217
|
if (!Array.isArray(received))
|
|
212
218
|
throw new TypeError("slicetest: toHaveStatuses expects an array of responses, e.g. from http.concurrently()");
|
|
213
|
-
const
|
|
214
|
-
|
|
219
|
+
const keys = Object.keys(counts).map((k) => {
|
|
220
|
+
const key = k.trim().toLowerCase();
|
|
221
|
+
if (!/^\d{3}$|^[1-5]xx$/.test(key))
|
|
222
|
+
throw new TypeError(`slicetest: a status is a code (201) or a class ("2xx"), got ${JSON.stringify(k)}`);
|
|
223
|
+
return key;
|
|
224
|
+
});
|
|
225
|
+
const actual = statusCounts(received, keys);
|
|
226
|
+
const expected = Object.fromEntries(Object.entries(counts).filter((entry) => (entry[1] ?? 0) > 0).map(([k, n]) => [k.trim().toLowerCase(), n]));
|
|
215
227
|
const pass = this.equals(actual, expected);
|
|
216
228
|
return {
|
|
217
229
|
pass,
|
package/dist/openapi.d.ts
CHANGED
|
@@ -30,6 +30,8 @@ export interface Message {
|
|
|
30
30
|
/** Parsed JSON when the body is JSON, else the raw text. */
|
|
31
31
|
body: unknown;
|
|
32
32
|
query?: URLSearchParams;
|
|
33
|
+
/** Header names in lower case, as Node delivers them; undefined skips the check for required header parameters. */
|
|
34
|
+
headers?: Record<string, string | string[] | undefined>;
|
|
33
35
|
}
|
|
34
36
|
export declare class OpenApiSpec {
|
|
35
37
|
#private;
|