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/mail.js
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
export class Mailbox {
|
|
3
|
+
server;
|
|
4
|
+
#messages = [];
|
|
5
|
+
#waiters = new Set();
|
|
6
|
+
#sockets = new Set();
|
|
7
|
+
constructor(server) {
|
|
8
|
+
this.server = server;
|
|
9
|
+
}
|
|
10
|
+
static async start() {
|
|
11
|
+
const box = new Mailbox(net.createServer((socket) => box.#session(socket)));
|
|
12
|
+
await new Promise((resolve, reject) => {
|
|
13
|
+
box.server.once("error", reject);
|
|
14
|
+
box.server.listen(0, "127.0.0.1", resolve);
|
|
15
|
+
});
|
|
16
|
+
return box;
|
|
17
|
+
}
|
|
18
|
+
get host() {
|
|
19
|
+
return "127.0.0.1";
|
|
20
|
+
}
|
|
21
|
+
get port() {
|
|
22
|
+
return this.server.address().port;
|
|
23
|
+
}
|
|
24
|
+
/** `smtp://127.0.0.1:<port>`, for libraries configured with a URL. */
|
|
25
|
+
get url() {
|
|
26
|
+
return `smtp://${this.host}:${this.port}`;
|
|
27
|
+
}
|
|
28
|
+
/** Messages received during this scenario, oldest first. */
|
|
29
|
+
messages(filter = {}) {
|
|
30
|
+
return this.#messages.filter((m) => matches(m, filter));
|
|
31
|
+
}
|
|
32
|
+
/** The most recent message matching `filter`, or undefined. */
|
|
33
|
+
last(filter = {}) {
|
|
34
|
+
return this.messages(filter).at(-1);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The first message matching `filter`, waiting up to `within` ms for the app
|
|
38
|
+
* to send it. Fails with the messages that did arrive.
|
|
39
|
+
*/
|
|
40
|
+
async waitFor(filter = {}, { within = 5000 } = {}) {
|
|
41
|
+
const deadline = Date.now() + within;
|
|
42
|
+
for (;;) {
|
|
43
|
+
const found = this.messages(filter)[0];
|
|
44
|
+
if (found)
|
|
45
|
+
return found;
|
|
46
|
+
const left = deadline - Date.now();
|
|
47
|
+
if (left <= 0)
|
|
48
|
+
throw new Error(`slicetest: no mail matching ${describeFilter(filter)} within ${within}ms\n${this.describe()}`);
|
|
49
|
+
await new Promise((resolve) => {
|
|
50
|
+
const done = () => {
|
|
51
|
+
clearTimeout(timer);
|
|
52
|
+
this.#waiters.delete(done);
|
|
53
|
+
resolve();
|
|
54
|
+
};
|
|
55
|
+
const timer = setTimeout(done, left);
|
|
56
|
+
this.#waiters.add(done);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** One line per message received this scenario, for failure output. */
|
|
61
|
+
describe() {
|
|
62
|
+
if (this.#messages.length === 0)
|
|
63
|
+
return "mail sent during this scenario: (none)";
|
|
64
|
+
return `mail sent during this scenario:\n${this.#messages.map((m) => ` ${m.from} → ${m.to.join(", ")}: ${JSON.stringify(m.subject)}`).join("\n")}`;
|
|
65
|
+
}
|
|
66
|
+
reset() {
|
|
67
|
+
this.#messages = [];
|
|
68
|
+
}
|
|
69
|
+
async close() {
|
|
70
|
+
for (const s of this.#sockets)
|
|
71
|
+
s.destroy();
|
|
72
|
+
await new Promise((resolve) => this.server.close(() => resolve()));
|
|
73
|
+
}
|
|
74
|
+
#receive(mail) {
|
|
75
|
+
this.#messages.push(mail);
|
|
76
|
+
for (const w of [...this.#waiters])
|
|
77
|
+
w();
|
|
78
|
+
}
|
|
79
|
+
/** A minimal SMTP (RFC 5321) session: no TLS, AUTH accepted, one message per MAIL … DATA. */
|
|
80
|
+
#session(socket) {
|
|
81
|
+
this.#sockets.add(socket);
|
|
82
|
+
socket.on("close", () => this.#sockets.delete(socket));
|
|
83
|
+
socket.on("error", () => { });
|
|
84
|
+
const reply = (line) => socket.write(`${line}\r\n`);
|
|
85
|
+
let buffer = "";
|
|
86
|
+
let from = "";
|
|
87
|
+
let to = [];
|
|
88
|
+
let data;
|
|
89
|
+
let auth;
|
|
90
|
+
reply("220 slicetest ESMTP");
|
|
91
|
+
socket.setEncoding("utf8");
|
|
92
|
+
socket.on("data", (chunk) => {
|
|
93
|
+
buffer += chunk;
|
|
94
|
+
let i;
|
|
95
|
+
while ((i = buffer.indexOf("\n")) >= 0) {
|
|
96
|
+
const line = buffer.slice(0, i).replace(/\r$/, "");
|
|
97
|
+
buffer = buffer.slice(i + 1);
|
|
98
|
+
if (data) {
|
|
99
|
+
if (line === ".") {
|
|
100
|
+
this.#receive(parseMail(data.join("\r\n"), from, to));
|
|
101
|
+
data = undefined;
|
|
102
|
+
reply("250 OK: queued");
|
|
103
|
+
}
|
|
104
|
+
else
|
|
105
|
+
data.push(line.startsWith(".") ? line.slice(1) : line);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (auth) {
|
|
109
|
+
auth = auth === "login-user" ? "login-pass" : undefined;
|
|
110
|
+
reply(auth ? "334 UGFzc3dvcmQ6" : "235 Authentication successful");
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const [verb = "", ...rest] = line.split(" ");
|
|
114
|
+
const arg = rest.join(" ");
|
|
115
|
+
switch (verb.toUpperCase()) {
|
|
116
|
+
case "EHLO":
|
|
117
|
+
reply("250-slicetest");
|
|
118
|
+
reply("250-AUTH PLAIN LOGIN");
|
|
119
|
+
reply("250-8BITMIME");
|
|
120
|
+
reply("250-SMTPUTF8");
|
|
121
|
+
reply("250 SIZE 52428800");
|
|
122
|
+
break;
|
|
123
|
+
case "HELO":
|
|
124
|
+
reply("250 slicetest");
|
|
125
|
+
break;
|
|
126
|
+
case "AUTH": {
|
|
127
|
+
const [mech = "", initial] = arg.split(" ");
|
|
128
|
+
if (mech.toUpperCase() === "LOGIN") {
|
|
129
|
+
auth = initial ? "login-pass" : "login-user";
|
|
130
|
+
reply(initial ? "334 UGFzc3dvcmQ6" : "334 VXNlcm5hbWU6");
|
|
131
|
+
}
|
|
132
|
+
else if (initial)
|
|
133
|
+
reply("235 Authentication successful");
|
|
134
|
+
else {
|
|
135
|
+
auth = "plain";
|
|
136
|
+
reply("334 ");
|
|
137
|
+
}
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
case "MAIL":
|
|
141
|
+
from = address(arg);
|
|
142
|
+
to = [];
|
|
143
|
+
reply("250 OK");
|
|
144
|
+
break;
|
|
145
|
+
case "RCPT":
|
|
146
|
+
to.push(address(arg));
|
|
147
|
+
reply("250 OK");
|
|
148
|
+
break;
|
|
149
|
+
case "DATA":
|
|
150
|
+
if (to.length === 0)
|
|
151
|
+
reply("503 RCPT first");
|
|
152
|
+
else {
|
|
153
|
+
data = [];
|
|
154
|
+
reply("354 End data with <CR><LF>.<CR><LF>");
|
|
155
|
+
}
|
|
156
|
+
break;
|
|
157
|
+
case "RSET":
|
|
158
|
+
from = "";
|
|
159
|
+
to = [];
|
|
160
|
+
reply("250 OK");
|
|
161
|
+
break;
|
|
162
|
+
case "NOOP":
|
|
163
|
+
reply("250 OK");
|
|
164
|
+
break;
|
|
165
|
+
case "QUIT":
|
|
166
|
+
reply("221 Bye");
|
|
167
|
+
socket.end();
|
|
168
|
+
break;
|
|
169
|
+
default:
|
|
170
|
+
reply(`502 ${verb} not implemented`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
function address(arg) {
|
|
177
|
+
const m = /<([^>]*)>/.exec(arg) ?? /:\s*(\S+)/.exec(arg);
|
|
178
|
+
return (m?.[1] ?? "").trim();
|
|
179
|
+
}
|
|
180
|
+
function matches(mail, f) {
|
|
181
|
+
const addr = (want, values) => want === undefined || values.some((v) => (typeof want === "string" ? v.toLowerCase() === want.toLowerCase() : test(want, v)));
|
|
182
|
+
const text = (want, value) => want === undefined || (value !== undefined && (typeof want === "string" ? value.includes(want) : test(want, value)));
|
|
183
|
+
return addr(f.to, mail.to) && addr(f.from, [mail.from]) && text(f.subject, mail.subject) && text(f.text, mail.text) && text(f.html, mail.html);
|
|
184
|
+
}
|
|
185
|
+
function test(re, s) {
|
|
186
|
+
re.lastIndex = 0;
|
|
187
|
+
return re.test(s);
|
|
188
|
+
}
|
|
189
|
+
function describeFilter(f) {
|
|
190
|
+
const parts = Object.entries(f).map(([k, v]) => `${k}: ${v instanceof RegExp ? String(v) : JSON.stringify(v)}`);
|
|
191
|
+
return parts.length ? `{ ${parts.join(", ")} }` : "(any)";
|
|
192
|
+
}
|
|
193
|
+
/** Headers are unfolded and lower-cased; the body is everything after the blank line. */
|
|
194
|
+
function splitPart(raw) {
|
|
195
|
+
const end = raw.search(/\r?\n\r?\n/);
|
|
196
|
+
const head = end < 0 ? raw : raw.slice(0, end);
|
|
197
|
+
const body = end < 0 ? "" : raw.slice(end).replace(/^\r?\n\r?\n/, "");
|
|
198
|
+
const headers = {};
|
|
199
|
+
for (const line of head.replace(/\r?\n[ \t]+/g, " ").split(/\r?\n/)) {
|
|
200
|
+
const i = line.indexOf(":");
|
|
201
|
+
if (i <= 0)
|
|
202
|
+
continue;
|
|
203
|
+
const name = line.slice(0, i).trim().toLowerCase();
|
|
204
|
+
const value = line.slice(i + 1).trim();
|
|
205
|
+
headers[name] = headers[name] ? `${headers[name]}, ${value}` : value;
|
|
206
|
+
}
|
|
207
|
+
return { headers, body };
|
|
208
|
+
}
|
|
209
|
+
export function parseMail(raw, from, to) {
|
|
210
|
+
const top = splitPart(raw);
|
|
211
|
+
const texts = [];
|
|
212
|
+
const walk = (part) => {
|
|
213
|
+
const type = (part.headers["content-type"] ?? "text/plain").toLowerCase();
|
|
214
|
+
const boundary = /boundary="?([^";]+)"?/i.exec(part.headers["content-type"] ?? "")?.[1];
|
|
215
|
+
if (type.startsWith("multipart/") && boundary) {
|
|
216
|
+
const pieces = part.body.split(new RegExp(`\\r?\\n?--${escape(boundary)}(?:--)?[ \\t]*\\r?\\n?`));
|
|
217
|
+
for (const piece of pieces.slice(1))
|
|
218
|
+
if (piece.trim())
|
|
219
|
+
walk(splitPart(piece));
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (!type.startsWith("text/") || /attachment/i.test(part.headers["content-disposition"] ?? ""))
|
|
223
|
+
return;
|
|
224
|
+
texts.push({ type, text: decodeBody(part) });
|
|
225
|
+
};
|
|
226
|
+
walk(top);
|
|
227
|
+
const text = texts.find((t) => t.type.startsWith("text/plain"))?.text;
|
|
228
|
+
const html = texts.find((t) => t.type.startsWith("text/html"))?.text;
|
|
229
|
+
const headerFrom = /<([^>]+)>/.exec(top.headers.from ?? "")?.[1] ?? top.headers.from;
|
|
230
|
+
return {
|
|
231
|
+
from: from || headerFrom || "",
|
|
232
|
+
to: to.length ? to : (top.headers.to ?? "").split(",").map((a) => (/<([^>]+)>/.exec(a)?.[1] ?? a).trim()).filter(Boolean),
|
|
233
|
+
subject: decodeWords(top.headers.subject ?? ""),
|
|
234
|
+
text: text ?? (html ? htmlToText(html) : ""),
|
|
235
|
+
html,
|
|
236
|
+
headers: Object.fromEntries(Object.entries(top.headers).map(([k, v]) => [k, decodeWords(v)])),
|
|
237
|
+
links: links([text ?? "", html ?? ""].join("\n")),
|
|
238
|
+
raw,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
function charsetOf(part) {
|
|
242
|
+
return /charset="?([^";]+)"?/i.exec(part.headers["content-type"] ?? "")?.[1] ?? "utf-8";
|
|
243
|
+
}
|
|
244
|
+
function decodeBody(part) {
|
|
245
|
+
const enc = (part.headers["content-transfer-encoding"] ?? "").toLowerCase();
|
|
246
|
+
const bytes = enc === "base64" ? Buffer.from(part.body.replace(/\s+/g, ""), "base64")
|
|
247
|
+
: enc === "quoted-printable" ? quotedPrintable(part.body)
|
|
248
|
+
: Buffer.from(part.body, "utf8");
|
|
249
|
+
return decodeCharset(bytes, charsetOf(part)).replace(/\r\n/g, "\n").replace(/\n$/, "");
|
|
250
|
+
}
|
|
251
|
+
function quotedPrintable(s) {
|
|
252
|
+
const bytes = [];
|
|
253
|
+
const src = s.replace(/=\r?\n/g, "");
|
|
254
|
+
for (let i = 0; i < src.length; i++) {
|
|
255
|
+
if (src[i] === "=" && /^[0-9A-F]{2}$/i.test(src.slice(i + 1, i + 3))) {
|
|
256
|
+
bytes.push(parseInt(src.slice(i + 1, i + 3), 16));
|
|
257
|
+
i += 2;
|
|
258
|
+
}
|
|
259
|
+
else
|
|
260
|
+
bytes.push(...Buffer.from(src[i], "utf8"));
|
|
261
|
+
}
|
|
262
|
+
return Buffer.from(bytes);
|
|
263
|
+
}
|
|
264
|
+
function decodeCharset(bytes, charset) {
|
|
265
|
+
try {
|
|
266
|
+
return new TextDecoder(charset).decode(bytes);
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
return bytes.toString("utf8");
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
/** RFC 2047 encoded words: `=?UTF-8?B?...?=` and `=?ISO-2022-JP?Q?...?=`. */
|
|
273
|
+
function decodeWords(s) {
|
|
274
|
+
return s
|
|
275
|
+
.replace(/\?=\s+=\?/g, "?==?")
|
|
276
|
+
.replace(/=\?([^?]+)\?([BbQq])\?([^?]*)\?=/g, (_, charset, enc, text) => {
|
|
277
|
+
const bytes = enc.toUpperCase() === "B" ? Buffer.from(text, "base64") : quotedPrintable(text.replace(/_/g, " "));
|
|
278
|
+
return decodeCharset(bytes, charset);
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
function htmlToText(html) {
|
|
282
|
+
return html
|
|
283
|
+
.replace(/<(script|style)[\s\S]*?<\/\1>/gi, "")
|
|
284
|
+
.replace(/<br\s*\/?>|<\/p>/gi, "\n")
|
|
285
|
+
.replace(/<[^>]+>/g, "")
|
|
286
|
+
.replace(/&/g, "&")
|
|
287
|
+
.replace(/</g, "<")
|
|
288
|
+
.replace(/>/g, ">")
|
|
289
|
+
.replace(/"/g, '"')
|
|
290
|
+
.replace(/'/g, "'")
|
|
291
|
+
.trim();
|
|
292
|
+
}
|
|
293
|
+
function links(s) {
|
|
294
|
+
const found = (s.match(/https?:\/\/[^\s"'<>)\]]+/g) ?? []).map((u) => u.replace(/&/g, "&").replace(/[.,;:!?]+$/, ""));
|
|
295
|
+
return [...new Set(found)];
|
|
296
|
+
}
|
|
297
|
+
function escape(s) {
|
|
298
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
299
|
+
}
|
package/dist/matchers.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type Where } from "./db.js";
|
|
2
|
+
import type { HttpResponse } from "./http.js";
|
|
2
3
|
import { type MatchOptions } from "./stub.js";
|
|
3
4
|
interface SlicetestMatchers<R = unknown> {
|
|
4
5
|
/** The stub received at least one call matching `method path` (and `match`, if given). */
|
|
@@ -7,6 +8,8 @@ interface SlicetestMatchers<R = unknown> {
|
|
|
7
8
|
toHaveReceivedTimes(n: number, method?: string, path?: string | RegExp, match?: MatchOptions): R;
|
|
8
9
|
/** The response has this status; the failure message shows the response body. */
|
|
9
10
|
toHaveStatus(status: number): R;
|
|
11
|
+
/** An array of responses has exactly these status counts, e.g. `{ 201: 1, 409: 9 }`. */
|
|
12
|
+
toHaveStatuses(counts: Record<number, number>): R;
|
|
10
13
|
/** Async: the table has at least one row matching `where` (`count` for an exact number). */
|
|
11
14
|
toHaveRow(table: string, where?: Where, count?: number): Promise<void>;
|
|
12
15
|
}
|
|
@@ -14,4 +17,5 @@ declare module "vitest" {
|
|
|
14
17
|
interface Matchers<R extends void | Promise<void> = void | Promise<void>, T = unknown> extends SlicetestMatchers<R> {
|
|
15
18
|
}
|
|
16
19
|
}
|
|
20
|
+
export declare function statusCounts(responses: HttpResponse[]): Record<number, number>;
|
|
17
21
|
export {};
|
package/dist/matchers.js
CHANGED
|
@@ -17,6 +17,25 @@ function assertStub(received) {
|
|
|
17
17
|
if (!(received instanceof Stub))
|
|
18
18
|
throw new TypeError("slicetest: expected a stub, e.g. expect(stub(\"slack\"))");
|
|
19
19
|
}
|
|
20
|
+
export function statusCounts(responses) {
|
|
21
|
+
const out = {};
|
|
22
|
+
for (const r of responses)
|
|
23
|
+
out[r.status] = (out[r.status] ?? 0) + 1;
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
function fmtCounts(c) {
|
|
27
|
+
return `{ ${Object.entries(c).map(([k, n]) => `${k}: ${n}`).join(", ")} }`;
|
|
28
|
+
}
|
|
29
|
+
/** One example response per status, which is usually what explains the odd one out. */
|
|
30
|
+
function describeResponses(responses) {
|
|
31
|
+
const seen = new Map();
|
|
32
|
+
for (const r of responses)
|
|
33
|
+
if (!seen.has(r.status))
|
|
34
|
+
seen.set(r.status, r);
|
|
35
|
+
return [...seen.values()]
|
|
36
|
+
.map((r) => ` ${r.status || "failed"}: ${r.method} ${r.url} ${(r.text.length > 200 ? `${r.text.slice(0, 200)}…` : r.text).replace(/\s+/g, " ") || "(empty)"}`)
|
|
37
|
+
.join("\n");
|
|
38
|
+
}
|
|
20
39
|
expect.extend({
|
|
21
40
|
toHaveReceived(received, method, path, match) {
|
|
22
41
|
assertStub(received);
|
|
@@ -53,6 +72,20 @@ expect.extend({
|
|
|
53
72
|
expected: status,
|
|
54
73
|
};
|
|
55
74
|
},
|
|
75
|
+
toHaveStatuses(received, counts) {
|
|
76
|
+
if (!Array.isArray(received))
|
|
77
|
+
throw new TypeError("slicetest: toHaveStatuses expects an array of responses, e.g. from http.concurrently()");
|
|
78
|
+
const actual = statusCounts(received);
|
|
79
|
+
const expected = Object.fromEntries(Object.entries(counts).filter(([, n]) => n > 0).map(([k, n]) => [Number(k), n]));
|
|
80
|
+
const pass = this.equals(actual, expected);
|
|
81
|
+
return {
|
|
82
|
+
pass,
|
|
83
|
+
message: () => `expected ${received.length} responses ${this.isNot ? "not " : ""}to have statuses ${fmtCounts(expected)}, got ${fmtCounts(actual)}\n` +
|
|
84
|
+
describeResponses(received),
|
|
85
|
+
actual,
|
|
86
|
+
expected,
|
|
87
|
+
};
|
|
88
|
+
},
|
|
56
89
|
async toHaveRow(received, table, where = {}, count) {
|
|
57
90
|
if (!(received instanceof Db))
|
|
58
91
|
throw new TypeError("slicetest: expected the db handle, e.g. expect(db)");
|
package/dist/openapi.d.ts
CHANGED
|
@@ -4,6 +4,18 @@ interface Operation {
|
|
|
4
4
|
method: string;
|
|
5
5
|
op: Record<string, any>;
|
|
6
6
|
}
|
|
7
|
+
export interface OperationSketch {
|
|
8
|
+
method: string;
|
|
9
|
+
template: string;
|
|
10
|
+
summary?: string;
|
|
11
|
+
pathParams: Record<string, unknown>;
|
|
12
|
+
query: Record<string, unknown>;
|
|
13
|
+
json?: unknown;
|
|
14
|
+
/** Documented response keys, e.g. `201`, `4XX`, `default`. */
|
|
15
|
+
responses: string[];
|
|
16
|
+
/** Top-level properties of the first 2xx JSON response. */
|
|
17
|
+
createdFields: string[];
|
|
18
|
+
}
|
|
7
19
|
export interface Message {
|
|
8
20
|
status?: number;
|
|
9
21
|
contentType?: string;
|
|
@@ -40,6 +52,12 @@ export declare class OpenApiSpec {
|
|
|
40
52
|
headers?: Record<string, string>;
|
|
41
53
|
body?: unknown;
|
|
42
54
|
} | undefined;
|
|
55
|
+
/**
|
|
56
|
+
* Every operation with what it takes to call it: sample values for its
|
|
57
|
+
* required path and query parameters and for its JSON request body, and the
|
|
58
|
+
* properties of its first 2xx JSON response. Used by `slicetest gen`.
|
|
59
|
+
*/
|
|
60
|
+
operations(): OperationSketch[];
|
|
43
61
|
/** Problems with a request the app sent to `method path`. */
|
|
44
62
|
checkRequest(method: string, path: string, req: Message): string[];
|
|
45
63
|
}
|
|
@@ -51,6 +69,7 @@ export declare function formatCoverage(spec: OpenApiSpec, hits: Set<string>): {
|
|
|
51
69
|
covered: number;
|
|
52
70
|
total: number;
|
|
53
71
|
percent: number;
|
|
72
|
+
markdown: string;
|
|
54
73
|
text: string;
|
|
55
74
|
};
|
|
56
75
|
export {};
|
package/dist/openapi.js
CHANGED
|
@@ -128,6 +128,38 @@ export class OpenApiSpec {
|
|
|
128
128
|
: this.#sample(m.schema);
|
|
129
129
|
return { status, headers: { "content-type": media === "*/*" ? "application/json" : media }, body: isJson(media) ? body : String(body ?? "") };
|
|
130
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
|
+
}
|
|
131
163
|
/** A value that satisfies `schema` (as far as a simple walk can): examples, defaults, enums, then types. */
|
|
132
164
|
#sample(schema, depth = 0) {
|
|
133
165
|
const s = this.#deref(schema);
|
|
@@ -274,7 +306,17 @@ export function formatCoverage(spec, hits) {
|
|
|
274
306
|
});
|
|
275
307
|
const covered = keys.filter((k) => hits.has(k)).length;
|
|
276
308
|
const percent = keys.length ? Math.round((covered / keys.length) * 100) : 100;
|
|
277
|
-
|
|
309
|
+
const markdown = [
|
|
310
|
+
`### slicetest: OpenAPI coverage ${percent}%`,
|
|
311
|
+
"",
|
|
312
|
+
`${covered} of ${keys.length} documented responses in \`${spec.file}\` were produced by a scenario.`,
|
|
313
|
+
"",
|
|
314
|
+
"| Operation | Responses |",
|
|
315
|
+
"|---|---|",
|
|
316
|
+
...[...byOp].map(([op, statuses]) => `| \`${op}\` | ${statuses.map((s) => `${hits.has(`${op} ${s}`) ? "✅" : "❌"} ${s}`).join(" ")} |`),
|
|
317
|
+
"",
|
|
318
|
+
].join("\n");
|
|
319
|
+
return { covered, total: keys.length, percent, markdown, text: `slicetest: OpenAPI coverage (${spec.file}): ${covered}/${keys.length} documented responses (${percent}%)\n${lines.join("\n")}` };
|
|
278
320
|
}
|
|
279
321
|
function isJson(media) {
|
|
280
322
|
return /[/+]json$/i.test(media) || media === "*/*";
|
package/dist/provided.d.ts
CHANGED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type SlicetestOptions } from "./config.js";
|
|
2
|
+
/** The main-process side of `npx slicetest record`: run the session, tell the user where to send requests, stop on Enter. */
|
|
3
|
+
export declare function record(configPath: string, options: SlicetestOptions, { out, port }: {
|
|
4
|
+
out?: string;
|
|
5
|
+
port?: number;
|
|
6
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { createInterface } from "node:readline";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { resolveOptions } from "./config.js";
|
|
8
|
+
import { SESSION_ENV } from "./record.js";
|
|
9
|
+
const ext = path.extname(fileURLToPath(import.meta.url));
|
|
10
|
+
const sessionFile = fileURLToPath(new URL(`./record-session${ext}`, import.meta.url));
|
|
11
|
+
/** The main-process side of `npx slicetest record`: run the session, tell the user where to send requests, stop on Enter. */
|
|
12
|
+
export async function record(configPath, options, { out, port = 0 }) {
|
|
13
|
+
const root = path.dirname(configPath);
|
|
14
|
+
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace("T", "-").slice(0, 15);
|
|
15
|
+
const file = path.resolve(root, out ?? path.join("scenarios", `recorded-${stamp}.scenario.yaml`));
|
|
16
|
+
if (existsSync(file))
|
|
17
|
+
throw new Error(`slicetest record: ${path.relative(process.cwd(), file)} already exists. Pass --out <file>.`);
|
|
18
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535)
|
|
19
|
+
throw new Error(`slicetest record: --port must be a port number, got ${port}`);
|
|
20
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
21
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), "slicetest-record-"));
|
|
22
|
+
const session = {
|
|
23
|
+
stateFile: path.join(dir, "state.json"),
|
|
24
|
+
stopFile: path.join(dir, "stop"),
|
|
25
|
+
out: file,
|
|
26
|
+
port,
|
|
27
|
+
stubs: resolveOptions(options, root).stubs,
|
|
28
|
+
};
|
|
29
|
+
process.env[SESSION_ENV] = JSON.stringify(session);
|
|
30
|
+
const stop = () => void writeFile(session.stopFile, "").catch(() => { });
|
|
31
|
+
process.once("SIGINT", stop);
|
|
32
|
+
const { startVitest } = await import("vitest/node");
|
|
33
|
+
const { slicetest } = await import("./vitest.js");
|
|
34
|
+
const announce = watchState(session.stateFile, (state) => {
|
|
35
|
+
process.stdout.write(`\nRecording. Use the app through ${state.url} (it forwards to ${state.app}).\n` +
|
|
36
|
+
"Everything it does is captured: responses, calls to stubs, database changes.\n" +
|
|
37
|
+
"Press Enter (or Ctrl+C) to finish and write the scenario.\n\n");
|
|
38
|
+
const rl = createInterface({ input: process.stdin });
|
|
39
|
+
rl.once("line", () => {
|
|
40
|
+
rl.close();
|
|
41
|
+
stop();
|
|
42
|
+
});
|
|
43
|
+
process.stdin.once("end", stop);
|
|
44
|
+
});
|
|
45
|
+
try {
|
|
46
|
+
const vitest = await startVitest([], { config: false, root, include: [sessionFile.replace(/\\/g, "/")], exclude: [], watch: false, run: true, reporters: ["dot"] }, { plugins: [slicetest(options)] });
|
|
47
|
+
await vitest?.close();
|
|
48
|
+
const state = existsSync(session.stateFile) ? JSON.parse(await readFile(session.stateFile, "utf8")) : {};
|
|
49
|
+
if (!state.done || !state.summary) {
|
|
50
|
+
process.stderr.write("slicetest record: the session ended before anything was written (see the output above).\n");
|
|
51
|
+
process.exitCode = 1;
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
// The session is written; a failure after it (e.g. an unmatched stub call) is reported in the file's header.
|
|
55
|
+
process.exitCode = 0;
|
|
56
|
+
const s = state.summary;
|
|
57
|
+
process.stdout.write(`\nWrote ${path.relative(process.cwd(), file)}: ${s.requests} request(s), ${s.stubs} stub route(s), changes in ${s.tables.length ? s.tables.join(", ") : "no tables"}.\n` +
|
|
58
|
+
(s.skippedAssets ? `Left out ${s.skippedAssets} request(s) for static files.\n` : "") +
|
|
59
|
+
(s.unanswered.length ? `Calls no stub answered (see the file's header): ${s.unanswered.join(", ")}\n` : "") +
|
|
60
|
+
`Replay it with: npx slicetest ${path.relative(root, file).replace(/\\/g, "/")}\n`);
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
announce.cancel();
|
|
64
|
+
process.off("SIGINT", stop);
|
|
65
|
+
// The Enter listener would otherwise keep the process alive.
|
|
66
|
+
process.stdin.destroy();
|
|
67
|
+
delete process.env[SESSION_ENV];
|
|
68
|
+
await rm(dir, { recursive: true, force: true });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function watchState(file, onReady) {
|
|
72
|
+
let cancelled = false;
|
|
73
|
+
void (async () => {
|
|
74
|
+
while (!cancelled) {
|
|
75
|
+
if (existsSync(file)) {
|
|
76
|
+
try {
|
|
77
|
+
const state = JSON.parse(await readFile(file, "utf8"));
|
|
78
|
+
if (state.url)
|
|
79
|
+
return onReady(state);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// Partly written; read it again.
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
86
|
+
}
|
|
87
|
+
})();
|
|
88
|
+
return {
|
|
89
|
+
cancel: () => {
|
|
90
|
+
cancelled = true;
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The test file `npx slicetest record` runs: one scenario that lasts until the
|
|
3
|
+
* user is done, so the session gets the same app, database and stubs as tests.
|
|
4
|
+
*/
|
|
5
|
+
import { SESSION_ENV, runSession } from "./record.js";
|
|
6
|
+
import { scenario } from "./scenario.js";
|
|
7
|
+
const session = JSON.parse(process.env[SESSION_ENV] ?? "null");
|
|
8
|
+
if (!session)
|
|
9
|
+
throw new Error("slicetest: this file is run by `npx slicetest record`");
|
|
10
|
+
scenario("slicetest record", (ctx) => runSession(ctx, session), 24 * 60 * 60 * 1000);
|
package/dist/record.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Changes } from "./db.js";
|
|
2
|
+
import type { ScenarioContext } from "./runtime.js";
|
|
3
|
+
import type { RecordedCall } from "./stub.js";
|
|
4
|
+
/**
|
|
5
|
+
* `npx slicetest record`: use the app for real (a browser, curl, a mobile
|
|
6
|
+
* client) through a proxy, and get the session back as a YAML scenario: the
|
|
7
|
+
* requests, the responses to expect, the stubbed services' replies and the
|
|
8
|
+
* database changes. The session runs as one long scenario, so the app, the
|
|
9
|
+
* database and the stubs are the same ones the tests use.
|
|
10
|
+
*/
|
|
11
|
+
/** Passed from the CLI to the session through the environment. */
|
|
12
|
+
export interface RecordSession {
|
|
13
|
+
/** Written by the session: `{ "url": … }` once the proxy listens, `{ "done": … }` at the end. */
|
|
14
|
+
stateFile: string;
|
|
15
|
+
/** Created by the CLI when the user is done. */
|
|
16
|
+
stopFile: string;
|
|
17
|
+
out: string;
|
|
18
|
+
port: number;
|
|
19
|
+
stubs: string[];
|
|
20
|
+
}
|
|
21
|
+
export declare const SESSION_ENV = "SLICETEST_RECORD_SESSION";
|
|
22
|
+
export interface Exchange {
|
|
23
|
+
method: string;
|
|
24
|
+
path: string;
|
|
25
|
+
contentType?: string;
|
|
26
|
+
body: string;
|
|
27
|
+
status: number;
|
|
28
|
+
responseType?: string;
|
|
29
|
+
response: string;
|
|
30
|
+
}
|
|
31
|
+
export declare function runSession(ctx: ScenarioContext, session: RecordSession): Promise<void>;
|
|
32
|
+
export interface Summary {
|
|
33
|
+
requests: number;
|
|
34
|
+
skippedAssets: number;
|
|
35
|
+
stubs: number;
|
|
36
|
+
tables: string[];
|
|
37
|
+
unanswered: string[];
|
|
38
|
+
}
|
|
39
|
+
/** The recorded session as a YAML scenario, with a comment header of what to review. */
|
|
40
|
+
export declare function buildScenario(exchanges: Exchange[], calls: Record<string, RecordedCall[]>, changes: Changes, { name, now }?: {
|
|
41
|
+
name?: string | undefined;
|
|
42
|
+
now?: Date | undefined;
|
|
43
|
+
}): {
|
|
44
|
+
yaml: string;
|
|
45
|
+
summary: Summary;
|
|
46
|
+
};
|