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/openapi.js
CHANGED
|
@@ -112,7 +112,8 @@ export class OpenApiSpec {
|
|
|
112
112
|
if (!key)
|
|
113
113
|
return [`${method} ${template} responded ${status}, which ${this.file} doesn't document (documented: ${Object.keys(responses).join(", ") || "none"})`];
|
|
114
114
|
const [response, at] = this.#resolve(responses[key], ["paths", template, found.method, "responses", key]);
|
|
115
|
-
|
|
115
|
+
const label = `${method} ${template} → ${status}`;
|
|
116
|
+
return [...this.#checkHeaders(label, response?.headers, [...at, "headers"], res.headers), ...this.#checkContent(response?.content, res, [...at, "content"], label)];
|
|
116
117
|
}
|
|
117
118
|
/**
|
|
118
119
|
* A response the real service could send to `method path`: the lowest
|
|
@@ -133,8 +134,13 @@ export class OpenApiSpec {
|
|
|
133
134
|
const response = this.#deref(responses[key]);
|
|
134
135
|
const content = response?.content ?? {};
|
|
135
136
|
const media = Object.keys(content).find(isJson) ?? Object.keys(content)[0];
|
|
137
|
+
// The headers the spec requires, so the example passes the same check the stub's replies are held to.
|
|
138
|
+
const required = Object.fromEntries(Object.entries(response?.headers ?? {})
|
|
139
|
+
.map(([name, def]) => [name, this.#deref(def)])
|
|
140
|
+
.filter(([name, h]) => h?.required && !["content-type", "accept", "authorization"].includes(name.toLowerCase()))
|
|
141
|
+
.map(([name, h]) => [name, String(h.example !== undefined ? h.example : this.#sample(h.schema))]));
|
|
136
142
|
if (!media)
|
|
137
|
-
return { status };
|
|
143
|
+
return { status, ...(Object.keys(required).length ? { headers: required } : {}) };
|
|
138
144
|
const m = content[media] ?? {};
|
|
139
145
|
const named = m.examples && Object.values(m.examples)[0];
|
|
140
146
|
const body = m.example !== undefined
|
|
@@ -142,7 +148,7 @@ export class OpenApiSpec {
|
|
|
142
148
|
: named !== undefined
|
|
143
149
|
? (this.#deref(named)?.value ?? null)
|
|
144
150
|
: this.#sample(m.schema);
|
|
145
|
-
return { status, headers: { "content-type": media === "*/*" ? "application/json" : media }, body: isJson(media) ? body : String(body ?? "") };
|
|
151
|
+
return { status, headers: { "content-type": media === "*/*" ? "application/json" : media, ...required }, body: isJson(media) ? body : String(body ?? "") };
|
|
146
152
|
}
|
|
147
153
|
/**
|
|
148
154
|
* Every operation with what it takes to call it: sample values for its
|
|
@@ -220,10 +226,14 @@ export class OpenApiSpec {
|
|
|
220
226
|
case "object":
|
|
221
227
|
return Object.fromEntries(Object.entries(s.properties ?? {}).map(([k, v]) => [k, this.#sample(v, depth + 1)]));
|
|
222
228
|
case "array":
|
|
223
|
-
return Array.from({ length: Math.max(1, s.minItems ?? 1) }, () =>
|
|
229
|
+
return Array.from({ length: Math.max(1, s.minItems ?? 1) }, (_, i) => {
|
|
230
|
+
const items = this.#deref(s.items);
|
|
231
|
+
// Equal items would break `uniqueItems`.
|
|
232
|
+
return s.uniqueItems && Array.isArray(items?.enum) && items.enum.length ? items.enum[i % items.enum.length] : this.#sample(s.items, depth + 1);
|
|
233
|
+
});
|
|
224
234
|
case "integer":
|
|
225
235
|
case "number":
|
|
226
|
-
return
|
|
236
|
+
return sampleNumber(s, type === "integer");
|
|
227
237
|
case "boolean":
|
|
228
238
|
return true;
|
|
229
239
|
case "string":
|
|
@@ -239,10 +249,23 @@ export class OpenApiSpec {
|
|
|
239
249
|
return [`${method} ${path} is not in ${this.file}`];
|
|
240
250
|
const { template, op } = found;
|
|
241
251
|
const errors = [];
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
252
|
+
const listed = [
|
|
253
|
+
...(this.doc.paths[template].parameters ?? []).map((p, i) => this.#resolve(p, ["paths", template, "parameters", String(i)])),
|
|
254
|
+
...(op.parameters ?? []).map((p, i) => this.#resolve(p, ["paths", template, found.method, "parameters", String(i)])),
|
|
255
|
+
];
|
|
256
|
+
for (const [p, at] of listed) {
|
|
257
|
+
if (p?.in === "query") {
|
|
258
|
+
if (p.required && !this.#sentQuery(p, req.query))
|
|
259
|
+
errors.push(`${method} ${template}: required query parameter "${p.name}" is missing`);
|
|
260
|
+
const values = req.query?.getAll(p.name) ?? [];
|
|
261
|
+
if (values.length && p.schema)
|
|
262
|
+
errors.push(...this.#checkValues(`${method} ${template}`, `query parameter "${p.name}"`, values, p.schema, [...at, "schema"]));
|
|
263
|
+
}
|
|
264
|
+
// Authorization, Accept and Content-Type are the transport's business, whatever the spec lists.
|
|
265
|
+
const skipped = ["authorization", "accept", "content-type"];
|
|
266
|
+
if (p?.in === "header" && p.required && req.headers && !skipped.includes(p.name.toLowerCase()) && req.headers[p.name.toLowerCase()] === undefined) {
|
|
267
|
+
errors.push(`${method} ${template}: required header "${p.name}" is missing`);
|
|
268
|
+
}
|
|
246
269
|
}
|
|
247
270
|
const [body, at] = this.#resolve(op.requestBody, ["paths", template, found.method, "requestBody"]);
|
|
248
271
|
if (!body)
|
|
@@ -254,6 +277,59 @@ export class OpenApiSpec {
|
|
|
254
277
|
}
|
|
255
278
|
return [...errors, ...this.#checkContent(body.content, req, [...at, "content"], `${method} ${template} request`)];
|
|
256
279
|
}
|
|
280
|
+
/** Whether the request carries query parameter `p`, also as `name[key]` (deepObject) or as its properties (an exploded form object). */
|
|
281
|
+
#sentQuery(p, query) {
|
|
282
|
+
if (!query)
|
|
283
|
+
return false;
|
|
284
|
+
if (query.has(p.name))
|
|
285
|
+
return true;
|
|
286
|
+
const keys = [...query.keys()];
|
|
287
|
+
if (p.style === "deepObject")
|
|
288
|
+
return keys.some((k) => k.startsWith(`${p.name}[`));
|
|
289
|
+
const schema = this.#deref(p.schema);
|
|
290
|
+
const props = schema?.type === "object" && (p.style ?? "form") === "form" && p.explode !== false ? Object.keys(schema.properties ?? {}) : [];
|
|
291
|
+
return props.some((k) => query.has(k));
|
|
292
|
+
}
|
|
293
|
+
/** A query parameter's or header's values as strings, held to its schema after reading them as the type it declares. */
|
|
294
|
+
#checkValues(label, subject, values, schema, pointer) {
|
|
295
|
+
const [node, at] = this.#resolve(schema, pointer);
|
|
296
|
+
const one = (raw, node, at, where) => {
|
|
297
|
+
if (!["string", "integer", "number", "boolean"].includes(node?.type) && !node?.enum)
|
|
298
|
+
return [];
|
|
299
|
+
const validate = this.#validator(at);
|
|
300
|
+
const value = (node.type === "integer" || node.type === "number") && raw.trim() !== "" && Number.isFinite(Number(raw)) ? Number(raw) : node.type === "boolean" && (raw === "true" || raw === "false") ? raw === "true" : raw;
|
|
301
|
+
if (validate(value))
|
|
302
|
+
return [];
|
|
303
|
+
return [`${label}: ${subject}${where} ${validate.errors[0].message} (got ${JSON.stringify(raw)})`];
|
|
304
|
+
};
|
|
305
|
+
if (node?.type !== "array")
|
|
306
|
+
return one(values[0], node, at, "");
|
|
307
|
+
const [items, itemsAt] = this.#resolve(node.items, [...at, "items"]);
|
|
308
|
+
// One value may carry the whole list (`?ids=1,2`, explode: false). Many servers accept it for the exploded default too, so it isn't held against the spec.
|
|
309
|
+
const list = values.length > 1 ? values : values.flatMap((v) => v.split(","));
|
|
310
|
+
return list.flatMap((raw, i) => one(raw, items, itemsAt, ` item ${i + 1}`));
|
|
311
|
+
}
|
|
312
|
+
/** The headers a response documents: the required ones must be there, and what is there must fit its schema. */
|
|
313
|
+
#checkHeaders(label, documented, at, actual) {
|
|
314
|
+
if (!documented || !actual)
|
|
315
|
+
return [];
|
|
316
|
+
const errors = [];
|
|
317
|
+
for (const [name, def] of Object.entries(documented)) {
|
|
318
|
+
// The spec says to ignore these three when they are listed.
|
|
319
|
+
if (["content-type", "accept", "authorization"].includes(name.toLowerCase()))
|
|
320
|
+
continue;
|
|
321
|
+
const [h, hat] = this.#resolve(def, [...at, name]);
|
|
322
|
+
const raw = actual[name.toLowerCase()];
|
|
323
|
+
if (raw === undefined) {
|
|
324
|
+
if (h?.required)
|
|
325
|
+
errors.push(`${label}: required header "${name}" is missing`);
|
|
326
|
+
}
|
|
327
|
+
else if (h?.schema) {
|
|
328
|
+
errors.push(...this.#checkValues(label, `header "${name}"`, [Array.isArray(raw) ? raw.join(", ") : raw], h.schema, [...hat, "schema"]));
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return errors;
|
|
332
|
+
}
|
|
257
333
|
#checkContent(content, msg, pointer, label) {
|
|
258
334
|
if (!content || Object.keys(content).length === 0)
|
|
259
335
|
return [];
|
|
@@ -261,9 +337,11 @@ export class OpenApiSpec {
|
|
|
261
337
|
// Without a content-type (e.g. a stub replying with a bare string) there's nothing to hold the body to.
|
|
262
338
|
if (!type)
|
|
263
339
|
return [];
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
340
|
+
// A spec may write the key with parameters (`application/json; charset=utf-8`); the type alone decides.
|
|
341
|
+
const bare = (k) => k.split(";")[0].trim().toLowerCase();
|
|
342
|
+
const media = Object.keys(content).find((k) => bare(k) === type) ??
|
|
343
|
+
Object.keys(content).find((k) => bare(k).endsWith("/*") && type.startsWith(bare(k).slice(0, -1))) ??
|
|
344
|
+
Object.keys(content).find((k) => bare(k) === "*/*");
|
|
267
345
|
if (!media)
|
|
268
346
|
return [`${label}: content-type "${type || "(none)"}" is not one of ${Object.keys(content).join(", ")}`];
|
|
269
347
|
if (!content[media]?.schema || !isJson(media))
|
|
@@ -313,9 +391,38 @@ const FORMATS = {
|
|
|
313
391
|
ipv6: "2001:db8::1",
|
|
314
392
|
};
|
|
315
393
|
function sampleString(s) {
|
|
316
|
-
|
|
394
|
+
let base = (s.format && FORMATS[s.format]) ?? "string";
|
|
395
|
+
// A cut-off date or address would break its format as much as the length, so only plain strings are shortened.
|
|
396
|
+
if (!s.format && s.maxLength !== undefined && base.length > s.maxLength)
|
|
397
|
+
base = base.slice(0, s.maxLength);
|
|
317
398
|
return base.length >= (s.minLength ?? 0) ? base : base.padEnd(s.minLength, "x");
|
|
318
399
|
}
|
|
400
|
+
/** A number inside the schema's bounds (3.0's `exclusiveMinimum: true` and 3.1's numeric form), on its `multipleOf` step. */
|
|
401
|
+
function sampleNumber(s, integer) {
|
|
402
|
+
const bound = (value, exclusive) => {
|
|
403
|
+
if (typeof s[exclusive] === "number")
|
|
404
|
+
return { at: s[exclusive], open: true };
|
|
405
|
+
if (typeof s[value] === "number")
|
|
406
|
+
return { at: s[value], open: s[exclusive] === true };
|
|
407
|
+
return undefined;
|
|
408
|
+
};
|
|
409
|
+
const lo = bound("minimum", "exclusiveMinimum");
|
|
410
|
+
const hi = bound("maximum", "exclusiveMaximum");
|
|
411
|
+
let n = 0;
|
|
412
|
+
if (lo)
|
|
413
|
+
n = lo.open ? lo.at + 1 : lo.at;
|
|
414
|
+
else if (hi)
|
|
415
|
+
n = hi.open ? hi.at - 1 : Math.min(0, hi.at);
|
|
416
|
+
// The step past an open lower bound can overshoot a close upper one.
|
|
417
|
+
if (lo && hi && (hi.open ? n >= hi.at : n > hi.at))
|
|
418
|
+
n = lo.open ? (lo.at + hi.at) / 2 : lo.at;
|
|
419
|
+
if (typeof s.multipleOf === "number" && s.multipleOf > 0) {
|
|
420
|
+
n = Math.ceil(n / s.multipleOf) * s.multipleOf;
|
|
421
|
+
if (lo?.open && n <= lo.at)
|
|
422
|
+
n += s.multipleOf;
|
|
423
|
+
}
|
|
424
|
+
return integer ? Math.ceil(n) : n;
|
|
425
|
+
}
|
|
319
426
|
function pickResponse(responses, status) {
|
|
320
427
|
const s = String(status);
|
|
321
428
|
return [s, `${s[0]}XX`, `${s[0]}xx`, "default"].find((k) => k in responses);
|
|
@@ -353,7 +460,8 @@ export function formatCoverage(spec, hits) {
|
|
|
353
460
|
return { covered, total: keys.length, percent, markdown, text: `slicetest: OpenAPI coverage (${spec.file}): ${covered}/${keys.length} documented responses (${percent}%)\n${lines.join("\n")}` };
|
|
354
461
|
}
|
|
355
462
|
function isJson(media) {
|
|
356
|
-
|
|
463
|
+
const type = media.split(";")[0].trim();
|
|
464
|
+
return /[/+]json$/i.test(type) || type === "*/*";
|
|
357
465
|
}
|
|
358
466
|
function escape(s) {
|
|
359
467
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
@@ -366,6 +474,17 @@ export function nullableToType(node) {
|
|
|
366
474
|
return node;
|
|
367
475
|
for (const [k, v] of Object.entries(node))
|
|
368
476
|
node[k] = nullableToType(v);
|
|
477
|
+
// 3.0 writes `minimum: 0, exclusiveMinimum: true`; JSON Schema (and Ajv) want `exclusiveMinimum: 0`.
|
|
478
|
+
for (const [flag, bound] of [["exclusiveMinimum", "minimum"], ["exclusiveMaximum", "maximum"]]) {
|
|
479
|
+
if (typeof node[flag] !== "boolean")
|
|
480
|
+
continue;
|
|
481
|
+
if (node[flag] && typeof node[bound] === "number") {
|
|
482
|
+
node[flag] = node[bound];
|
|
483
|
+
delete node[bound];
|
|
484
|
+
}
|
|
485
|
+
else
|
|
486
|
+
delete node[flag];
|
|
487
|
+
}
|
|
369
488
|
if (node.nullable === true) {
|
|
370
489
|
if (node.enum && !node.enum.includes(null))
|
|
371
490
|
node.enum = [...node.enum, null];
|
package/dist/record-cli.js
CHANGED
|
@@ -4,6 +4,7 @@ import os from "node:os";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { createInterface } from "node:readline";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { compatStart } from "./cli-run.js";
|
|
7
8
|
import { resolveOptions } from "./config.js";
|
|
8
9
|
import { SESSION_ENV } from "./record.js";
|
|
9
10
|
const ext = path.extname(fileURLToPath(import.meta.url));
|
|
@@ -29,7 +30,7 @@ export async function record(configPath, options, { out, port = 0 }) {
|
|
|
29
30
|
process.env[SESSION_ENV] = JSON.stringify(session);
|
|
30
31
|
const stop = () => void writeFile(session.stopFile, "").catch(() => { });
|
|
31
32
|
process.once("SIGINT", stop);
|
|
32
|
-
const
|
|
33
|
+
const startVitest = compatStart(await import("vitest/node"));
|
|
33
34
|
const { slicetest } = await import("./vitest.js");
|
|
34
35
|
const announce = watchState(session.stateFile, (state) => {
|
|
35
36
|
process.stdout.write(`\nRecording. Use the app through ${state.url} (it forwards to ${state.app}).\n` +
|
package/dist/record.js
CHANGED
|
@@ -5,6 +5,7 @@ import path from "node:path";
|
|
|
5
5
|
import { stringify } from "yaml";
|
|
6
6
|
import { parseForm } from "./stub.js";
|
|
7
7
|
import { mask } from "./trace.js";
|
|
8
|
+
import { requestUrl } from "./request-url.js";
|
|
8
9
|
export const SESSION_ENV = "SLICETEST_RECORD_SESSION";
|
|
9
10
|
/** Requests a browser makes on its own, not part of what the scenario is about. */
|
|
10
11
|
const ASSET = /\.(ico|png|jpe?g|gif|webp|avif|svg|css|js|mjs|map|woff2?|ttf|eot)(\?|$)/i;
|
|
@@ -27,7 +28,7 @@ export async function runSession(ctx, session) {
|
|
|
27
28
|
headers.append(k, value);
|
|
28
29
|
}
|
|
29
30
|
try {
|
|
30
|
-
const upstream = await fetch(
|
|
31
|
+
const upstream = await fetch(requestUrl(req.url, target), {
|
|
31
32
|
method: req.method,
|
|
32
33
|
headers,
|
|
33
34
|
body: body.length && req.method !== "GET" && req.method !== "HEAD" ? body : undefined,
|
package/dist/recording.d.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { RecordedCall, StubResponse } from "./stub.js";
|
|
2
2
|
/** One recorded exchange with the real service, as stored in the recordings file. */
|
|
3
3
|
export interface Recording {
|
|
4
|
+
/** A parameter sent more than once (`?ids=1&ids=2`) is a list of its values, in order. */
|
|
4
5
|
request: {
|
|
5
6
|
method: string;
|
|
6
7
|
path: string;
|
|
7
|
-
query?: Record<string, string>;
|
|
8
|
+
query?: Record<string, string | string[]>;
|
|
8
9
|
json?: unknown;
|
|
9
10
|
body?: string;
|
|
10
11
|
};
|
|
@@ -38,6 +39,8 @@ export declare class Recorder {
|
|
|
38
39
|
/** Recordings made by this worker, to be merged into the file when the run ends. */
|
|
39
40
|
added(): Recording[];
|
|
40
41
|
}
|
|
42
|
+
/** Query parameters by name, sorted; a repeated one as the list of its values. */
|
|
43
|
+
export declare function queryOf(params: URLSearchParams): Record<string, string | string[]>;
|
|
41
44
|
export declare const REDACTED = "[redacted]";
|
|
42
45
|
/**
|
|
43
46
|
* A request with credential values replaced by `[redacted]`, so recordings can be committed and
|
package/dist/recording.js
CHANGED
|
@@ -88,8 +88,15 @@ export class Recorder {
|
|
|
88
88
|
};
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
|
+
/** Query parameters by name, sorted; a repeated one as the list of its values. */
|
|
92
|
+
export function queryOf(params) {
|
|
93
|
+
return Object.fromEntries([...new Set(params.keys())].sort((a, b) => a.localeCompare(b)).map((k) => {
|
|
94
|
+
const all = params.getAll(k);
|
|
95
|
+
return [k, all.length > 1 ? all : all[0]];
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
91
98
|
function requestOf(call) {
|
|
92
|
-
const query =
|
|
99
|
+
const query = queryOf(call.query);
|
|
93
100
|
return redactRequest({
|
|
94
101
|
method: call.method,
|
|
95
102
|
path: call.path,
|
|
@@ -111,7 +118,7 @@ const FORM_BODY = /^[^\s=&]+=[^\s&]*(&[^\s=&]+=[^\s&]*)*$/;
|
|
|
111
118
|
export function redactRequest(r) {
|
|
112
119
|
const out = { ...r };
|
|
113
120
|
if (r.query)
|
|
114
|
-
out.query = Object.fromEntries(Object.entries(r.query).map(([k, v]) => [k, SECRET.test(k) ? REDACTED :
|
|
121
|
+
out.query = Object.fromEntries(Object.entries(r.query).map(([k, v]) => [k, !SECRET.test(k) ? v : Array.isArray(v) ? v.map(() => REDACTED) : REDACTED]));
|
|
115
122
|
if (r.json !== undefined)
|
|
116
123
|
out.json = redactJson(r.json);
|
|
117
124
|
if (r.body && FORM_BODY.test(r.body)) {
|
|
@@ -142,7 +149,11 @@ function sortKeys(v) {
|
|
|
142
149
|
return v;
|
|
143
150
|
}
|
|
144
151
|
function toResponse(e) {
|
|
145
|
-
|
|
152
|
+
const { status, headers, json, body } = e.response;
|
|
153
|
+
// A string is sent as it is; a JSON string reply (`"ok"`) needs its quotes back.
|
|
154
|
+
if (typeof json === "string")
|
|
155
|
+
return { status, headers: { "content-type": "application/json", ...headers }, body: JSON.stringify(json) };
|
|
156
|
+
return { status, headers, body: json !== undefined ? json : (body ?? "") };
|
|
146
157
|
}
|
|
147
158
|
function parse(text) {
|
|
148
159
|
if (!text)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function requestUrl(target: string | undefined, base: string | URL): URL;
|
package/dist/runtime.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { App } from "./app.js";
|
|
2
2
|
import { Issuer } from "./auth.js";
|
|
3
|
-
import type
|
|
3
|
+
import { type ResolvedOptions } from "./config.js";
|
|
4
4
|
import { Dependency } from "./containers.js";
|
|
5
5
|
import { connectionVars } from "./connection.js";
|
|
6
6
|
import { Db } from "./db.js";
|
|
@@ -63,8 +63,8 @@ export declare class Runtime {
|
|
|
63
63
|
}): Promise<Runtime>;
|
|
64
64
|
context(): ScenarioContext;
|
|
65
65
|
beforeScenario(): Promise<void>;
|
|
66
|
-
/**
|
|
67
|
-
afterScenario(): Promise<void>;
|
|
66
|
+
/** `deadline`: when the test's own timeout is about to end, so `idle` reports before Vitest does. */
|
|
67
|
+
afterScenario(deadline?: number): Promise<void>;
|
|
68
68
|
/** The current scenario as a Mermaid sequence diagram. */
|
|
69
69
|
diagram(): Promise<string>;
|
|
70
70
|
/**
|
package/dist/runtime.js
CHANGED
|
@@ -4,6 +4,7 @@ import os from "node:os";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { App } from "./app.js";
|
|
6
6
|
import { Issuer } from "./auth.js";
|
|
7
|
+
import { seedFiles } from "./config.js";
|
|
7
8
|
import { Dependency } from "./containers.js";
|
|
8
9
|
import { connectionVars } from "./connection.js";
|
|
9
10
|
import { Db, formatChanges, noDatabase } from "./db.js";
|
|
@@ -125,7 +126,7 @@ export class Runtime {
|
|
|
125
126
|
schemas: opts.db.schemas,
|
|
126
127
|
keep: opts.db.keep,
|
|
127
128
|
ignoreChanges: opts.db.ignoreChanges,
|
|
128
|
-
|
|
129
|
+
seedFiles: seedFiles(opts.db.seed).map((f) => path.resolve(opts.root, f)),
|
|
129
130
|
})
|
|
130
131
|
: noDatabase();
|
|
131
132
|
const started = await Promise.allSettled(Object.entries(opts.containers).map(async ([name, c]) => containers.set(name, await Dependency.start(name, c))));
|
|
@@ -234,7 +235,7 @@ export class Runtime {
|
|
|
234
235
|
throw new Error(`slicetest: unknown container "${name}". Declared containers: ${[...this.containers.keys()].join(", ") || "(none)"}`);
|
|
235
236
|
return c;
|
|
236
237
|
},
|
|
237
|
-
trace: async (opts) => mask(buildTrace(this.http.
|
|
238
|
+
trace: async (opts) => mask(buildTrace(this.http.requests, this.stubs.values(), await this.db.changesSinceStart(), this.mailbox), opts),
|
|
238
239
|
diagram: () => this.diagram(),
|
|
239
240
|
get mail() {
|
|
240
241
|
if (!mailbox)
|
|
@@ -252,6 +253,34 @@ export class Runtime {
|
|
|
252
253
|
#processes() {
|
|
253
254
|
return [this.app, ...this.services.values()];
|
|
254
255
|
}
|
|
256
|
+
// Marked busy until afterScenario() sees `idle` answer 2xx, so a scenario that failed, or that Vitest
|
|
257
|
+
// timed out mid-wait, leaves its processes flagged and the next scenario restarts them.
|
|
258
|
+
#busy = new Set();
|
|
259
|
+
#queue = Promise.resolve();
|
|
260
|
+
/** Not concurrent: a scenario Vitest timed out leaves its beforeScenario() running, and a second would race it for `this.app`. */
|
|
261
|
+
#serial(fn) {
|
|
262
|
+
const run = this.#queue.then(fn);
|
|
263
|
+
this.#queue = run.catch(() => { });
|
|
264
|
+
return run;
|
|
265
|
+
}
|
|
266
|
+
#hooked(hook) {
|
|
267
|
+
const all = [
|
|
268
|
+
{ name: "app", proc: this.app, options: this.opts.app },
|
|
269
|
+
...[...this.services].map(([n, proc]) => ({ name: `services.${n}`, proc, options: this.opts.services[n] })),
|
|
270
|
+
];
|
|
271
|
+
return all.filter((p) => p.options[hook] && !p.proc.exited);
|
|
272
|
+
}
|
|
273
|
+
/** Every busy process fails the scenario, not only the first, and each is flagged for a restart. */
|
|
274
|
+
async #waitIdle(deadline) {
|
|
275
|
+
const hooked = this.#hooked("idle");
|
|
276
|
+
const results = await Promise.allSettled(hooked.map(async ({ name, proc, options }) => {
|
|
277
|
+
await pollIdle(proc, options.idle, `${name}.idle`, deadline);
|
|
278
|
+
this.#busy.delete(proc);
|
|
279
|
+
}));
|
|
280
|
+
const failed = results.flatMap((r) => (r.status === "rejected" ? [r.reason.message] : []));
|
|
281
|
+
if (failed.length > 0)
|
|
282
|
+
throw new Error(failed.join("\n"));
|
|
283
|
+
}
|
|
255
284
|
#usageDir;
|
|
256
285
|
/** Note which provider operations the stubs were called for, before their calls are cleared. */
|
|
257
286
|
#collectUsage() {
|
|
@@ -266,20 +295,30 @@ export class Runtime {
|
|
|
266
295
|
this.#used.set(name, used);
|
|
267
296
|
}
|
|
268
297
|
}
|
|
269
|
-
|
|
298
|
+
beforeScenario() {
|
|
299
|
+
return this.#serial(() => this.#before());
|
|
300
|
+
}
|
|
301
|
+
/** `deadline`: when the test's own timeout is about to end, so `idle` reports before Vitest does. */
|
|
302
|
+
afterScenario(deadline) {
|
|
303
|
+
return this.#serial(() => this.#after(deadline));
|
|
304
|
+
}
|
|
305
|
+
async #before() {
|
|
270
306
|
this.#collectUsage();
|
|
271
|
-
// A
|
|
272
|
-
for (const
|
|
273
|
-
if (
|
|
274
|
-
|
|
275
|
-
await service.stop();
|
|
276
|
-
this.services.set(name, await App.start(this.opts.services[name], this.opts.root, this.vars, `service.${name}`, service.port));
|
|
307
|
+
// A failed scenario skipped afterScenario(). One look, not a wait: it already failed, and a restart ends the work.
|
|
308
|
+
for (const { proc, options } of this.#hooked("idle")) {
|
|
309
|
+
if (this.#busy.has(proc) && (await probeIdle(proc, options.idle)))
|
|
310
|
+
this.#busy.delete(proc);
|
|
277
311
|
}
|
|
278
|
-
|
|
312
|
+
// Stop before the reset, start after. Not start-then-reset: the old process could still write into the
|
|
313
|
+
// fresh database, and the new one would boot against stale rows.
|
|
314
|
+
const restartService = (name, service) => service.exited || this.#busy.has(service) || this.opts.services[name].restart === "scenario";
|
|
315
|
+
const services = [...this.services].filter(([name, service]) => restartService(name, service));
|
|
316
|
+
const restartApp = !!this.app.exited || this.#busy.has(this.app) || this.opts.app.restart === "scenario";
|
|
317
|
+
if (restartApp)
|
|
279
318
|
await this.app.stop();
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
319
|
+
for (const [, service] of [...services].reverse())
|
|
320
|
+
await service.stop();
|
|
321
|
+
this.#busy.clear();
|
|
283
322
|
this.#contract = [];
|
|
284
323
|
await this.db.reset();
|
|
285
324
|
for (const stub of this.stubs.values())
|
|
@@ -293,13 +332,27 @@ export class Runtime {
|
|
|
293
332
|
this.interceptor?.blocked.clear();
|
|
294
333
|
await Promise.all([...this.containers.values()].map((c) => c.reset()));
|
|
295
334
|
this.http.reset();
|
|
335
|
+
for (const [name, old] of services) {
|
|
336
|
+
this.services.set(name, await App.start(this.opts.services[name], this.opts.root, this.vars, `service.${name}`, old.port));
|
|
337
|
+
}
|
|
338
|
+
if (restartApp) {
|
|
339
|
+
this.app = await App.start(this.opts.app, this.opts.root, this.vars);
|
|
340
|
+
this.#http = this.#client();
|
|
341
|
+
}
|
|
296
342
|
for (const p of this.#processes())
|
|
297
343
|
p.beginScenario();
|
|
344
|
+
// Not before the start: a cache filled at boot would hold the pre-reset rows.
|
|
345
|
+
for (const { name, proc, options } of this.#hooked("reset")) {
|
|
346
|
+
await callHook(proc, options.reset, `${name}.reset`);
|
|
347
|
+
}
|
|
348
|
+
for (const { proc } of this.#hooked("idle"))
|
|
349
|
+
this.#busy.add(proc);
|
|
298
350
|
}
|
|
299
351
|
/** Failures that the scenario body can't see on its own. */
|
|
300
|
-
async
|
|
352
|
+
async #after(deadline) {
|
|
301
353
|
await Promise.all(this.#processes().map((p) => p.settle()));
|
|
302
354
|
this.#assertAlive();
|
|
355
|
+
await this.#waitIdle(deadline);
|
|
303
356
|
if (this.interceptor?.blocked.size)
|
|
304
357
|
throw new Error(blockedHint([...this.interceptor.blocked]));
|
|
305
358
|
const thrown = this.#handlerErrors();
|
|
@@ -340,12 +393,12 @@ export class Runtime {
|
|
|
340
393
|
#checkStubCall(name, spec, call) {
|
|
341
394
|
if (!call.matched)
|
|
342
395
|
return []; // already reported as an unmatched call
|
|
343
|
-
const req = { contentType: header(call.headers["content-type"]), body: call.json ?? call.body, query: call.query };
|
|
396
|
+
const req = { contentType: header(call.headers["content-type"]), body: call.json ?? call.body, query: call.query, headers: call.headers };
|
|
344
397
|
const out = spec.checkRequest(call.method, call.path, req).map((c) => `app → ${name}: ${c}`);
|
|
345
398
|
const res = call.response;
|
|
346
399
|
if (res) {
|
|
347
400
|
const body = res.body ? (parseJson(res.body) ?? res.body) : "";
|
|
348
|
-
const problems = spec.checkResponse(call.method, call.path, { status: res.status, contentType: res.headers["content-type"], body });
|
|
401
|
+
const problems = spec.checkResponse(call.method, call.path, { status: res.status, contentType: res.headers["content-type"], body, headers: Object.fromEntries(Object.entries(res.headers).map(([k, v]) => [k.toLowerCase(), v])) });
|
|
349
402
|
out.push(...problems.map((c) => `stub ${name} reply (the real service wouldn't answer this way): ${c}`));
|
|
350
403
|
}
|
|
351
404
|
return out;
|
|
@@ -375,7 +428,7 @@ export class Runtime {
|
|
|
375
428
|
/** The current scenario as a Mermaid sequence diagram. */
|
|
376
429
|
async diagram() {
|
|
377
430
|
const changes = this.opts.db.none ? undefined : await this.db.changesSinceStart().catch(() => undefined);
|
|
378
|
-
return sequenceDiagram(this.http.
|
|
431
|
+
return sequenceDiagram(this.http.requests, this.stubs.values(), changes, this.mailbox);
|
|
379
432
|
}
|
|
380
433
|
/**
|
|
381
434
|
* After a scenario: its diagram goes to the `SLICETEST_DIAGRAMS` directory (one Markdown page
|
|
@@ -511,8 +564,69 @@ async function ensureWorkerDatabase(engine, adminUrl, template, prefix) {
|
|
|
511
564
|
await admin.close();
|
|
512
565
|
}
|
|
513
566
|
}
|
|
567
|
+
const HOOK_TIMEOUT = 5000;
|
|
568
|
+
/** Not `new URL(path, base)` alone: `//host/x` and `/\host` would send the request to another host. */
|
|
569
|
+
function hookUrl(proc, hook) {
|
|
570
|
+
const url = new URL(hook.path, proc.url);
|
|
571
|
+
if (url.origin !== new URL(proc.url).origin)
|
|
572
|
+
throw new Error(`slicetest: hook path ${JSON.stringify(hook.path)} leaves the ${proc.label} (${url.origin}); use a path on the app`);
|
|
573
|
+
return url;
|
|
574
|
+
}
|
|
575
|
+
async function callHook(proc, hook, where) {
|
|
576
|
+
const method = (hook.method ?? "POST").toUpperCase();
|
|
577
|
+
const what = `${method} ${hook.path}`;
|
|
578
|
+
let res;
|
|
579
|
+
try {
|
|
580
|
+
// Not following redirects: a 3xx must not move the request off the app.
|
|
581
|
+
res = await fetch(hookUrl(proc, hook), { method, redirect: "manual", signal: AbortSignal.timeout(hook.timeout ?? HOOK_TIMEOUT) });
|
|
582
|
+
}
|
|
583
|
+
catch (e) {
|
|
584
|
+
throw new Error(`slicetest: ${where}: ${what} failed: ${e.message}. The ${proc.label}'s in-process state wasn't reset, so this scenario could see the previous one's. Check that the ${proc.label} serves ${hook.path}.`);
|
|
585
|
+
}
|
|
586
|
+
const body = (await res.text().catch(() => "")).slice(0, 300);
|
|
587
|
+
if (res.status >= 300) {
|
|
588
|
+
throw new Error(`slicetest: ${where}: ${what} answered ${res.status}${body ? `: ${body}` : ""}. The ${proc.label}'s in-process state wasn't reset, so this scenario could see the previous one's.`);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
/** One request: is the process idle right now. */
|
|
592
|
+
async function probeIdle(proc, hook) {
|
|
593
|
+
try {
|
|
594
|
+
const res = await fetch(hookUrl(proc, hook), { method: (hook.method ?? "GET").toUpperCase(), redirect: "manual", signal: AbortSignal.timeout(1000) });
|
|
595
|
+
await res.body?.cancel();
|
|
596
|
+
return res.ok;
|
|
597
|
+
}
|
|
598
|
+
catch {
|
|
599
|
+
return false;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
/** Poll `app.idle` until it answers 2xx, for `timeout` or until `deadline` (epoch ms), whichever is first. */
|
|
603
|
+
async function pollIdle(proc, hook, where, deadline) {
|
|
604
|
+
const timeout = hook.timeout ?? HOOK_TIMEOUT;
|
|
605
|
+
const start = Date.now();
|
|
606
|
+
const limit = Math.min(start + timeout, deadline ?? Infinity);
|
|
607
|
+
const method = (hook.method ?? "GET").toUpperCase();
|
|
608
|
+
let last = "no answer";
|
|
609
|
+
for (;;) {
|
|
610
|
+
try {
|
|
611
|
+
const res = await fetch(hookUrl(proc, hook), { method, redirect: "manual", signal: AbortSignal.timeout(Math.max(100, Math.min(1000, limit - Date.now()))) });
|
|
612
|
+
const body = (await res.text().catch(() => "")).trim().slice(0, 200);
|
|
613
|
+
if (res.ok)
|
|
614
|
+
return;
|
|
615
|
+
last = `${res.status}${body ? ` ${body}` : ""}`;
|
|
616
|
+
}
|
|
617
|
+
catch (e) {
|
|
618
|
+
last = e.message;
|
|
619
|
+
}
|
|
620
|
+
if (Date.now() >= limit)
|
|
621
|
+
break;
|
|
622
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
623
|
+
}
|
|
624
|
+
const cut = limit < start + timeout ? `; the test's own timeout left no more (raise it, or lower ${where}.timeout)` : "";
|
|
625
|
+
throw new Error(`slicetest: ${where}: the ${proc.label} still had background work after ${Date.now() - start}ms (${method} ${hook.path} last answered: ${last})${cut}. ` +
|
|
626
|
+
`Left running, it would write into a later scenario, so the ${proc.label} is restarted before the next one. Finish the work before the scenario ends, wait for it in the scenario, or raise the timeout.`);
|
|
627
|
+
}
|
|
514
628
|
function message(res) {
|
|
515
|
-
return { status: res.status, contentType: res.headers.get("content-type") ?? undefined, body: res.json ?? res.text };
|
|
629
|
+
return { status: res.status, contentType: res.headers.get("content-type") ?? undefined, body: res.json ?? res.text, headers: Object.fromEntries(res.headers) };
|
|
516
630
|
}
|
|
517
631
|
function header(v) {
|
|
518
632
|
return Array.isArray(v) ? v[0] : v;
|
package/dist/scenario.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { clearScenarioDeadline, setScenarioDeadline } from "./deadline.js";
|
|
1
2
|
import { test } from "vitest";
|
|
2
3
|
// Kept on globalThis so it survives the setup file and the test file loading
|
|
3
4
|
// separate copies of this module (e.g. one from dist, one through an alias).
|
|
@@ -35,9 +36,17 @@ function define(register) {
|
|
|
35
36
|
console.error(`--- slicetest ---\n${await runtime.diagnostics()}\n-----------------`);
|
|
36
37
|
await runtime.reportDiagram(file, task.name, true);
|
|
37
38
|
});
|
|
39
|
+
const started = Date.now();
|
|
38
40
|
await runtime.beforeScenario();
|
|
39
|
-
|
|
40
|
-
|
|
41
|
+
setScenarioDeadline(started, task.timeout);
|
|
42
|
+
try {
|
|
43
|
+
await body(runtime.context());
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
clearScenarioDeadline();
|
|
47
|
+
}
|
|
48
|
+
// Leaves a margin for the checks after the wait, so `idle` reports before Vitest's timeout does.
|
|
49
|
+
await runtime.afterScenario(task.timeout ? started + task.timeout - 500 : undefined);
|
|
41
50
|
await runtime.reportDiagram(file, task.name, false);
|
|
42
51
|
}, timeout);
|
|
43
52
|
};
|