slicetest 0.2.0 → 0.4.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 +188 -9
- package/dist/ci.d.ts +29 -0
- package/dist/ci.js +57 -0
- package/dist/cli.js +52 -3
- package/dist/config.d.ts +61 -8
- package/dist/config.js +46 -2
- package/dist/containers.d.ts +22 -0
- package/dist/containers.js +59 -0
- package/dist/doctor.d.ts +22 -0
- package/dist/doctor.js +190 -0
- package/dist/drivers/driver.d.ts +2 -0
- package/dist/drivers/index.d.ts +2 -2
- package/dist/drivers/index.js +13 -2
- package/dist/drivers/mysql.d.ts +29 -0
- package/dist/drivers/mysql.js +270 -0
- package/dist/drivers/sqlite.d.ts +23 -0
- package/dist/drivers/sqlite.js +203 -0
- package/dist/gen.d.ts +24 -0
- package/dist/gen.js +116 -0
- package/dist/global-setup.js +53 -5
- package/dist/http.d.ts +6 -0
- package/dist/http.js +14 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +1 -0
- package/dist/init.d.ts +2 -0
- package/dist/init.js +180 -3
- package/dist/mail.d.ts +58 -0
- package/dist/mail.js +299 -0
- package/dist/matchers.d.ts +4 -0
- package/dist/matchers.js +33 -0
- package/dist/openapi.d.ts +19 -0
- package/dist/openapi.js +43 -1
- package/dist/provided.d.ts +2 -0
- package/dist/record-cli.d.ts +6 -0
- package/dist/record-cli.js +93 -0
- package/dist/record-session.d.ts +1 -0
- package/dist/record-session.js +10 -0
- package/dist/record.d.ts +46 -0
- package/dist/record.js +201 -0
- package/dist/recording.d.ts +43 -0
- package/dist/recording.js +148 -0
- package/dist/runtime.d.ts +17 -0
- package/dist/runtime.js +71 -6
- package/dist/stub.d.ts +1 -1
- package/dist/stub.js +14 -3
- package/dist/trace.d.ts +43 -0
- package/dist/trace.js +59 -0
- package/dist/vitest.js +5 -2
- package/dist/yaml-runtime.d.ts +1 -0
- package/dist/yaml-runtime.js +58 -13
- package/dist/yaml.d.ts +30 -1
- package/dist/yaml.js +41 -5
- package/package.json +18 -4
- package/schema/scenario.schema.json +173 -1
package/dist/record.js
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { writeFile } from "node:fs/promises";
|
|
3
|
+
import http from "node:http";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { stringify } from "yaml";
|
|
6
|
+
import { mask } from "./trace.js";
|
|
7
|
+
export const SESSION_ENV = "SLICETEST_RECORD_SESSION";
|
|
8
|
+
/** Requests a browser makes on its own, not part of what the scenario is about. */
|
|
9
|
+
const ASSET = /\.(ico|png|jpe?g|gif|webp|avif|svg|css|js|mjs|map|woff2?|ttf|eot)(\?|$)/i;
|
|
10
|
+
/** Hop-by-hop headers, and the ones fetch rewrites. */
|
|
11
|
+
const DROP_REQUEST = new Set(["host", "connection", "keep-alive", "transfer-encoding", "upgrade", "proxy-connection", "content-length", "accept-encoding"]);
|
|
12
|
+
const DROP_RESPONSE = new Set(["connection", "keep-alive", "transfer-encoding", "content-encoding", "content-length"]);
|
|
13
|
+
export async function runSession(ctx, session) {
|
|
14
|
+
const exchanges = [];
|
|
15
|
+
const target = ctx.app.url;
|
|
16
|
+
const proxy = http.createServer(async (req, res) => {
|
|
17
|
+
const chunks = [];
|
|
18
|
+
for await (const c of req)
|
|
19
|
+
chunks.push(c);
|
|
20
|
+
const body = Buffer.concat(chunks);
|
|
21
|
+
const headers = new Headers();
|
|
22
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
23
|
+
if (v === undefined || DROP_REQUEST.has(k))
|
|
24
|
+
continue;
|
|
25
|
+
for (const value of Array.isArray(v) ? v : [v])
|
|
26
|
+
headers.append(k, value);
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
const upstream = await fetch(new URL(req.url ?? "/", target), {
|
|
30
|
+
method: req.method,
|
|
31
|
+
headers,
|
|
32
|
+
body: body.length && req.method !== "GET" && req.method !== "HEAD" ? body : undefined,
|
|
33
|
+
redirect: "manual",
|
|
34
|
+
});
|
|
35
|
+
const payload = Buffer.from(await upstream.arrayBuffer());
|
|
36
|
+
const out = {};
|
|
37
|
+
upstream.headers.forEach((v, k) => {
|
|
38
|
+
if (!DROP_RESPONSE.has(k) && k !== "set-cookie")
|
|
39
|
+
out[k] = v;
|
|
40
|
+
});
|
|
41
|
+
const cookies = upstream.headers.getSetCookie();
|
|
42
|
+
if (cookies.length)
|
|
43
|
+
out["set-cookie"] = cookies;
|
|
44
|
+
res.writeHead(upstream.status, out).end(payload);
|
|
45
|
+
exchanges.push({
|
|
46
|
+
method: req.method ?? "GET",
|
|
47
|
+
path: req.url ?? "/",
|
|
48
|
+
contentType: req.headers["content-type"],
|
|
49
|
+
body: body.toString("utf8"),
|
|
50
|
+
status: upstream.status,
|
|
51
|
+
responseType: upstream.headers.get("content-type") ?? undefined,
|
|
52
|
+
response: payload.toString("utf8"),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
catch (e) {
|
|
56
|
+
res.writeHead(502, { "content-type": "text/plain" }).end(`slicetest record: the app didn't answer: ${e.message}\n`);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
await new Promise((resolve, reject) => {
|
|
60
|
+
proxy.once("error", reject);
|
|
61
|
+
proxy.listen(session.port, "127.0.0.1", resolve);
|
|
62
|
+
});
|
|
63
|
+
const url = `http://127.0.0.1:${proxy.address().port}`;
|
|
64
|
+
await writeFile(session.stateFile, JSON.stringify({ url, app: target }));
|
|
65
|
+
while (!existsSync(session.stopFile))
|
|
66
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
67
|
+
await new Promise((resolve) => proxy.close(() => resolve()));
|
|
68
|
+
proxy.closeAllConnections();
|
|
69
|
+
const calls = Object.fromEntries(session.stubs.map((name) => [name, ctx.stub(name).calls()]));
|
|
70
|
+
const changes = await ctx.db.changes();
|
|
71
|
+
const { yaml, summary } = buildScenario(exchanges, calls, changes, { name: path.basename(session.out).replace(/\.scenario\.ya?ml$/, "") });
|
|
72
|
+
await writeFile(session.out, yaml);
|
|
73
|
+
await writeFile(session.stateFile, JSON.stringify({ url, done: true, summary }));
|
|
74
|
+
}
|
|
75
|
+
/** The recorded session as a YAML scenario, with a comment header of what to review. */
|
|
76
|
+
export function buildScenario(exchanges, calls, changes, { name = "recorded session", now = new Date() } = {}) {
|
|
77
|
+
const kept = exchanges.filter((x) => !ASSET.test(x.path));
|
|
78
|
+
const steps = [];
|
|
79
|
+
const notes = [];
|
|
80
|
+
// Stubs first: the answers the services gave, so the replay doesn't need them.
|
|
81
|
+
const unanswered = [];
|
|
82
|
+
let stubRoutes = 0;
|
|
83
|
+
for (const [stub, list] of Object.entries(calls)) {
|
|
84
|
+
const groups = new Map();
|
|
85
|
+
for (const c of list) {
|
|
86
|
+
if (!c.response || c.response.status === 501) {
|
|
87
|
+
unanswered.push(`${stub}: ${c.method} ${c.path}`);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const key = `${c.method} ${c.path}`;
|
|
91
|
+
groups.set(key, [...(groups.get(key) ?? []), c]);
|
|
92
|
+
}
|
|
93
|
+
for (const [on, group] of groups) {
|
|
94
|
+
const replies = group.map((c) => ({ status: c.response.status, ...(c.response.body ? { body: parse(c.response.body, c.response.headers["content-type"]) } : {}) }));
|
|
95
|
+
const same = replies.every((r) => JSON.stringify(r) === JSON.stringify(replies[0]));
|
|
96
|
+
steps.push({ stub, on, ...(same ? { reply: replies[0] } : { sequence: replies }) });
|
|
97
|
+
stubRoutes++;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (unanswered.length)
|
|
101
|
+
notes.push(`These calls got no answer while recording (register a route, or give the stub an upstream / autoReply): ${unanswered.join(", ")}`);
|
|
102
|
+
// Values from earlier responses that later requests use (ids in paths, tokens in bodies) become captures.
|
|
103
|
+
const captured = new Map();
|
|
104
|
+
kept.forEach((x, i) => {
|
|
105
|
+
let reqPath = x.path;
|
|
106
|
+
let reqBody = x.body;
|
|
107
|
+
for (const [value, variable] of captured) {
|
|
108
|
+
reqPath = reqPath.split(value).join(`{{${variable}}}`);
|
|
109
|
+
reqBody = reqBody.split(value).join(`{{${variable}}}`);
|
|
110
|
+
}
|
|
111
|
+
const [method, pathOnly] = [x.method, reqPath];
|
|
112
|
+
const step = { request: `${method} ${pathOnly}` };
|
|
113
|
+
if (x.body) {
|
|
114
|
+
if (/json/i.test(x.contentType ?? ""))
|
|
115
|
+
step.json = parse(reqBody, "application/json");
|
|
116
|
+
else if (/x-www-form-urlencoded/i.test(x.contentType ?? ""))
|
|
117
|
+
step.form = Object.fromEntries(new URLSearchParams(reqBody));
|
|
118
|
+
else
|
|
119
|
+
step.body = reqBody;
|
|
120
|
+
}
|
|
121
|
+
const expectation = { status: x.status };
|
|
122
|
+
const json = /json/i.test(x.responseType ?? "") ? parse(x.response, "application/json") : undefined;
|
|
123
|
+
if (json !== undefined && typeof json === "object")
|
|
124
|
+
expectation.json = loosen(json);
|
|
125
|
+
else if (!/html/i.test(x.responseType ?? "") && x.response && x.response.length <= 200)
|
|
126
|
+
expectation.text = x.response;
|
|
127
|
+
step.expect = expectation;
|
|
128
|
+
// Capture top-level values that show up again later.
|
|
129
|
+
if (json && typeof json === "object" && !Array.isArray(json)) {
|
|
130
|
+
const later = kept.slice(i + 1).map((y) => `${y.path}\n${y.body}`).join("\n");
|
|
131
|
+
const capture = {};
|
|
132
|
+
for (const [key, value] of Object.entries(json)) {
|
|
133
|
+
if ((typeof value !== "string" && typeof value !== "number") || String(value).length < 3 || !later.includes(String(value)))
|
|
134
|
+
continue;
|
|
135
|
+
let variable = key.replace(/\W/g, "_");
|
|
136
|
+
while ([...captured.values()].includes(variable))
|
|
137
|
+
variable += "_";
|
|
138
|
+
capture[variable] = `json.${key}`;
|
|
139
|
+
captured.set(String(value), variable);
|
|
140
|
+
}
|
|
141
|
+
if (Object.keys(capture).length)
|
|
142
|
+
step.capture = capture;
|
|
143
|
+
}
|
|
144
|
+
steps.push(step);
|
|
145
|
+
});
|
|
146
|
+
for (const [stub, list] of Object.entries(calls)) {
|
|
147
|
+
const byCall = new Map();
|
|
148
|
+
for (const c of list)
|
|
149
|
+
if (c.response && c.response.status !== 501)
|
|
150
|
+
byCall.set(`${c.method} ${c.path}`, (byCall.get(`${c.method} ${c.path}`) ?? 0) + 1);
|
|
151
|
+
for (const [call, times] of byCall)
|
|
152
|
+
steps.push({ received: stub, call, times });
|
|
153
|
+
}
|
|
154
|
+
const tables = Object.keys(changes);
|
|
155
|
+
const counts = Object.fromEntries(Object.entries(changes).map(([table, c]) => [
|
|
156
|
+
table,
|
|
157
|
+
Object.fromEntries(Object.entries({ inserted: c.inserted.length, updated: c.updated.length, deleted: c.deleted.length }).filter(([, n]) => n > 0)),
|
|
158
|
+
]));
|
|
159
|
+
steps.push({ changes: counts });
|
|
160
|
+
const skipped = exchanges.length - kept.length;
|
|
161
|
+
if (skipped)
|
|
162
|
+
notes.push(`${skipped} request(s) for static files (scripts, styles, images) were left out.`);
|
|
163
|
+
if (kept.length === 0)
|
|
164
|
+
notes.push("No requests to the app were recorded; send them to the proxy URL printed when recording started.");
|
|
165
|
+
const header = [
|
|
166
|
+
`# Recorded with \`npx slicetest record\` on ${now.toISOString().slice(0, 10)}. Review it before committing:`,
|
|
167
|
+
"# - give the scenario a name that says what it checks, and split it if it checks several things;",
|
|
168
|
+
"# - dates and UUIDs in responses are matched by type only, and `changes` only counts rows;",
|
|
169
|
+
"# tighten what matters (see https://github.com/revo1290/slicetest#yaml-scenarios).",
|
|
170
|
+
...notes.map((n) => `# - ${n}`),
|
|
171
|
+
"# yaml-language-server: $schema=https://unpkg.com/slicetest/schema/scenario.schema.json",
|
|
172
|
+
"",
|
|
173
|
+
].join("\n");
|
|
174
|
+
const yaml = header + stringify({ scenarios: [{ name, steps }] }, { lineWidth: 0 });
|
|
175
|
+
const summary = { requests: kept.length, skippedAssets: skipped, stubs: stubRoutes, tables, unanswered };
|
|
176
|
+
return { yaml, summary };
|
|
177
|
+
}
|
|
178
|
+
function parse(text, contentType) {
|
|
179
|
+
if (/json/i.test(contentType ?? "")) {
|
|
180
|
+
try {
|
|
181
|
+
return JSON.parse(text);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return text;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return text;
|
|
188
|
+
}
|
|
189
|
+
/** Dates and UUIDs change from run to run: expect their type instead of their value. */
|
|
190
|
+
function loosen(value) {
|
|
191
|
+
const walk = (v) => {
|
|
192
|
+
if (v === "[date]" || v === "[uuid]")
|
|
193
|
+
return { $type: "string" };
|
|
194
|
+
if (Array.isArray(v))
|
|
195
|
+
return v.map(walk);
|
|
196
|
+
if (v && typeof v === "object")
|
|
197
|
+
return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)]));
|
|
198
|
+
return v;
|
|
199
|
+
};
|
|
200
|
+
return walk(mask(value));
|
|
201
|
+
}
|
|
@@ -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,8 +1,11 @@
|
|
|
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";
|
|
6
|
+
import { Mailbox } from "./mail.js";
|
|
5
7
|
import { Stub } from "./stub.js";
|
|
8
|
+
import { type MaskOptions, type Trace } from "./trace.js";
|
|
6
9
|
export interface ScenarioContext {
|
|
7
10
|
http: HttpClient;
|
|
8
11
|
db: Db;
|
|
@@ -10,6 +13,15 @@ export interface ScenarioContext {
|
|
|
10
13
|
app: App;
|
|
11
14
|
/** A process from the `services` option: its `url`, `logs()` and `waitForLog()`. */
|
|
12
15
|
service: (name: string) => App;
|
|
16
|
+
/** A container from the `containers` option: its `host`, `port`, `address` and `exec()`. */
|
|
17
|
+
container: (name: string) => Dependency;
|
|
18
|
+
/**
|
|
19
|
+
* What the scenario did so far: requests to the app, calls to stubs and database changes,
|
|
20
|
+
* with timestamps and UUIDs masked. `expect(await trace()).toMatchSnapshot()`.
|
|
21
|
+
*/
|
|
22
|
+
trace: (opts?: MaskOptions) => Promise<Trace>;
|
|
23
|
+
/** Mail the app sent during the scenario. Needs `mail: true` in the config. */
|
|
24
|
+
mail: Mailbox;
|
|
13
25
|
}
|
|
14
26
|
/** Everything one test file needs: its own database, stub servers and app process. */
|
|
15
27
|
export declare class Runtime {
|
|
@@ -22,6 +34,10 @@ export declare class Runtime {
|
|
|
22
34
|
private readonly vars;
|
|
23
35
|
private readonly specs;
|
|
24
36
|
private readonly coverageDir?;
|
|
37
|
+
private readonly recorders;
|
|
38
|
+
private readonly recordDir?;
|
|
39
|
+
readonly containers: Map<string, Dependency>;
|
|
40
|
+
readonly mailbox?: Mailbox | undefined;
|
|
25
41
|
private constructor();
|
|
26
42
|
get http(): HttpClient;
|
|
27
43
|
static start(opts: ResolvedOptions, shared: {
|
|
@@ -29,6 +45,7 @@ export declare class Runtime {
|
|
|
29
45
|
template: string;
|
|
30
46
|
prefix: string;
|
|
31
47
|
coverageDir?: string;
|
|
48
|
+
recordDir?: string;
|
|
32
49
|
}): Promise<Runtime>;
|
|
33
50
|
context(): ScenarioContext;
|
|
34
51
|
beforeScenario(): Promise<void>;
|
package/dist/runtime.js
CHANGED
|
@@ -2,11 +2,15 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { writeFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { App } from "./app.js";
|
|
5
|
+
import { Dependency } from "./containers.js";
|
|
5
6
|
import { Db, formatChanges } from "./db.js";
|
|
6
7
|
import { engineFor } from "./drivers/index.js";
|
|
7
8
|
import { formatHistory, HttpClient } from "./http.js";
|
|
9
|
+
import { Mailbox } from "./mail.js";
|
|
8
10
|
import { OpenApiSpec } from "./openapi.js";
|
|
11
|
+
import { Recorder } from "./recording.js";
|
|
9
12
|
import { Stub } from "./stub.js";
|
|
13
|
+
import { buildTrace, mask } from "./trace.js";
|
|
10
14
|
/** Everything one test file needs: its own database, stub servers and app process. */
|
|
11
15
|
export class Runtime {
|
|
12
16
|
app;
|
|
@@ -17,12 +21,16 @@ export class Runtime {
|
|
|
17
21
|
vars;
|
|
18
22
|
specs;
|
|
19
23
|
coverageDir;
|
|
24
|
+
recorders;
|
|
25
|
+
recordDir;
|
|
26
|
+
containers;
|
|
27
|
+
mailbox;
|
|
20
28
|
#http;
|
|
21
29
|
/** Responses from the app that don't match its OpenAPI spec, this scenario. */
|
|
22
30
|
#contract = [];
|
|
23
31
|
/** Documented responses seen in this file, for the run's coverage report. */
|
|
24
32
|
#covered = new Set();
|
|
25
|
-
constructor(app, services, db, stubs, opts, vars, specs, coverageDir) {
|
|
33
|
+
constructor(app, services, db, stubs, opts, vars, specs, coverageDir, recorders = new Map(), recordDir, containers = new Map(), mailbox) {
|
|
26
34
|
this.app = app;
|
|
27
35
|
this.services = services;
|
|
28
36
|
this.db = db;
|
|
@@ -31,6 +39,10 @@ export class Runtime {
|
|
|
31
39
|
this.vars = vars;
|
|
32
40
|
this.specs = specs;
|
|
33
41
|
this.coverageDir = coverageDir;
|
|
42
|
+
this.recorders = recorders;
|
|
43
|
+
this.recordDir = recordDir;
|
|
44
|
+
this.containers = containers;
|
|
45
|
+
this.mailbox = mailbox;
|
|
34
46
|
this.#http = this.#client();
|
|
35
47
|
}
|
|
36
48
|
#client() {
|
|
@@ -56,11 +68,14 @@ export class Runtime {
|
|
|
56
68
|
return this.#http;
|
|
57
69
|
}
|
|
58
70
|
static async start(opts, shared) {
|
|
59
|
-
const engine = engineFor(opts);
|
|
71
|
+
const engine = await engineFor(opts);
|
|
60
72
|
const url = await ensureWorkerDatabase(engine, shared.adminUrl, shared.template, shared.prefix);
|
|
61
73
|
const stubs = new Map();
|
|
74
|
+
const recorders = new Map();
|
|
75
|
+
const containers = new Map();
|
|
62
76
|
const services = new Map();
|
|
63
77
|
let db;
|
|
78
|
+
let mailbox;
|
|
64
79
|
try {
|
|
65
80
|
// Loaded first: a broken spec should fail before anything is started.
|
|
66
81
|
const specs = {
|
|
@@ -71,16 +86,37 @@ export class Runtime {
|
|
|
71
86
|
specs.stubs.set(name, await OpenApiSpec.load(path.resolve(opts.root, file), file));
|
|
72
87
|
for (const name of opts.stubs)
|
|
73
88
|
stubs.set(name, await Stub.start(name));
|
|
74
|
-
for (const name of opts.
|
|
75
|
-
|
|
76
|
-
|
|
89
|
+
for (const [name, r] of Object.entries(opts.recordings))
|
|
90
|
+
recorders.set(name, await Recorder.load(name, path.resolve(opts.root, r.file), r.upstream, r.record));
|
|
91
|
+
// A registered route wins, then a recording of the real service, then an answer made up from its spec.
|
|
92
|
+
for (const [name, stub] of stubs) {
|
|
93
|
+
const recorder = recorders.get(name);
|
|
94
|
+
const spec = opts.openapi.autoReply.includes(name) ? specs.stubs.get(name) : undefined;
|
|
95
|
+
if (!recorder && !spec)
|
|
96
|
+
continue;
|
|
97
|
+
stub.fallback(async (call) => (await recorder?.answer(call)) ?? spec?.exampleResponse(call.method, call.path), recorder?.hint());
|
|
77
98
|
}
|
|
78
99
|
db = await Db.connect(await engine.driver(url), url, {
|
|
79
100
|
schemas: opts.db.schemas,
|
|
80
101
|
keep: opts.db.keep,
|
|
81
102
|
seedFile: opts.db.seed && path.resolve(opts.root, opts.db.seed),
|
|
82
103
|
});
|
|
104
|
+
const started = await Promise.allSettled(Object.entries(opts.containers).map(async ([name, c]) => containers.set(name, await Dependency.start(name, c))));
|
|
105
|
+
const failed = started.find((r) => r.status === "rejected");
|
|
106
|
+
if (failed)
|
|
107
|
+
throw failed.reason;
|
|
83
108
|
const vars = { "db.url": url };
|
|
109
|
+
if (engine.name === "sqlite")
|
|
110
|
+
vars["db.path"] = (await import("./drivers/sqlite.js")).sqlitePath(url);
|
|
111
|
+
if (opts.mail) {
|
|
112
|
+
mailbox = await Mailbox.start();
|
|
113
|
+
Object.assign(vars, { "mail.host": mailbox.host, "mail.port": String(mailbox.port), "mail.url": mailbox.url });
|
|
114
|
+
}
|
|
115
|
+
for (const [name, c] of containers) {
|
|
116
|
+
vars[`container.${name}`] = c.address;
|
|
117
|
+
vars[`container.${name}.host`] = c.host;
|
|
118
|
+
vars[`container.${name}.port`] = String(c.port);
|
|
119
|
+
}
|
|
84
120
|
for (const [name, stub] of stubs)
|
|
85
121
|
vars[`stub.${name}`] = stub.url;
|
|
86
122
|
for (const [name, service] of Object.entries(opts.services)) {
|
|
@@ -90,16 +126,19 @@ export class Runtime {
|
|
|
90
126
|
vars[`service.${name}.port`] = String(started.port);
|
|
91
127
|
}
|
|
92
128
|
const app = await App.start(opts.app, opts.root, vars);
|
|
93
|
-
return new Runtime(app, services, db, stubs, opts, vars, specs, shared.coverageDir);
|
|
129
|
+
return new Runtime(app, services, db, stubs, opts, vars, specs, shared.coverageDir, recorders, shared.recordDir, containers, mailbox);
|
|
94
130
|
}
|
|
95
131
|
catch (e) {
|
|
96
132
|
await Promise.all([...services.values()].map((s) => s.stop()));
|
|
97
133
|
await db?.close();
|
|
98
134
|
await Promise.all([...stubs.values()].map((s) => s.close()));
|
|
135
|
+
await Promise.allSettled([...containers.values()].map((c) => c.stop()));
|
|
136
|
+
await mailbox?.close();
|
|
99
137
|
throw e;
|
|
100
138
|
}
|
|
101
139
|
}
|
|
102
140
|
context() {
|
|
141
|
+
const mailbox = this.mailbox;
|
|
103
142
|
return {
|
|
104
143
|
http: this.http,
|
|
105
144
|
db: this.db,
|
|
@@ -118,6 +157,18 @@ export class Runtime {
|
|
|
118
157
|
}
|
|
119
158
|
return service;
|
|
120
159
|
},
|
|
160
|
+
container: (name) => {
|
|
161
|
+
const c = this.containers.get(name);
|
|
162
|
+
if (!c)
|
|
163
|
+
throw new Error(`slicetest: unknown container "${name}". Declared containers: ${[...this.containers.keys()].join(", ") || "(none)"}`);
|
|
164
|
+
return c;
|
|
165
|
+
},
|
|
166
|
+
trace: async (opts) => mask(buildTrace(this.http.history, this.stubs.values(), await this.db.changesSinceStart(), this.mailbox), opts),
|
|
167
|
+
get mail() {
|
|
168
|
+
if (!mailbox)
|
|
169
|
+
throw new Error("slicetest: mail is off. Add `mail: true` to the config and point the app's SMTP settings at {{mail.host}} / {{mail.port}}.");
|
|
170
|
+
return mailbox;
|
|
171
|
+
},
|
|
121
172
|
};
|
|
122
173
|
}
|
|
123
174
|
/** The app and every service, for checks that apply to all of them. */
|
|
@@ -141,6 +192,10 @@ export class Runtime {
|
|
|
141
192
|
await this.db.reset();
|
|
142
193
|
for (const stub of this.stubs.values())
|
|
143
194
|
stub.reset();
|
|
195
|
+
for (const recorder of this.recorders.values())
|
|
196
|
+
recorder.reset();
|
|
197
|
+
this.mailbox?.reset();
|
|
198
|
+
await Promise.all([...this.containers.values()].map((c) => c.reset()));
|
|
144
199
|
this.http.reset();
|
|
145
200
|
for (const p of this.#processes())
|
|
146
201
|
p.beginScenario();
|
|
@@ -195,6 +250,7 @@ export class Runtime {
|
|
|
195
250
|
return [
|
|
196
251
|
...calls.map((c) => ` ${s.name}: ${c.method} ${c.path}${c.query.size ? `?${c.query}` : ""}`),
|
|
197
252
|
` registered on ${s.name}: ${routes.length ? routes.join(", ") : "(none)"}`,
|
|
253
|
+
...(this.recorders.has(s.name) ? [` ${this.recorders.get(s.name).hint()}`] : []),
|
|
198
254
|
];
|
|
199
255
|
});
|
|
200
256
|
}
|
|
@@ -221,6 +277,8 @@ export class Runtime {
|
|
|
221
277
|
catch (e) {
|
|
222
278
|
sections.push(`database changes during this scenario: unavailable (${e.message})`);
|
|
223
279
|
}
|
|
280
|
+
if (this.mailbox)
|
|
281
|
+
sections.push(this.mailbox.describe());
|
|
224
282
|
const logs = this.app.scenarioLogs();
|
|
225
283
|
sections.push(logs ? `app output during this scenario:\n${logs}` : "app output during this scenario: (none)");
|
|
226
284
|
for (const [name, service] of this.services) {
|
|
@@ -233,11 +291,18 @@ export class Runtime {
|
|
|
233
291
|
if (this.coverageDir && this.#covered.size > 0) {
|
|
234
292
|
await writeFile(path.join(this.coverageDir, `${process.pid}-${randomUUID()}.json`), JSON.stringify([...this.#covered])).catch(() => { });
|
|
235
293
|
}
|
|
294
|
+
for (const [name, recorder] of this.recorders) {
|
|
295
|
+
if (this.recordDir && recorder.added().length > 0) {
|
|
296
|
+
await writeFile(path.join(this.recordDir, `${name}.${process.pid}-${randomUUID()}.json`), JSON.stringify(recorder.added())).catch(() => { });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
236
299
|
const results = await Promise.allSettled([
|
|
237
300
|
this.app.stop(),
|
|
238
301
|
...[...this.services.values()].map((s) => s.stop()),
|
|
239
302
|
this.db.close(),
|
|
240
303
|
...[...this.stubs.values()].map((s) => s.close()),
|
|
304
|
+
...[...this.containers.values()].map((c) => c.stop()),
|
|
305
|
+
this.mailbox?.close(),
|
|
241
306
|
]);
|
|
242
307
|
const failed = results.find((r) => r.status === "rejected");
|
|
243
308
|
if (failed)
|
package/dist/stub.d.ts
CHANGED
|
@@ -81,7 +81,7 @@ export declare class Stub {
|
|
|
81
81
|
* Return undefined to leave a call unanswered (it then fails the scenario as usual).
|
|
82
82
|
* Kept across scenarios, unlike routes.
|
|
83
83
|
*/
|
|
84
|
-
fallback(respond: ((call: RecordedCall) => StubResponse | undefined) | undefined): this;
|
|
84
|
+
fallback(respond: ((call: RecordedCall) => StubResponse | undefined | Promise<StubResponse | undefined>) | undefined, hint?: string): this;
|
|
85
85
|
close(): Promise<void>;
|
|
86
86
|
}
|
|
87
87
|
/** `expected` is contained in `actual`: objects compare key by key, arrays element-wise. */
|
package/dist/stub.js
CHANGED
|
@@ -10,6 +10,8 @@ export class Stub {
|
|
|
10
10
|
#routes = [];
|
|
11
11
|
#calls = [];
|
|
12
12
|
#fallback;
|
|
13
|
+
/** Appended to the 501 answer for a call nothing could answer. */
|
|
14
|
+
#hint;
|
|
13
15
|
url = "";
|
|
14
16
|
constructor(name) {
|
|
15
17
|
this.name = name;
|
|
@@ -86,8 +88,9 @@ export class Stub {
|
|
|
86
88
|
* Return undefined to leave a call unanswered (it then fails the scenario as usual).
|
|
87
89
|
* Kept across scenarios, unlike routes.
|
|
88
90
|
*/
|
|
89
|
-
fallback(respond) {
|
|
91
|
+
fallback(respond, hint) {
|
|
90
92
|
this.#fallback = respond;
|
|
93
|
+
this.#hint = hint;
|
|
91
94
|
return this;
|
|
92
95
|
}
|
|
93
96
|
async close() {
|
|
@@ -125,9 +128,17 @@ export class Stub {
|
|
|
125
128
|
break;
|
|
126
129
|
}
|
|
127
130
|
if (!route) {
|
|
128
|
-
|
|
131
|
+
let out;
|
|
132
|
+
try {
|
|
133
|
+
out = await this.#fallback?.(call);
|
|
134
|
+
}
|
|
135
|
+
catch (e) {
|
|
136
|
+
res.writeHead(502, { "content-type": "text/plain" }).end(e.message);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
129
139
|
if (!out) {
|
|
130
|
-
|
|
140
|
+
const hint = this.#hint ? ` (${this.#hint})` : "";
|
|
141
|
+
res.writeHead(501, { "content-type": "text/plain" }).end(`slicetest: no stub for ${call.method} ${call.path}${hint}`);
|
|
131
142
|
return;
|
|
132
143
|
}
|
|
133
144
|
call.matched = true;
|