slicetest 0.1.0 → 0.3.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.
@@ -0,0 +1,337 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { Ajv } from "ajv";
3
+ import { Ajv2020 } from "ajv/dist/2020.js";
4
+ import addFormatsModule from "ajv-formats";
5
+ import { parse } from "yaml";
6
+ const addFormats = (addFormatsModule.default ?? addFormatsModule);
7
+ const METHODS = ["get", "put", "post", "delete", "options", "head", "patch", "trace"];
8
+ /**
9
+ * An OpenAPI 3.0 / 3.1 document, used to check that real traffic matches it:
10
+ * the app's responses against the app's own spec, and the app's calls to a
11
+ * stubbed service (and the stub's canned replies) against that service's spec.
12
+ */
13
+ export class OpenApiSpec {
14
+ file;
15
+ doc;
16
+ #ajv;
17
+ #validators = new Map();
18
+ #ops = [];
19
+ #basePaths;
20
+ #documentOrder = [];
21
+ constructor(file, doc) {
22
+ this.file = file;
23
+ this.doc = doc;
24
+ const v31 = String(doc.openapi ?? "").startsWith("3.1");
25
+ this.#ajv = v31 ? new Ajv2020({ strict: false, allErrors: true }) : new Ajv({ strict: false, allErrors: true });
26
+ addFormats(this.#ajv);
27
+ for (const f of ["int32", "int64", "float", "double", "byte", "binary", "password"])
28
+ this.#ajv.addFormat(f, true);
29
+ this.#ajv.addSchema(v31 ? doc : nullableToType(structuredClone(doc)), "spec");
30
+ for (const [template, item] of Object.entries(doc.paths ?? {})) {
31
+ for (const method of METHODS)
32
+ if (item[method])
33
+ this.#documentOrder.push({ template, method, op: item[method] });
34
+ }
35
+ // Concrete segments before templated ones, so /polls/new wins over /polls/{id}.
36
+ const templates = Object.keys(doc.paths ?? {}).sort((a, b) => a.split("{").length - b.split("{").length);
37
+ for (const template of templates) {
38
+ const re = new RegExp(`^${template.split(/\{[^}]+\}/).map(escape).join("[^/]+")}/?$`);
39
+ for (const method of METHODS) {
40
+ const op = doc.paths[template][method];
41
+ if (op)
42
+ this.#ops.push({ re, op: { template, method, op } });
43
+ }
44
+ }
45
+ // Paths in the spec are relative to the server URL, e.g. /v1 for https://api.example.com/v1.
46
+ this.#basePaths = [
47
+ ...new Set((doc.servers ?? [])
48
+ .map((s) => new URL(s.url ?? "/", "http://x").pathname.replace(/\/$/, ""))
49
+ .filter(Boolean)),
50
+ ];
51
+ }
52
+ /** `file` is read; `label` (default: `file`) is how messages refer to it. */
53
+ static async load(file, label = file) {
54
+ let doc;
55
+ try {
56
+ doc = parse(await readFile(file, "utf8"));
57
+ }
58
+ catch (e) {
59
+ throw new Error(`slicetest: can't read OpenAPI spec ${label}: ${e.message}`);
60
+ }
61
+ if (!doc || typeof doc !== "object" || !("openapi" in doc)) {
62
+ throw new Error(`slicetest: ${label} is not an OpenAPI 3 document (no "openapi" field)`);
63
+ }
64
+ return new OpenApiSpec(label, doc);
65
+ }
66
+ find(method, path) {
67
+ const m = method.toLowerCase();
68
+ for (const candidate of [path, ...this.#basePaths.filter((b) => path.startsWith(b)).map((b) => path.slice(b.length) || "/")]) {
69
+ const hit = this.#ops.find((o) => o.op.method === m && o.re.test(candidate));
70
+ if (hit)
71
+ return hit.op;
72
+ }
73
+ return undefined;
74
+ }
75
+ /** Problems with a response to `method path`; empty when it matches the spec. */
76
+ /** The documented response (`200`, `4XX`, `default`) that `status` falls under, for coverage. */
77
+ responseKey(method, path, status) {
78
+ const found = this.find(method, path);
79
+ if (!found)
80
+ return undefined;
81
+ const key = pickResponse(found.op.responses ?? {}, status);
82
+ return key && `${method.toUpperCase()} ${found.template} ${key}`;
83
+ }
84
+ /** Every documented response as `METHOD /template key`, in document order. */
85
+ responseKeys() {
86
+ return this.#documentOrder.flatMap(({ template, method, op }) => Object.keys(op.responses ?? {}).map((key) => `${method.toUpperCase()} ${template} ${key}`));
87
+ }
88
+ checkResponse(method, path, res) {
89
+ const found = this.find(method, path);
90
+ if (!found)
91
+ return [`${method} ${path} is not in ${this.file}`];
92
+ const { template, op } = found;
93
+ const status = String(res.status);
94
+ const responses = op.responses ?? {};
95
+ const key = pickResponse(responses, res.status ?? 0);
96
+ if (!key)
97
+ return [`${method} ${template} responded ${status}, which ${this.file} doesn't document (documented: ${Object.keys(responses).join(", ") || "none"})`];
98
+ const [response, at] = this.#resolve(responses[key], ["paths", template, found.method, "responses", key]);
99
+ return this.#checkContent(response?.content, res, [...at, "content"], `${method} ${template} → ${status}`);
100
+ }
101
+ /**
102
+ * A response the real service could send to `method path`: the lowest
103
+ * documented 2xx, with its example if the spec has one, else a value built
104
+ * from its schema. Undefined when the operation isn't in the spec.
105
+ */
106
+ exampleResponse(method, path) {
107
+ const found = this.find(method, path);
108
+ if (!found)
109
+ return undefined;
110
+ const responses = found.op.responses ?? {};
111
+ const key = Object.keys(responses)
112
+ .filter((k) => /^2\d\d$/.test(k))
113
+ .sort()[0] ?? Object.keys(responses).find((k) => /^2xx$/i.test(k));
114
+ if (!key)
115
+ return undefined;
116
+ const status = key.length === 3 && /^\d+$/.test(key) ? Number(key) : 200;
117
+ const response = this.#deref(responses[key]);
118
+ const content = response?.content ?? {};
119
+ const media = Object.keys(content).find(isJson) ?? Object.keys(content)[0];
120
+ if (!media)
121
+ return { status };
122
+ const m = content[media] ?? {};
123
+ const named = m.examples && Object.values(m.examples)[0];
124
+ const body = m.example !== undefined
125
+ ? m.example
126
+ : named !== undefined
127
+ ? (this.#deref(named)?.value ?? null)
128
+ : this.#sample(m.schema);
129
+ return { status, headers: { "content-type": media === "*/*" ? "application/json" : media }, body: isJson(media) ? body : String(body ?? "") };
130
+ }
131
+ /**
132
+ * Every operation with what it takes to call it: sample values for its
133
+ * required path and query parameters and for its JSON request body, and the
134
+ * properties of its first 2xx JSON response. Used by `slicetest gen`.
135
+ */
136
+ operations() {
137
+ return this.#documentOrder.map(({ template, method, op }) => {
138
+ const params = [...(this.doc.paths[template].parameters ?? []), ...(op.parameters ?? [])].map((p) => this.#deref(p)).filter(Boolean);
139
+ const sample = (p) => (p.example !== undefined ? p.example : p.schema ? this.#sample(p.schema) : "1");
140
+ const pathParams = Object.fromEntries(params.filter((p) => p.in === "path").map((p) => [p.name, sample(p)]));
141
+ const query = Object.fromEntries(params.filter((p) => p.in === "query" && p.required).map((p) => [p.name, sample(p)]));
142
+ const body = this.#deref(op.requestBody);
143
+ const media = body?.content && Object.keys(body.content).find(isJson);
144
+ const m = media ? body.content[media] : undefined;
145
+ const json = m ? (m.example !== undefined ? m.example : this.#sample(m.schema)) : undefined;
146
+ const responses = Object.keys(op.responses ?? {});
147
+ const ok = responses.filter((k) => /^2/.test(k)).sort()[0];
148
+ const okContent = ok ? this.#deref(op.responses[ok])?.content : undefined;
149
+ const okMedia = okContent && Object.keys(okContent).find(isJson);
150
+ const okSchema = okMedia ? this.#deref(okContent[okMedia]?.schema) : undefined;
151
+ return {
152
+ method: method.toUpperCase(),
153
+ template,
154
+ summary: op.summary ?? op.operationId,
155
+ pathParams,
156
+ query,
157
+ json: json ?? undefined,
158
+ responses,
159
+ createdFields: okSchema?.properties ? Object.keys(okSchema.properties) : [],
160
+ };
161
+ });
162
+ }
163
+ /** A value that satisfies `schema` (as far as a simple walk can): examples, defaults, enums, then types. */
164
+ #sample(schema, depth = 0) {
165
+ const s = this.#deref(schema);
166
+ if (!s || typeof s !== "object" || depth > 8)
167
+ return null;
168
+ if (s.example !== undefined)
169
+ return s.example;
170
+ if (Array.isArray(s.examples) && s.examples.length)
171
+ return s.examples[0];
172
+ if (s.default !== undefined)
173
+ return s.default;
174
+ if (s.const !== undefined)
175
+ return s.const;
176
+ if (Array.isArray(s.enum) && s.enum.length)
177
+ return s.enum[0];
178
+ if (Array.isArray(s.allOf)) {
179
+ return Object.assign({}, ...s.allOf.map((part) => this.#sample(part, depth + 1)).filter((v) => v && typeof v === "object"));
180
+ }
181
+ const first = s.oneOf?.[0] ?? s.anyOf?.[0];
182
+ if (first)
183
+ return this.#sample(first, depth + 1);
184
+ const type = Array.isArray(s.type) ? s.type.find((t) => t !== "null") : s.type;
185
+ switch (type ?? (s.properties ? "object" : s.items ? "array" : undefined)) {
186
+ case "object":
187
+ return Object.fromEntries(Object.entries(s.properties ?? {}).map(([k, v]) => [k, this.#sample(v, depth + 1)]));
188
+ case "array":
189
+ return Array.from({ length: Math.max(1, s.minItems ?? 1) }, () => this.#sample(s.items, depth + 1));
190
+ case "integer":
191
+ case "number":
192
+ return typeof s.minimum === "number" ? s.minimum : typeof s.exclusiveMinimum === "number" ? s.exclusiveMinimum + 1 : 0;
193
+ case "boolean":
194
+ return true;
195
+ case "string":
196
+ return sampleString(s);
197
+ default:
198
+ return null;
199
+ }
200
+ }
201
+ /** Problems with a request the app sent to `method path`. */
202
+ checkRequest(method, path, req) {
203
+ const found = this.find(method, path);
204
+ if (!found)
205
+ return [`${method} ${path} is not in ${this.file}`];
206
+ const { template, op } = found;
207
+ const errors = [];
208
+ const params = [...(this.doc.paths[template].parameters ?? []), ...(op.parameters ?? [])].map((p) => this.#deref(p));
209
+ for (const p of params) {
210
+ if (p?.in === "query" && p.required && !req.query?.has(p.name))
211
+ errors.push(`${method} ${template}: required query parameter "${p.name}" is missing`);
212
+ }
213
+ const [body, at] = this.#resolve(op.requestBody, ["paths", template, found.method, "requestBody"]);
214
+ if (!body)
215
+ return errors;
216
+ if (req.body === "" || req.body === undefined) {
217
+ if (body.required)
218
+ errors.push(`${method} ${template}: the request body is required`);
219
+ return errors;
220
+ }
221
+ return [...errors, ...this.#checkContent(body.content, req, [...at, "content"], `${method} ${template} request`)];
222
+ }
223
+ #checkContent(content, msg, pointer, label) {
224
+ if (!content || Object.keys(content).length === 0)
225
+ return [];
226
+ const type = (msg.contentType ?? "").split(";")[0].trim().toLowerCase();
227
+ // Without a content-type (e.g. a stub replying with a bare string) there's nothing to hold the body to.
228
+ if (!type)
229
+ return [];
230
+ const media = Object.keys(content).find((k) => k.toLowerCase() === type) ??
231
+ Object.keys(content).find((k) => k.endsWith("/*") && type.startsWith(k.slice(0, -1))) ??
232
+ Object.keys(content).find((k) => k === "*/*");
233
+ if (!media)
234
+ return [`${label}: content-type "${type || "(none)"}" is not one of ${Object.keys(content).join(", ")}`];
235
+ if (!content[media]?.schema || !isJson(media))
236
+ return [];
237
+ if (typeof msg.body === "string")
238
+ return [`${label}: body is not valid JSON`];
239
+ const validate = this.#validator([...pointer, media, "schema"]);
240
+ if (validate(msg.body))
241
+ return [];
242
+ return (validate.errors ?? []).slice(0, 5).map((e) => `${label}: ${e.instancePath || "body"} ${e.message}${e.params && "additionalProperty" in e.params ? ` "${e.params.additionalProperty}"` : ""}`);
243
+ }
244
+ #validator(pointer) {
245
+ const ref = `spec#/${pointer.map((p) => p.replace(/~/g, "~0").replace(/\//g, "~1")).join("/")}`;
246
+ let v = this.#validators.get(ref);
247
+ if (!v) {
248
+ v = this.#ajv.compile({ $ref: ref });
249
+ this.#validators.set(ref, v);
250
+ }
251
+ return v;
252
+ }
253
+ /** Follow a local `$ref` such as `#/components/responses/NotFound`. */
254
+ #deref(node) {
255
+ return this.#resolve(node, [])[0];
256
+ }
257
+ /** The node behind any `$ref`s, and its location in the document (for compiling schemas under it). */
258
+ #resolve(node, at, depth = 0) {
259
+ if (!node || typeof node.$ref !== "string" || !node.$ref.startsWith("#/") || depth > 10)
260
+ return [node, at];
261
+ const pointer = node.$ref
262
+ .slice(2)
263
+ .split("/")
264
+ .map((p) => p.replace(/~1/g, "/").replace(/~0/g, "~"));
265
+ const target = pointer.reduce((cur, key) => cur?.[key], this.doc);
266
+ return this.#resolve(target, pointer, depth + 1);
267
+ }
268
+ }
269
+ const FORMATS = {
270
+ "date-time": "2026-01-01T00:00:00Z",
271
+ date: "2026-01-01",
272
+ time: "00:00:00Z",
273
+ email: "user@example.com",
274
+ uri: "https://example.com",
275
+ url: "https://example.com",
276
+ uuid: "00000000-0000-4000-8000-000000000000",
277
+ hostname: "example.com",
278
+ ipv4: "192.0.2.1",
279
+ ipv6: "2001:db8::1",
280
+ };
281
+ function sampleString(s) {
282
+ const base = (s.format && FORMATS[s.format]) ?? "string";
283
+ return base.length >= (s.minLength ?? 0) ? base : base.padEnd(s.minLength, "x");
284
+ }
285
+ function pickResponse(responses, status) {
286
+ const s = String(status);
287
+ return [s, `${s[0]}XX`, `${s[0]}xx`, "default"].find((k) => k in responses);
288
+ }
289
+ /**
290
+ * Coverage report: which documented responses the scenarios produced.
291
+ * `hits` are `responseKey()` values collected from every worker.
292
+ */
293
+ export function formatCoverage(spec, hits) {
294
+ const keys = spec.responseKeys();
295
+ const byOp = new Map();
296
+ for (const k of keys) {
297
+ const i = k.lastIndexOf(" ");
298
+ const op = k.slice(0, i);
299
+ byOp.set(op, [...(byOp.get(op) ?? []), k.slice(i + 1)]);
300
+ }
301
+ const width = Math.max(0, ...[...byOp.keys()].map((op) => op.indexOf(" ") > -1 ? op.length - op.indexOf(" ") - 1 : 0));
302
+ const lines = [...byOp].map(([op, statuses]) => {
303
+ const i = op.indexOf(" ");
304
+ const marks = statuses.map((s) => `${s} ${hits.has(`${op} ${s}`) ? "✓" : "✗"}`).join(" ");
305
+ return ` ${op.slice(0, i).padEnd(6)} ${op.slice(i + 1).padEnd(width)} ${marks}`;
306
+ });
307
+ const covered = keys.filter((k) => hits.has(k)).length;
308
+ const percent = keys.length ? Math.round((covered / keys.length) * 100) : 100;
309
+ return { covered, total: keys.length, percent, text: `slicetest: OpenAPI coverage (${spec.file}): ${covered}/${keys.length} documented responses (${percent}%)\n${lines.join("\n")}` };
310
+ }
311
+ function isJson(media) {
312
+ return /[/+]json$/i.test(media) || media === "*/*";
313
+ }
314
+ function escape(s) {
315
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
316
+ }
317
+ /** OpenAPI 3.0's `nullable: true` → JSON Schema's `type: [T, "null"]`. */
318
+ function nullableToType(node) {
319
+ if (Array.isArray(node))
320
+ return node.map(nullableToType);
321
+ if (!node || typeof node !== "object")
322
+ return node;
323
+ for (const [k, v] of Object.entries(node))
324
+ node[k] = nullableToType(v);
325
+ if (node.nullable === true) {
326
+ if (node.enum && !node.enum.includes(null))
327
+ node.enum = [...node.enum, null];
328
+ if (typeof node.type === "string")
329
+ node.type = [node.type, "null"];
330
+ else if (!node.type) {
331
+ const inner = { ...node };
332
+ delete inner.nullable;
333
+ return { anyOf: [inner, { type: "null" }] };
334
+ }
335
+ }
336
+ return node;
337
+ }
@@ -6,6 +6,8 @@ declare module "vitest" {
6
6
  adminUrl: string;
7
7
  template: string;
8
8
  prefix: string;
9
+ coverageDir?: string;
10
+ recordDir?: string;
9
11
  };
10
12
  }
11
13
  }
@@ -0,0 +1,43 @@
1
+ import type { RecordedCall, StubResponse } from "./stub.js";
2
+ /** One recorded exchange with the real service, as stored in the recordings file. */
3
+ export interface Recording {
4
+ request: {
5
+ method: string;
6
+ path: string;
7
+ query?: Record<string, string>;
8
+ json?: unknown;
9
+ body?: string;
10
+ };
11
+ response: {
12
+ status: number;
13
+ headers?: Record<string, string>;
14
+ json?: unknown;
15
+ body?: string;
16
+ };
17
+ }
18
+ /**
19
+ * Replays recorded exchanges for calls no route matches and, in record mode,
20
+ * forwards the calls it has no recording for to the real service and records
21
+ * the answer. Identical requests replay their recordings in order within a
22
+ * scenario; the last one repeats.
23
+ */
24
+ export declare class Recorder {
25
+ #private;
26
+ readonly name: string;
27
+ readonly file: string;
28
+ readonly upstream: string;
29
+ readonly recording: boolean;
30
+ private constructor();
31
+ static load(name: string, file: string, upstream: string, recording: boolean): Promise<Recorder>;
32
+ /** What recording a call gets, or undefined to leave it unanswered. */
33
+ answer(call: RecordedCall): Promise<StubResponse | undefined>;
34
+ /** Why a call went unanswered, for the stub's 501 reply. */
35
+ hint(): string;
36
+ /** Start of a scenario: identical requests replay from their first recording again. */
37
+ reset(): void;
38
+ /** Recordings made by this worker, to be merged into the file when the run ends. */
39
+ added(): Recording[];
40
+ }
41
+ export declare function readRecordings(file: string): Promise<Recording[]>;
42
+ /** Append new recordings to `file`, skipping exact duplicates, keeping the order they were made in. */
43
+ export declare function mergeRecordings(file: string, upstream: string, added: Recording[]): Promise<void>;
@@ -0,0 +1,148 @@
1
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { isDeepStrictEqual } from "node:util";
4
+ import YAML from "yaml";
5
+ /**
6
+ * Response headers worth keeping. Everything else (dates, cookies, rate-limit
7
+ * counters, request ids) is noise in a committed file, or a secret.
8
+ */
9
+ const KEPT_HEADERS = ["content-type", "location", "retry-after", "link", "etag"];
10
+ /** Request headers not forwarded to the real service. */
11
+ const HOP_HEADERS = ["host", "connection", "content-length", "accept-encoding", "transfer-encoding", "keep-alive"];
12
+ /**
13
+ * Replays recorded exchanges for calls no route matches and, in record mode,
14
+ * forwards the calls it has no recording for to the real service and records
15
+ * the answer. Identical requests replay their recordings in order within a
16
+ * scenario; the last one repeats.
17
+ */
18
+ export class Recorder {
19
+ name;
20
+ file;
21
+ upstream;
22
+ recording;
23
+ #entries;
24
+ #added = [];
25
+ #seen = new Map();
26
+ constructor(name, file, upstream, recording, entries) {
27
+ this.name = name;
28
+ this.file = file;
29
+ this.upstream = upstream;
30
+ this.recording = recording;
31
+ this.#entries = entries;
32
+ }
33
+ static async load(name, file, upstream, recording) {
34
+ return new Recorder(name, file, upstream, recording, await readRecordings(file));
35
+ }
36
+ /** What recording a call gets, or undefined to leave it unanswered. */
37
+ async answer(call) {
38
+ const request = requestOf(call);
39
+ const key = keyOf(request);
40
+ const matches = this.#entries.filter((e) => keyOf(e.request) === key);
41
+ const n = this.#seen.get(key) ?? 0;
42
+ this.#seen.set(key, n + 1);
43
+ if (matches.length > 0 && (!this.recording || n < matches.length))
44
+ return toResponse(matches[Math.min(n, matches.length - 1)]);
45
+ if (!this.recording)
46
+ return undefined;
47
+ const entry = { request, response: await this.#forward(call) };
48
+ this.#entries.push(entry);
49
+ this.#added.push(entry);
50
+ return toResponse(entry);
51
+ }
52
+ /** Why a call went unanswered, for the stub's 501 reply. */
53
+ hint() {
54
+ return `no recording in ${this.file} either; run with SLICETEST_RECORD=${this.name} to record it from ${this.upstream}`;
55
+ }
56
+ /** Start of a scenario: identical requests replay from their first recording again. */
57
+ reset() {
58
+ this.#seen.clear();
59
+ }
60
+ /** Recordings made by this worker, to be merged into the file when the run ends. */
61
+ added() {
62
+ return this.#added;
63
+ }
64
+ async #forward(call) {
65
+ const url = new URL(this.upstream);
66
+ // Keep a path prefix on the upstream (e.g. https://api.example.com/v2) in front of the call's path.
67
+ url.pathname = url.pathname.replace(/\/$/, "") + call.path;
68
+ url.search = call.query.toString();
69
+ const headers = new Headers();
70
+ for (const [k, v] of Object.entries(call.headers)) {
71
+ if (v !== undefined && !HOP_HEADERS.includes(k))
72
+ headers.set(k, Array.isArray(v) ? v.join(", ") : v);
73
+ }
74
+ let res;
75
+ try {
76
+ res = await fetch(url, { method: call.method, headers, body: ["GET", "HEAD"].includes(call.method) ? undefined : call.body });
77
+ }
78
+ catch (e) {
79
+ throw new Error(`slicetest: recording stub ${this.name}: ${call.method} ${url} failed: ${e.message}`);
80
+ }
81
+ const text = await res.text();
82
+ const kept = Object.fromEntries(KEPT_HEADERS.flatMap((h) => (res.headers.has(h) ? [[h, res.headers.get(h)]] : [])));
83
+ const json = parse(text);
84
+ return {
85
+ status: res.status,
86
+ ...(Object.keys(kept).length ? { headers: kept } : {}),
87
+ ...(json !== undefined ? { json } : text ? { body: text } : {}),
88
+ };
89
+ }
90
+ }
91
+ function requestOf(call) {
92
+ const query = Object.fromEntries([...call.query.entries()].sort(([a], [b]) => a.localeCompare(b)));
93
+ return {
94
+ method: call.method,
95
+ path: call.path,
96
+ ...(Object.keys(query).length ? { query } : {}),
97
+ ...(call.json !== undefined ? { json: call.json } : call.body ? { body: call.body } : {}),
98
+ };
99
+ }
100
+ function keyOf(r) {
101
+ return JSON.stringify([r.method.toUpperCase(), r.path, sortKeys(r.query ?? {}), sortKeys(r.json ?? null), r.body ?? ""]);
102
+ }
103
+ function sortKeys(v) {
104
+ if (Array.isArray(v))
105
+ return v.map(sortKeys);
106
+ if (v && typeof v === "object")
107
+ return Object.fromEntries(Object.entries(v).sort(([a], [b]) => a.localeCompare(b)).map(([k, x]) => [k, sortKeys(x)]));
108
+ return v;
109
+ }
110
+ function toResponse(e) {
111
+ return { status: e.response.status, headers: e.response.headers, body: e.response.json !== undefined ? e.response.json : (e.response.body ?? "") };
112
+ }
113
+ function parse(text) {
114
+ if (!text)
115
+ return undefined;
116
+ try {
117
+ return JSON.parse(text);
118
+ }
119
+ catch {
120
+ return undefined;
121
+ }
122
+ }
123
+ export async function readRecordings(file) {
124
+ let text;
125
+ try {
126
+ text = await readFile(file, "utf8");
127
+ }
128
+ catch {
129
+ return [];
130
+ }
131
+ const doc = YAML.parse(text);
132
+ if (doc == null)
133
+ return [];
134
+ if (!Array.isArray(doc) || !doc.every((e) => e?.request?.method && e?.request?.path && typeof e?.response?.status === "number")) {
135
+ throw new Error(`slicetest: ${file} is not a recordings file: expected a list of { request: { method, path }, response: { status } }`);
136
+ }
137
+ return doc;
138
+ }
139
+ /** Append new recordings to `file`, skipping exact duplicates, keeping the order they were made in. */
140
+ export async function mergeRecordings(file, upstream, added) {
141
+ const entries = await readRecordings(file);
142
+ for (const e of added)
143
+ if (!entries.some((x) => isDeepStrictEqual(x, e)))
144
+ entries.push(e);
145
+ await mkdir(path.dirname(file), { recursive: true });
146
+ const header = `# Recorded by slicetest from ${upstream}. Review before committing: request bodies are stored as sent.\n`;
147
+ await writeFile(file, header + YAML.stringify(entries, { lineWidth: 0 }));
148
+ }
package/dist/runtime.d.ts CHANGED
@@ -1,34 +1,53 @@
1
1
  import { App } from "./app.js";
2
2
  import type { ResolvedOptions } from "./config.js";
3
+ import { Dependency } from "./containers.js";
3
4
  import { Db } from "./db.js";
4
5
  import { HttpClient } from "./http.js";
5
6
  import { Stub } from "./stub.js";
7
+ import { type MaskOptions, type Trace } from "./trace.js";
6
8
  export interface ScenarioContext {
7
9
  http: HttpClient;
8
10
  db: Db;
9
11
  stub: (name: string) => Stub;
10
12
  app: App;
13
+ /** A process from the `services` option: its `url`, `logs()` and `waitForLog()`. */
14
+ service: (name: string) => App;
15
+ /** A container from the `containers` option: its `host`, `port`, `address` and `exec()`. */
16
+ container: (name: string) => Dependency;
17
+ /**
18
+ * What the scenario did so far: requests to the app, calls to stubs and database changes,
19
+ * with timestamps and UUIDs masked. `expect(await trace()).toMatchSnapshot()`.
20
+ */
21
+ trace: (opts?: MaskOptions) => Promise<Trace>;
11
22
  }
12
23
  /** Everything one test file needs: its own database, stub servers and app process. */
13
24
  export declare class Runtime {
14
25
  #private;
15
26
  app: App;
27
+ readonly services: Map<string, App>;
16
28
  readonly db: Db;
17
29
  readonly stubs: Map<string, Stub>;
18
30
  private readonly opts;
19
31
  private readonly vars;
32
+ private readonly specs;
33
+ private readonly coverageDir?;
34
+ private readonly recorders;
35
+ private readonly recordDir?;
36
+ readonly containers: Map<string, Dependency>;
20
37
  private constructor();
21
38
  get http(): HttpClient;
22
39
  static start(opts: ResolvedOptions, shared: {
23
40
  adminUrl: string;
24
41
  template: string;
25
42
  prefix: string;
43
+ coverageDir?: string;
44
+ recordDir?: string;
26
45
  }): Promise<Runtime>;
27
46
  context(): ScenarioContext;
28
47
  beforeScenario(): Promise<void>;
29
48
  /** Failures that the scenario body can't see on its own. */
30
49
  afterScenario(): Promise<void>;
31
50
  /** What happened during the current scenario, printed when it fails. */
32
- diagnostics(): string;
51
+ diagnostics(): Promise<string>;
33
52
  stop(): Promise<void>;
34
53
  }