slicetest 0.3.0 → 0.5.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 +211 -7
- package/dist/auth.d.ts +45 -0
- package/dist/auth.js +128 -0
- package/dist/ci.d.ts +29 -0
- package/dist/ci.js +57 -0
- package/dist/cli.js +31 -3
- package/dist/config.d.ts +25 -2
- package/dist/config.js +25 -4
- package/dist/db.d.ts +26 -0
- package/dist/db.js +50 -0
- package/dist/doctor.d.ts +22 -0
- package/dist/doctor.js +190 -0
- package/dist/drivers/driver.d.ts +29 -0
- package/dist/drivers/index.js +2 -0
- package/dist/drivers/mysql.d.ts +2 -1
- package/dist/drivers/mysql.js +38 -0
- package/dist/drivers/postgres.d.ts +2 -1
- package/dist/drivers/postgres.js +28 -0
- package/dist/drivers/sqlite.d.ts +24 -0
- package/dist/drivers/sqlite.js +255 -0
- package/dist/factory.d.ts +21 -0
- package/dist/factory.js +129 -0
- package/dist/gen.d.ts +1 -0
- package/dist/gen.js +9 -4
- package/dist/global-setup.js +24 -3
- package/dist/http.d.ts +13 -0
- package/dist/http.js +24 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +2 -0
- package/dist/init.js +123 -5
- 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 +9 -0
- package/dist/openapi.js +29 -1
- package/dist/provided.d.ts +1 -0
- package/dist/query-log.d.ts +49 -0
- package/dist/query-log.js +261 -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/runtime.d.ts +10 -0
- package/dist/runtime.js +62 -3
- package/dist/stub.d.ts +32 -0
- package/dist/stub.js +87 -0
- package/dist/trace.d.ts +9 -1
- package/dist/trace.js +3 -1
- package/dist/vitest.js +5 -2
- package/dist/webhook.d.ts +40 -0
- package/dist/webhook.js +52 -0
- package/dist/yaml-runtime.d.ts +1 -0
- package/dist/yaml-runtime.js +92 -15
- package/dist/yaml.d.ts +59 -1
- package/dist/yaml.js +88 -5
- package/package.json +11 -4
- package/schema/scenario.schema.json +329 -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
|
+
}
|
package/dist/runtime.d.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { App } from "./app.js";
|
|
2
|
+
import { Issuer } from "./auth.js";
|
|
2
3
|
import type { ResolvedOptions } from "./config.js";
|
|
3
4
|
import { Dependency } from "./containers.js";
|
|
4
5
|
import { Db } from "./db.js";
|
|
5
6
|
import { HttpClient } from "./http.js";
|
|
7
|
+
import { Mailbox } from "./mail.js";
|
|
8
|
+
import { QueryLog } from "./query-log.js";
|
|
6
9
|
import { Stub } from "./stub.js";
|
|
7
10
|
import { type MaskOptions, type Trace } from "./trace.js";
|
|
8
11
|
export interface ScenarioContext {
|
|
@@ -19,6 +22,10 @@ export interface ScenarioContext {
|
|
|
19
22
|
* with timestamps and UUIDs masked. `expect(await trace()).toMatchSnapshot()`.
|
|
20
23
|
*/
|
|
21
24
|
trace: (opts?: MaskOptions) => Promise<Trace>;
|
|
25
|
+
/** Mail the app sent during the scenario. Needs `mail: true` in the config. */
|
|
26
|
+
mail: Mailbox;
|
|
27
|
+
/** The OpenID Connect issuer the app trusts: `auth.token(claims)`. Needs `auth` in the config. */
|
|
28
|
+
auth: Issuer;
|
|
22
29
|
}
|
|
23
30
|
/** Everything one test file needs: its own database, stub servers and app process. */
|
|
24
31
|
export declare class Runtime {
|
|
@@ -34,6 +41,9 @@ export declare class Runtime {
|
|
|
34
41
|
private readonly recorders;
|
|
35
42
|
private readonly recordDir?;
|
|
36
43
|
readonly containers: Map<string, Dependency>;
|
|
44
|
+
readonly mailbox?: Mailbox | undefined;
|
|
45
|
+
readonly issuer?: Issuer | undefined;
|
|
46
|
+
readonly queryLog?: QueryLog | undefined;
|
|
37
47
|
private constructor();
|
|
38
48
|
get http(): HttpClient;
|
|
39
49
|
static start(opts: ResolvedOptions, shared: {
|
package/dist/runtime.js
CHANGED
|
@@ -2,11 +2,14 @@ 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 { Issuer } from "./auth.js";
|
|
5
6
|
import { Dependency } from "./containers.js";
|
|
6
7
|
import { Db, formatChanges } from "./db.js";
|
|
7
8
|
import { engineFor } from "./drivers/index.js";
|
|
8
9
|
import { formatHistory, HttpClient } from "./http.js";
|
|
10
|
+
import { Mailbox } from "./mail.js";
|
|
9
11
|
import { OpenApiSpec } from "./openapi.js";
|
|
12
|
+
import { QueryLog } from "./query-log.js";
|
|
10
13
|
import { Recorder } from "./recording.js";
|
|
11
14
|
import { Stub } from "./stub.js";
|
|
12
15
|
import { buildTrace, mask } from "./trace.js";
|
|
@@ -23,12 +26,15 @@ export class Runtime {
|
|
|
23
26
|
recorders;
|
|
24
27
|
recordDir;
|
|
25
28
|
containers;
|
|
29
|
+
mailbox;
|
|
30
|
+
issuer;
|
|
31
|
+
queryLog;
|
|
26
32
|
#http;
|
|
27
33
|
/** Responses from the app that don't match its OpenAPI spec, this scenario. */
|
|
28
34
|
#contract = [];
|
|
29
35
|
/** Documented responses seen in this file, for the run's coverage report. */
|
|
30
36
|
#covered = new Set();
|
|
31
|
-
constructor(app, services, db, stubs, opts, vars, specs, coverageDir, recorders = new Map(), recordDir, containers = new Map()) {
|
|
37
|
+
constructor(app, services, db, stubs, opts, vars, specs, coverageDir, recorders = new Map(), recordDir, containers = new Map(), mailbox, issuer, queryLog) {
|
|
32
38
|
this.app = app;
|
|
33
39
|
this.services = services;
|
|
34
40
|
this.db = db;
|
|
@@ -40,6 +46,9 @@ export class Runtime {
|
|
|
40
46
|
this.recorders = recorders;
|
|
41
47
|
this.recordDir = recordDir;
|
|
42
48
|
this.containers = containers;
|
|
49
|
+
this.mailbox = mailbox;
|
|
50
|
+
this.issuer = issuer;
|
|
51
|
+
this.queryLog = queryLog;
|
|
43
52
|
this.#http = this.#client();
|
|
44
53
|
}
|
|
45
54
|
#client() {
|
|
@@ -72,6 +81,9 @@ export class Runtime {
|
|
|
72
81
|
const containers = new Map();
|
|
73
82
|
const services = new Map();
|
|
74
83
|
let db;
|
|
84
|
+
let mailbox;
|
|
85
|
+
let issuer;
|
|
86
|
+
let queryLog;
|
|
75
87
|
try {
|
|
76
88
|
// Loaded first: a broken spec should fail before anything is started.
|
|
77
89
|
const specs = {
|
|
@@ -102,6 +114,22 @@ export class Runtime {
|
|
|
102
114
|
if (failed)
|
|
103
115
|
throw failed.reason;
|
|
104
116
|
const vars = { "db.url": url };
|
|
117
|
+
if (opts.db.queries && engine.name !== "sqlite") {
|
|
118
|
+
const u = new URL(url);
|
|
119
|
+
queryLog = await QueryLog.start(engine.name, { host: u.hostname, port: Number(u.port || (engine.name === "mysql" ? 3306 : 5432)) });
|
|
120
|
+
vars["db.url"] = queryLog.proxyUrl(url);
|
|
121
|
+
db.attachQueryLog(queryLog);
|
|
122
|
+
}
|
|
123
|
+
if (engine.name === "sqlite")
|
|
124
|
+
vars["db.path"] = (await import("./drivers/sqlite.js")).sqlitePath(url);
|
|
125
|
+
if (opts.mail) {
|
|
126
|
+
mailbox = await Mailbox.start();
|
|
127
|
+
Object.assign(vars, { "mail.host": mailbox.host, "mail.port": String(mailbox.port), "mail.url": mailbox.url });
|
|
128
|
+
}
|
|
129
|
+
if (opts.auth) {
|
|
130
|
+
issuer = await Issuer.start(opts.auth);
|
|
131
|
+
Object.assign(vars, { "auth.issuer": issuer.url, "auth.jwks": issuer.jwksUrl, "auth.audience": issuer.audience });
|
|
132
|
+
}
|
|
105
133
|
for (const [name, c] of containers) {
|
|
106
134
|
vars[`container.${name}`] = c.address;
|
|
107
135
|
vars[`container.${name}.host`] = c.host;
|
|
@@ -116,17 +144,22 @@ export class Runtime {
|
|
|
116
144
|
vars[`service.${name}.port`] = String(started.port);
|
|
117
145
|
}
|
|
118
146
|
const app = await App.start(opts.app, opts.root, vars);
|
|
119
|
-
return new Runtime(app, services, db, stubs, opts, vars, specs, shared.coverageDir, recorders, shared.recordDir, containers);
|
|
147
|
+
return new Runtime(app, services, db, stubs, opts, vars, specs, shared.coverageDir, recorders, shared.recordDir, containers, mailbox, issuer, queryLog);
|
|
120
148
|
}
|
|
121
149
|
catch (e) {
|
|
122
150
|
await Promise.all([...services.values()].map((s) => s.stop()));
|
|
123
151
|
await db?.close();
|
|
124
152
|
await Promise.all([...stubs.values()].map((s) => s.close()));
|
|
125
153
|
await Promise.allSettled([...containers.values()].map((c) => c.stop()));
|
|
154
|
+
await mailbox?.close();
|
|
155
|
+
await issuer?.close();
|
|
156
|
+
await queryLog?.close();
|
|
126
157
|
throw e;
|
|
127
158
|
}
|
|
128
159
|
}
|
|
129
160
|
context() {
|
|
161
|
+
const mailbox = this.mailbox;
|
|
162
|
+
const issuer = this.issuer;
|
|
130
163
|
return {
|
|
131
164
|
http: this.http,
|
|
132
165
|
db: this.db,
|
|
@@ -151,7 +184,17 @@ export class Runtime {
|
|
|
151
184
|
throw new Error(`slicetest: unknown container "${name}". Declared containers: ${[...this.containers.keys()].join(", ") || "(none)"}`);
|
|
152
185
|
return c;
|
|
153
186
|
},
|
|
154
|
-
trace: async (opts) => mask(buildTrace(this.http.history, this.stubs.values(), await this.db.changesSinceStart()), opts),
|
|
187
|
+
trace: async (opts) => mask(buildTrace(this.http.history, this.stubs.values(), await this.db.changesSinceStart(), this.mailbox), opts),
|
|
188
|
+
get mail() {
|
|
189
|
+
if (!mailbox)
|
|
190
|
+
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}}.");
|
|
191
|
+
return mailbox;
|
|
192
|
+
},
|
|
193
|
+
get auth() {
|
|
194
|
+
if (!issuer)
|
|
195
|
+
throw new Error("slicetest: auth is off. Add `auth: true` to the config and point the app's JWT settings at {{auth.issuer}} / {{auth.jwks}}.");
|
|
196
|
+
return issuer;
|
|
197
|
+
},
|
|
155
198
|
};
|
|
156
199
|
}
|
|
157
200
|
/** The app and every service, for checks that apply to all of them. */
|
|
@@ -177,6 +220,9 @@ export class Runtime {
|
|
|
177
220
|
stub.reset();
|
|
178
221
|
for (const recorder of this.recorders.values())
|
|
179
222
|
recorder.reset();
|
|
223
|
+
this.mailbox?.reset();
|
|
224
|
+
this.issuer?.reset();
|
|
225
|
+
this.queryLog?.reset();
|
|
180
226
|
await Promise.all([...this.containers.values()].map((c) => c.reset()));
|
|
181
227
|
this.http.reset();
|
|
182
228
|
for (const p of this.#processes())
|
|
@@ -247,6 +293,9 @@ export class Runtime {
|
|
|
247
293
|
const unmatched = this.#unmatched();
|
|
248
294
|
if (unmatched.length > 0)
|
|
249
295
|
sections.push(`stub calls with no matching route:\n${unmatched.join("\n")}`);
|
|
296
|
+
const chaos = [...this.stubs.values()].map((s) => s.describeChaos()).filter((c) => c !== undefined);
|
|
297
|
+
if (chaos.length > 0)
|
|
298
|
+
sections.push(chaos.join("\n"));
|
|
250
299
|
const contract = this.#contractViolations();
|
|
251
300
|
if (contract.length > 0)
|
|
252
301
|
sections.push(`OpenAPI mismatches:\n${contract.map((c) => ` ${c}`).join("\n")}`);
|
|
@@ -259,6 +308,13 @@ export class Runtime {
|
|
|
259
308
|
catch (e) {
|
|
260
309
|
sections.push(`database changes during this scenario: unavailable (${e.message})`);
|
|
261
310
|
}
|
|
311
|
+
if (this.mailbox)
|
|
312
|
+
sections.push(this.mailbox.describe());
|
|
313
|
+
if (this.queryLog) {
|
|
314
|
+
const q = this.queryLog.queries();
|
|
315
|
+
const top = q.shapes().slice(0, 5).map((s) => ` ${s.count > 1 ? `×${s.count} ` : ""}${s.sql.length > 160 ? `${s.sql.slice(0, 157)}...` : s.sql}`);
|
|
316
|
+
sections.push(q.length ? `SQL the app ran during this scenario (${q.length} statements, most frequent first):\n${top.join("\n")}` : "SQL the app ran during this scenario: (none)");
|
|
317
|
+
}
|
|
262
318
|
const logs = this.app.scenarioLogs();
|
|
263
319
|
sections.push(logs ? `app output during this scenario:\n${logs}` : "app output during this scenario: (none)");
|
|
264
320
|
for (const [name, service] of this.services) {
|
|
@@ -282,6 +338,9 @@ export class Runtime {
|
|
|
282
338
|
this.db.close(),
|
|
283
339
|
...[...this.stubs.values()].map((s) => s.close()),
|
|
284
340
|
...[...this.containers.values()].map((c) => c.stop()),
|
|
341
|
+
this.mailbox?.close(),
|
|
342
|
+
this.issuer?.close(),
|
|
343
|
+
this.queryLog?.close(),
|
|
285
344
|
]);
|
|
286
345
|
const failed = results.find((r) => r.status === "rejected");
|
|
287
346
|
if (failed)
|
package/dist/stub.d.ts
CHANGED
|
@@ -19,6 +19,27 @@ export interface RecordedCall {
|
|
|
19
19
|
};
|
|
20
20
|
/** Answered by the fallback (e.g. an example from the provider's OpenAPI spec), not a registered route. */
|
|
21
21
|
fallback?: boolean;
|
|
22
|
+
/** The fault `chaos()` injected instead of the normal answer: `503`, `reset`. */
|
|
23
|
+
fault?: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Faults injected into a stub's answers, to test the app's retries, timeouts and
|
|
27
|
+
* fallbacks. Random faults come from a seeded generator, so a failing run can be
|
|
28
|
+
* replayed with the seed printed in the failure output.
|
|
29
|
+
*/
|
|
30
|
+
export interface ChaosOptions {
|
|
31
|
+
/** Fail the first `n` calls, then answer normally: the shape of a retry test. */
|
|
32
|
+
failFirst?: number;
|
|
33
|
+
/** Share of calls (0–1) answered with one of `statuses`. */
|
|
34
|
+
errorRate?: number;
|
|
35
|
+
/** Error statuses to pick from. Default [500, 502, 503]; 429 and 503 come with `Retry-After: 1`. */
|
|
36
|
+
statuses?: number[];
|
|
37
|
+
/** Share of calls (0–1) whose connection is dropped without an answer. */
|
|
38
|
+
networkErrorRate?: number;
|
|
39
|
+
/** Extra delay for every call, in ms: a fixed value or a [min, max] range. */
|
|
40
|
+
latency?: number | [number, number];
|
|
41
|
+
/** Seed for the random choices. Default: `SLICETEST_CHAOS_SEED`, else random. */
|
|
42
|
+
seed?: number;
|
|
22
43
|
}
|
|
23
44
|
export interface StubResponse {
|
|
24
45
|
status?: number;
|
|
@@ -70,6 +91,17 @@ export declare class Stub {
|
|
|
70
91
|
* `call.params`) or be a RegExp; `method` may be `*`. Later routes win.
|
|
71
92
|
*/
|
|
72
93
|
on(method: string, path: string | RegExp, match?: MatchOptions): RouteBuilder;
|
|
94
|
+
/**
|
|
95
|
+
* Inject faults into this stub's answers for the rest of the scenario:
|
|
96
|
+
* `stub("payments").chaos({ failFirst: 2 })` to test a retry,
|
|
97
|
+
* `chaos({ errorRate: 0.3, latency: [50, 200] })` to test resilience.
|
|
98
|
+
* Calls that fault don't use up `once()` / `times()` routes.
|
|
99
|
+
*/
|
|
100
|
+
chaos(opts: ChaosOptions): this;
|
|
101
|
+
/** Calls `chaos()` answered with a fault. */
|
|
102
|
+
faults(): RecordedCall[];
|
|
103
|
+
/** The active `chaos()` settings and what they did, for failure output; undefined when off. */
|
|
104
|
+
describeChaos(): string | undefined;
|
|
73
105
|
/** Calls received so far, optionally filtered by method, path and conditions (same syntax as `on()`). */
|
|
74
106
|
calls(method?: string, path?: string | RegExp, match?: MatchOptions): RecordedCall[];
|
|
75
107
|
unmatched(): RecordedCall[];
|
package/dist/stub.js
CHANGED
|
@@ -12,6 +12,7 @@ export class Stub {
|
|
|
12
12
|
#fallback;
|
|
13
13
|
/** Appended to the 501 answer for a call nothing could answer. */
|
|
14
14
|
#hint;
|
|
15
|
+
#chaos;
|
|
15
16
|
url = "";
|
|
16
17
|
constructor(name) {
|
|
17
18
|
this.name = name;
|
|
@@ -61,6 +62,74 @@ export class Stub {
|
|
|
61
62
|
};
|
|
62
63
|
return builder;
|
|
63
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Inject faults into this stub's answers for the rest of the scenario:
|
|
67
|
+
* `stub("payments").chaos({ failFirst: 2 })` to test a retry,
|
|
68
|
+
* `chaos({ errorRate: 0.3, latency: [50, 200] })` to test resilience.
|
|
69
|
+
* Calls that fault don't use up `once()` / `times()` routes.
|
|
70
|
+
*/
|
|
71
|
+
chaos(opts) {
|
|
72
|
+
for (const key of ["errorRate", "networkErrorRate"]) {
|
|
73
|
+
const v = opts[key];
|
|
74
|
+
if (v !== undefined && !(v >= 0 && v <= 1))
|
|
75
|
+
throw new Error(`slicetest: chaos ${key} must be between 0 and 1, got ${v}`);
|
|
76
|
+
}
|
|
77
|
+
if (opts.statuses && (opts.statuses.length === 0 || opts.statuses.some((s) => !Number.isInteger(s) || s < 400 || s > 599))) {
|
|
78
|
+
throw new Error(`slicetest: chaos statuses must be 4xx/5xx codes, got ${JSON.stringify(opts.statuses)}`);
|
|
79
|
+
}
|
|
80
|
+
const envSeed = Number(process.env.SLICETEST_CHAOS_SEED);
|
|
81
|
+
const seed = opts.seed ?? (Number.isInteger(envSeed) ? envSeed : Math.floor(Math.random() * 2 ** 31));
|
|
82
|
+
this.#chaos = { opts, seed, random: mulberry32(seed), calls: 0 };
|
|
83
|
+
return this;
|
|
84
|
+
}
|
|
85
|
+
/** Calls `chaos()` answered with a fault. */
|
|
86
|
+
faults() {
|
|
87
|
+
return this.#calls.filter((c) => c.fault !== undefined);
|
|
88
|
+
}
|
|
89
|
+
/** The active `chaos()` settings and what they did, for failure output; undefined when off. */
|
|
90
|
+
describeChaos() {
|
|
91
|
+
const c = this.#chaos;
|
|
92
|
+
if (!c)
|
|
93
|
+
return undefined;
|
|
94
|
+
const settings = Object.entries(c.opts)
|
|
95
|
+
.filter(([k]) => k !== "seed")
|
|
96
|
+
.map(([k, v]) => `${k} ${JSON.stringify(v)}`)
|
|
97
|
+
.join(", ");
|
|
98
|
+
return `chaos on ${this.name}: ${settings}; ${this.faults().length} of ${c.calls} calls faulted. Replay with SLICETEST_CHAOS_SEED=${c.seed}`;
|
|
99
|
+
}
|
|
100
|
+
/** Delay and fault for the next call, consuming the random sequence in a fixed order. */
|
|
101
|
+
async #injectFault() {
|
|
102
|
+
const c = this.#chaos;
|
|
103
|
+
if (!c)
|
|
104
|
+
return undefined;
|
|
105
|
+
const { opts, random } = c;
|
|
106
|
+
const n = ++c.calls;
|
|
107
|
+
const lat = opts.latency;
|
|
108
|
+
const ms = lat === undefined ? 0 : Array.isArray(lat) ? lat[0] + Math.floor(random() * (lat[1] - lat[0] + 1)) : lat;
|
|
109
|
+
if (ms > 0)
|
|
110
|
+
await new Promise((r) => setTimeout(r, ms));
|
|
111
|
+
const statuses = opts.statuses ?? [500, 502, 503];
|
|
112
|
+
const pick = () => statuses[Math.floor(random() * statuses.length)];
|
|
113
|
+
if (opts.failFirst !== undefined && n <= opts.failFirst)
|
|
114
|
+
return { status: pick() };
|
|
115
|
+
const roll = random();
|
|
116
|
+
if (roll < (opts.networkErrorRate ?? 0))
|
|
117
|
+
return "reset";
|
|
118
|
+
if (roll < (opts.networkErrorRate ?? 0) + (opts.errorRate ?? 0))
|
|
119
|
+
return { status: pick() };
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
#fail(call, req, res, fault) {
|
|
123
|
+
call.matched = true;
|
|
124
|
+
if (fault === "reset") {
|
|
125
|
+
call.fault = "reset";
|
|
126
|
+
req.socket.destroy();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
call.fault = String(fault.status);
|
|
130
|
+
const retry = fault.status === 429 || fault.status === 503 ? { "retry-after": "1" } : undefined;
|
|
131
|
+
this.#send(call, res, { status: fault.status, headers: retry, body: { error: "slicetest chaos", status: fault.status } });
|
|
132
|
+
}
|
|
64
133
|
/** Calls received so far, optionally filtered by method, path and conditions (same syntax as `on()`). */
|
|
65
134
|
calls(method, path, match = {}) {
|
|
66
135
|
const compiled = path === undefined ? undefined : compilePath(path);
|
|
@@ -82,6 +151,7 @@ export class Stub {
|
|
|
82
151
|
reset() {
|
|
83
152
|
this.#routes = [];
|
|
84
153
|
this.#calls = [];
|
|
154
|
+
this.#chaos = undefined;
|
|
85
155
|
}
|
|
86
156
|
/**
|
|
87
157
|
* Answer calls that no registered route matches, instead of failing with 501.
|
|
@@ -127,6 +197,12 @@ export class Stub {
|
|
|
127
197
|
route = r;
|
|
128
198
|
break;
|
|
129
199
|
}
|
|
200
|
+
// Faults only replace answers the stub would give: an unknown route still fails as unmatched.
|
|
201
|
+
if (route || this.#fallback) {
|
|
202
|
+
const fault = await this.#injectFault();
|
|
203
|
+
if (fault)
|
|
204
|
+
return this.#fail(call, req, res, fault);
|
|
205
|
+
}
|
|
130
206
|
if (!route) {
|
|
131
207
|
let out;
|
|
132
208
|
try {
|
|
@@ -262,3 +338,14 @@ function parseJson(body) {
|
|
|
262
338
|
return undefined;
|
|
263
339
|
}
|
|
264
340
|
}
|
|
341
|
+
/** A small seeded PRNG: the same seed gives the same faults on every run. */
|
|
342
|
+
function mulberry32(seed) {
|
|
343
|
+
let a = seed >>> 0;
|
|
344
|
+
return () => {
|
|
345
|
+
a = (a + 0x6d2b79f5) >>> 0;
|
|
346
|
+
let t = a;
|
|
347
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
348
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
349
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
350
|
+
};
|
|
351
|
+
}
|
package/dist/trace.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Changes } from "./db.js";
|
|
2
2
|
import type { HttpResponse } from "./http.js";
|
|
3
|
+
import type { Mailbox } from "./mail.js";
|
|
3
4
|
import type { Stub } from "./stub.js";
|
|
4
5
|
/**
|
|
5
6
|
* Everything one scenario did at the three boundaries: the requests it made to
|
|
@@ -19,6 +20,13 @@ export interface Trace {
|
|
|
19
20
|
status?: number;
|
|
20
21
|
}[]>;
|
|
21
22
|
db: Changes;
|
|
23
|
+
/** Mail the app sent, when the `mail` option is on. */
|
|
24
|
+
mail?: {
|
|
25
|
+
from: string;
|
|
26
|
+
to: string[];
|
|
27
|
+
subject: string;
|
|
28
|
+
text: string;
|
|
29
|
+
}[];
|
|
22
30
|
}
|
|
23
31
|
export interface MaskOptions {
|
|
24
32
|
/** Keys whose values are always replaced, e.g. `["token", "password_hash"]`. Matched at any depth. */
|
|
@@ -32,4 +40,4 @@ export interface MaskOptions {
|
|
|
32
40
|
* `[uuid]`, and the values of `keys` / strings matching `patterns` by `[masked]`.
|
|
33
41
|
*/
|
|
34
42
|
export declare function mask<T>(value: T, opts?: MaskOptions): T;
|
|
35
|
-
export declare function buildTrace(history: readonly HttpResponse[], stubs: Iterable<Stub>, db: Changes): Trace;
|
|
43
|
+
export declare function buildTrace(history: readonly HttpResponse[], stubs: Iterable<Stub>, db: Changes, mailbox?: Mailbox): Trace;
|
package/dist/trace.js
CHANGED
|
@@ -30,7 +30,7 @@ export function mask(value, opts = {}) {
|
|
|
30
30
|
};
|
|
31
31
|
return walk(value);
|
|
32
32
|
}
|
|
33
|
-
export function buildTrace(history, stubs, db) {
|
|
33
|
+
export function buildTrace(history, stubs, db, mailbox) {
|
|
34
34
|
const trace = {
|
|
35
35
|
http: history.map((r) => {
|
|
36
36
|
const url = new URL(r.url, "http://app");
|
|
@@ -53,5 +53,7 @@ export function buildTrace(history, stubs, db) {
|
|
|
53
53
|
};
|
|
54
54
|
});
|
|
55
55
|
}
|
|
56
|
+
if (mailbox)
|
|
57
|
+
trace.mail = mailbox.messages().map((m) => ({ from: m.from, to: m.to, subject: m.subject, text: m.text }));
|
|
56
58
|
return trace;
|
|
57
59
|
}
|
package/dist/vitest.js
CHANGED
|
@@ -2,6 +2,7 @@ import path from "node:path";
|
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
3
|
import { configDefaults } from "vitest/config";
|
|
4
4
|
import { resolveOptions } from "./config.js";
|
|
5
|
+
import { SESSION_ENV } from "./record.js";
|
|
5
6
|
import { parseScenarioFile } from "./yaml.js";
|
|
6
7
|
/** YAML scenario files are picked up next to the regular test files. */
|
|
7
8
|
export const YAML_SCENARIOS = "**/*.scenario.{yaml,yml}";
|
|
@@ -17,7 +18,9 @@ export function slicetest(options) {
|
|
|
17
18
|
const root = path.resolve(config.root ?? process.cwd());
|
|
18
19
|
// Mutated rather than returned: a returned list would replace Vitest's default include instead of extending it.
|
|
19
20
|
const test = (config.test ??= {});
|
|
20
|
-
|
|
21
|
+
// `slicetest record` runs only its session file.
|
|
22
|
+
if (!process.env[SESSION_ENV])
|
|
23
|
+
test.include = [...(test.include ?? configDefaults.include), YAML_SCENARIOS];
|
|
21
24
|
return {
|
|
22
25
|
test: {
|
|
23
26
|
globalSetup: [path.join(here, `global-setup${ext}`)],
|
|
@@ -31,7 +34,7 @@ export function slicetest(options) {
|
|
|
31
34
|
const file = id.split("?")[0];
|
|
32
35
|
if (!YAML_ID.test(file))
|
|
33
36
|
return;
|
|
34
|
-
const doc = parseScenarioFile(code, path.relative(root ?? process.cwd(), file) || file);
|
|
37
|
+
const doc = { ...parseScenarioFile(code, path.relative(root ?? process.cwd(), file) || file), path: file };
|
|
35
38
|
const runtime = JSON.stringify(path.join(here, `yaml-runtime${ext}`));
|
|
36
39
|
return { code: `import { defineYamlScenarios } from ${runtime};\ndefineYamlScenarios(${JSON.stringify(doc)});\n`, map: null };
|
|
37
40
|
},
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** A custom HMAC scheme: `header: prefix + hmac(secret, body)`. */
|
|
2
|
+
export interface HmacScheme {
|
|
3
|
+
header: string;
|
|
4
|
+
/** Default `sha256`. */
|
|
5
|
+
algorithm?: string;
|
|
6
|
+
/** Default `hex`. */
|
|
7
|
+
encoding?: "hex" | "base64";
|
|
8
|
+
/** Prepended to the digest, e.g. `sha256=`. */
|
|
9
|
+
prefix?: string;
|
|
10
|
+
}
|
|
11
|
+
export type WebhookProvider = "stripe" | "github" | "slack" | "shopify" | "standard" | HmacScheme;
|
|
12
|
+
export interface WebhookOptions {
|
|
13
|
+
provider: WebhookProvider;
|
|
14
|
+
/** The signing secret the app is configured with. For `standard` (Svix), `whsec_<base64>` or the raw key. */
|
|
15
|
+
secret: string;
|
|
16
|
+
/** Event type, sent where the provider puts it: `X-GitHub-Event`, `X-Shopify-Topic`. */
|
|
17
|
+
event?: string;
|
|
18
|
+
/** Unix seconds the signature is made for. Default: now. */
|
|
19
|
+
timestamp?: number;
|
|
20
|
+
/** Signed ten minutes ago: an app that rejects replays must refuse it. */
|
|
21
|
+
stale?: boolean;
|
|
22
|
+
/** Signed with a different secret: the app must refuse it. */
|
|
23
|
+
invalidSignature?: boolean;
|
|
24
|
+
/** Message id for `standard` (`webhook-id`). Default: a random `msg_…`. */
|
|
25
|
+
id?: string;
|
|
26
|
+
/** Extra request headers. */
|
|
27
|
+
headers?: Record<string, string>;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The headers a provider sends with `body`, signed with `secret` the way its
|
|
31
|
+
* SDK verifies them: Stripe's `Stripe-Signature`, GitHub's
|
|
32
|
+
* `X-Hub-Signature-256`, Slack's `v0` signature, Shopify's base64 HMAC and
|
|
33
|
+
* Standard Webhooks (Svix, Resend, Clerk, …).
|
|
34
|
+
*/
|
|
35
|
+
export declare function signWebhook(body: string, opts: WebhookOptions): Record<string, string>;
|
|
36
|
+
/** The bytes to send and their content type: objects as JSON, URLSearchParams as a form (Slack commands), strings as-is. */
|
|
37
|
+
export declare function webhookBody(payload: unknown): {
|
|
38
|
+
body: string;
|
|
39
|
+
type: string;
|
|
40
|
+
};
|