slicetest 0.3.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 +102 -6
- package/dist/ci.d.ts +29 -0
- package/dist/ci.js +57 -0
- package/dist/cli.js +23 -0
- package/dist/config.d.ts +11 -2
- package/dist/config.js +9 -4
- 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.js +2 -0
- package/dist/drivers/sqlite.d.ts +23 -0
- package/dist/drivers/sqlite.js +203 -0
- package/dist/global-setup.js +24 -3
- package/dist/http.d.ts +6 -0
- package/dist/http.js +14 -0
- package/dist/index.d.ts +1 -0
- package/dist/init.js +80 -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 +1 -0
- package/dist/openapi.js +11 -1
- package/dist/provided.d.ts +1 -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 +4 -0
- package/dist/runtime.js +24 -3
- package/dist/trace.d.ts +9 -1
- package/dist/trace.js +3 -1
- package/dist/vitest.js +5 -2
- package/dist/yaml-runtime.d.ts +1 -0
- package/dist/yaml-runtime.js +52 -13
- package/dist/yaml.d.ts +24 -1
- package/dist/yaml.js +34 -5
- package/package.json +4 -3
- package/schema/scenario.schema.json +149 -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
|
@@ -3,6 +3,7 @@ import type { ResolvedOptions } from "./config.js";
|
|
|
3
3
|
import { Dependency } from "./containers.js";
|
|
4
4
|
import { Db } from "./db.js";
|
|
5
5
|
import { HttpClient } from "./http.js";
|
|
6
|
+
import { Mailbox } from "./mail.js";
|
|
6
7
|
import { Stub } from "./stub.js";
|
|
7
8
|
import { type MaskOptions, type Trace } from "./trace.js";
|
|
8
9
|
export interface ScenarioContext {
|
|
@@ -19,6 +20,8 @@ export interface ScenarioContext {
|
|
|
19
20
|
* with timestamps and UUIDs masked. `expect(await trace()).toMatchSnapshot()`.
|
|
20
21
|
*/
|
|
21
22
|
trace: (opts?: MaskOptions) => Promise<Trace>;
|
|
23
|
+
/** Mail the app sent during the scenario. Needs `mail: true` in the config. */
|
|
24
|
+
mail: Mailbox;
|
|
22
25
|
}
|
|
23
26
|
/** Everything one test file needs: its own database, stub servers and app process. */
|
|
24
27
|
export declare class Runtime {
|
|
@@ -34,6 +37,7 @@ export declare class Runtime {
|
|
|
34
37
|
private readonly recorders;
|
|
35
38
|
private readonly recordDir?;
|
|
36
39
|
readonly containers: Map<string, Dependency>;
|
|
40
|
+
readonly mailbox?: Mailbox | undefined;
|
|
37
41
|
private constructor();
|
|
38
42
|
get http(): HttpClient;
|
|
39
43
|
static start(opts: ResolvedOptions, shared: {
|
package/dist/runtime.js
CHANGED
|
@@ -6,6 +6,7 @@ import { Dependency } from "./containers.js";
|
|
|
6
6
|
import { Db, formatChanges } from "./db.js";
|
|
7
7
|
import { engineFor } from "./drivers/index.js";
|
|
8
8
|
import { formatHistory, HttpClient } from "./http.js";
|
|
9
|
+
import { Mailbox } from "./mail.js";
|
|
9
10
|
import { OpenApiSpec } from "./openapi.js";
|
|
10
11
|
import { Recorder } from "./recording.js";
|
|
11
12
|
import { Stub } from "./stub.js";
|
|
@@ -23,12 +24,13 @@ export class Runtime {
|
|
|
23
24
|
recorders;
|
|
24
25
|
recordDir;
|
|
25
26
|
containers;
|
|
27
|
+
mailbox;
|
|
26
28
|
#http;
|
|
27
29
|
/** Responses from the app that don't match its OpenAPI spec, this scenario. */
|
|
28
30
|
#contract = [];
|
|
29
31
|
/** Documented responses seen in this file, for the run's coverage report. */
|
|
30
32
|
#covered = new Set();
|
|
31
|
-
constructor(app, services, db, stubs, opts, vars, specs, coverageDir, recorders = new Map(), recordDir, containers = new Map()) {
|
|
33
|
+
constructor(app, services, db, stubs, opts, vars, specs, coverageDir, recorders = new Map(), recordDir, containers = new Map(), mailbox) {
|
|
32
34
|
this.app = app;
|
|
33
35
|
this.services = services;
|
|
34
36
|
this.db = db;
|
|
@@ -40,6 +42,7 @@ export class Runtime {
|
|
|
40
42
|
this.recorders = recorders;
|
|
41
43
|
this.recordDir = recordDir;
|
|
42
44
|
this.containers = containers;
|
|
45
|
+
this.mailbox = mailbox;
|
|
43
46
|
this.#http = this.#client();
|
|
44
47
|
}
|
|
45
48
|
#client() {
|
|
@@ -72,6 +75,7 @@ export class Runtime {
|
|
|
72
75
|
const containers = new Map();
|
|
73
76
|
const services = new Map();
|
|
74
77
|
let db;
|
|
78
|
+
let mailbox;
|
|
75
79
|
try {
|
|
76
80
|
// Loaded first: a broken spec should fail before anything is started.
|
|
77
81
|
const specs = {
|
|
@@ -102,6 +106,12 @@ export class Runtime {
|
|
|
102
106
|
if (failed)
|
|
103
107
|
throw failed.reason;
|
|
104
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
|
+
}
|
|
105
115
|
for (const [name, c] of containers) {
|
|
106
116
|
vars[`container.${name}`] = c.address;
|
|
107
117
|
vars[`container.${name}.host`] = c.host;
|
|
@@ -116,17 +126,19 @@ export class Runtime {
|
|
|
116
126
|
vars[`service.${name}.port`] = String(started.port);
|
|
117
127
|
}
|
|
118
128
|
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);
|
|
129
|
+
return new Runtime(app, services, db, stubs, opts, vars, specs, shared.coverageDir, recorders, shared.recordDir, containers, mailbox);
|
|
120
130
|
}
|
|
121
131
|
catch (e) {
|
|
122
132
|
await Promise.all([...services.values()].map((s) => s.stop()));
|
|
123
133
|
await db?.close();
|
|
124
134
|
await Promise.all([...stubs.values()].map((s) => s.close()));
|
|
125
135
|
await Promise.allSettled([...containers.values()].map((c) => c.stop()));
|
|
136
|
+
await mailbox?.close();
|
|
126
137
|
throw e;
|
|
127
138
|
}
|
|
128
139
|
}
|
|
129
140
|
context() {
|
|
141
|
+
const mailbox = this.mailbox;
|
|
130
142
|
return {
|
|
131
143
|
http: this.http,
|
|
132
144
|
db: this.db,
|
|
@@ -151,7 +163,12 @@ export class Runtime {
|
|
|
151
163
|
throw new Error(`slicetest: unknown container "${name}". Declared containers: ${[...this.containers.keys()].join(", ") || "(none)"}`);
|
|
152
164
|
return c;
|
|
153
165
|
},
|
|
154
|
-
trace: async (opts) => mask(buildTrace(this.http.history, this.stubs.values(), await this.db.changesSinceStart()), opts),
|
|
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
|
+
},
|
|
155
172
|
};
|
|
156
173
|
}
|
|
157
174
|
/** The app and every service, for checks that apply to all of them. */
|
|
@@ -177,6 +194,7 @@ export class Runtime {
|
|
|
177
194
|
stub.reset();
|
|
178
195
|
for (const recorder of this.recorders.values())
|
|
179
196
|
recorder.reset();
|
|
197
|
+
this.mailbox?.reset();
|
|
180
198
|
await Promise.all([...this.containers.values()].map((c) => c.reset()));
|
|
181
199
|
this.http.reset();
|
|
182
200
|
for (const p of this.#processes())
|
|
@@ -259,6 +277,8 @@ export class Runtime {
|
|
|
259
277
|
catch (e) {
|
|
260
278
|
sections.push(`database changes during this scenario: unavailable (${e.message})`);
|
|
261
279
|
}
|
|
280
|
+
if (this.mailbox)
|
|
281
|
+
sections.push(this.mailbox.describe());
|
|
262
282
|
const logs = this.app.scenarioLogs();
|
|
263
283
|
sections.push(logs ? `app output during this scenario:\n${logs}` : "app output during this scenario: (none)");
|
|
264
284
|
for (const [name, service] of this.services) {
|
|
@@ -282,6 +302,7 @@ export class Runtime {
|
|
|
282
302
|
this.db.close(),
|
|
283
303
|
...[...this.stubs.values()].map((s) => s.close()),
|
|
284
304
|
...[...this.containers.values()].map((c) => c.stop()),
|
|
305
|
+
this.mailbox?.close(),
|
|
285
306
|
]);
|
|
286
307
|
const failed = results.find((r) => r.status === "rejected");
|
|
287
308
|
if (failed)
|
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
|
},
|
package/dist/yaml-runtime.d.ts
CHANGED
package/dist/yaml-runtime.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { expect } from "vitest";
|
|
1
|
+
import { expect, inject } from "vitest";
|
|
2
|
+
import { recordYamlFailure } from "./ci.js";
|
|
3
|
+
import "./provided.js";
|
|
2
4
|
import { formatChanges } from "./db.js";
|
|
3
5
|
import "./matchers.js";
|
|
4
6
|
import { scenario } from "./scenario.js";
|
|
@@ -17,11 +19,17 @@ async function runSteps(doc, sc, steps, ctx, vars) {
|
|
|
17
19
|
for (const [i, step] of steps.entries()) {
|
|
18
20
|
try {
|
|
19
21
|
// A log step waits by itself; `within` is its timeout.
|
|
20
|
-
|
|
22
|
+
// Log and mail steps wait by themselves; `within` is their timeout.
|
|
23
|
+
await retry("within" in step && !("log" in step) && !("mail" in step) ? step.within : undefined, () => runStep(step, ctx, vars));
|
|
21
24
|
}
|
|
22
25
|
catch (e) {
|
|
23
26
|
const label = step.name ?? describeStep(step);
|
|
24
27
|
const where = `${doc.file}:${step.line} (${sc.name}, step ${i + 1}: ${label})`;
|
|
28
|
+
const ciDir = inject("slicetestDb")?.ciDir;
|
|
29
|
+
if (ciDir && doc.path) {
|
|
30
|
+
const message = (e instanceof Error ? e.message : String(e)).replace(/\x1b\[[0-9;]*m/g, "");
|
|
31
|
+
await recordYamlFailure(ciDir, { file: doc.path, line: step.line, scenario: sc.name, step: `step ${i + 1}: ${label}`, message }).catch(() => { });
|
|
32
|
+
}
|
|
25
33
|
if (e instanceof Error) {
|
|
26
34
|
e.message = `${where}\n${e.message}`;
|
|
27
35
|
throw e;
|
|
@@ -32,7 +40,7 @@ async function runSteps(doc, sc, steps, ctx, vars) {
|
|
|
32
40
|
}
|
|
33
41
|
function describeStep(step) {
|
|
34
42
|
if ("request" in step)
|
|
35
|
-
return step.request;
|
|
43
|
+
return step.concurrency ? `${step.request} ×${step.concurrency}` : step.request;
|
|
36
44
|
if ("stub" in step)
|
|
37
45
|
return `stub ${step.stub} ${step.on}`;
|
|
38
46
|
if ("received" in step)
|
|
@@ -47,6 +55,8 @@ function describeStep(step) {
|
|
|
47
55
|
return "checkpoint";
|
|
48
56
|
if ("snapshot" in step)
|
|
49
57
|
return "snapshot";
|
|
58
|
+
if ("mail" in step)
|
|
59
|
+
return `mail${Object.entries(step.mail).map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`).join(",")}`;
|
|
50
60
|
if ("log" in step)
|
|
51
61
|
return `log ${step.from ? `from ${step.from} ` : ""}/${step.log}/`;
|
|
52
62
|
return "sql";
|
|
@@ -84,6 +94,24 @@ async function runStep(step, ctx, vars) {
|
|
|
84
94
|
await target.waitForLog(new RegExp(interpolate(step.log, vars)), step.within ?? 5000);
|
|
85
95
|
return;
|
|
86
96
|
}
|
|
97
|
+
if ("mail" in step) {
|
|
98
|
+
const filter = Object.fromEntries(Object.entries(interpolate(step.mail, vars)).map(([k, v]) => {
|
|
99
|
+
const re = v && typeof v === "object" && "$regex" in v ? new RegExp(String(v.$regex)) : undefined;
|
|
100
|
+
return [k, re ?? String(v)];
|
|
101
|
+
}));
|
|
102
|
+
const box = ctx.mail;
|
|
103
|
+
await retry(step.within ?? 5000, async () => {
|
|
104
|
+
const found = box.messages(filter);
|
|
105
|
+
if (step.times === undefined ? found.length === 0 : found.length !== step.times) {
|
|
106
|
+
const want = step.times === undefined ? "at least one message" : `${step.times} message(s)`;
|
|
107
|
+
throw new Error(`expected ${want} matching ${JSON.stringify(step.mail)}, got ${found.length}\n${box.describe()}`);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
const last = box.last(filter);
|
|
111
|
+
if (last)
|
|
112
|
+
capture(step.capture, last, vars);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
87
115
|
if ("changes" in step) {
|
|
88
116
|
const actual = await ctx.db.changes();
|
|
89
117
|
const expected = interpolate(step.changes, vars);
|
|
@@ -141,18 +169,29 @@ async function runStep(step, ctx, vars) {
|
|
|
141
169
|
: step.form !== undefined
|
|
142
170
|
? ctx.http.form(interpolate(step.form, vars))
|
|
143
171
|
: interpolate(step.body, vars);
|
|
144
|
-
const res = await ctx.http.request(method, path, body, opts);
|
|
145
172
|
const e = step.expect;
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
173
|
+
const verify = (res) => {
|
|
174
|
+
if (e?.status !== undefined)
|
|
175
|
+
expect(res).toHaveStatus(interpolate(e.status, vars));
|
|
176
|
+
if (e?.headers !== undefined) {
|
|
177
|
+
const expected = Object.fromEntries(Object.entries(e.headers).map(([k, v]) => [k.toLowerCase(), v]));
|
|
178
|
+
check(Object.fromEntries(res.headers), expected, vars, "response headers");
|
|
179
|
+
}
|
|
180
|
+
if (e?.json !== undefined)
|
|
181
|
+
check(res.json, e.json, vars, "response JSON");
|
|
182
|
+
if (e?.text !== undefined)
|
|
183
|
+
check(res.text, e.text, vars, "response text");
|
|
184
|
+
};
|
|
185
|
+
if (step.concurrency !== undefined) {
|
|
186
|
+
// Each request gets its own body: a URLSearchParams body can only be read once.
|
|
187
|
+
const all = await ctx.http.concurrently(step.concurrency, () => ctx.http.request(method, path, body instanceof URLSearchParams ? new URLSearchParams(body) : body, opts));
|
|
188
|
+
if (e?.statuses !== undefined)
|
|
189
|
+
expect(all).toHaveStatuses(interpolate(e.statuses, vars));
|
|
190
|
+
all.forEach(verify);
|
|
191
|
+
return;
|
|
151
192
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
if (e?.text !== undefined)
|
|
155
|
-
check(res.text, e.text, vars, "response text");
|
|
193
|
+
const res = await ctx.http.request(method, path, body, opts);
|
|
194
|
+
verify(res);
|
|
156
195
|
capture(step.capture, { status: res.status, json: res.json, text: res.text, headers: Object.fromEntries(res.headers) }, vars);
|
|
157
196
|
return;
|
|
158
197
|
}
|
package/dist/yaml.d.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
export interface YamlFile {
|
|
8
8
|
file: string;
|
|
9
|
+
/** Absolute path, for CI annotations. */
|
|
10
|
+
path?: string;
|
|
9
11
|
/** Steps run at the start of every scenario in the file. */
|
|
10
12
|
setup: Step[];
|
|
11
13
|
scenarios: YamlScenario[];
|
|
@@ -19,7 +21,7 @@ export interface YamlScenario {
|
|
|
19
21
|
timeout?: number;
|
|
20
22
|
steps: Step[];
|
|
21
23
|
}
|
|
22
|
-
export type Step = (StubStep | RequestStep | InsertStep | SqlStep | DbStep | ReceivedStep | ChangesStep | CheckpointStep | LogStep | SnapshotStep) & {
|
|
24
|
+
export type Step = (StubStep | RequestStep | InsertStep | SqlStep | DbStep | ReceivedStep | ChangesStep | CheckpointStep | LogStep | SnapshotStep | MailStep) & {
|
|
23
25
|
line: number;
|
|
24
26
|
name?: string;
|
|
25
27
|
};
|
|
@@ -55,8 +57,11 @@ export interface RequestStep {
|
|
|
55
57
|
form?: Record<string, unknown>;
|
|
56
58
|
body?: string;
|
|
57
59
|
follow?: boolean;
|
|
60
|
+
/** Send the request this many times at once. `expect` applies to every response; `statuses` counts them. */
|
|
61
|
+
concurrency?: number;
|
|
58
62
|
expect?: {
|
|
59
63
|
status?: number;
|
|
64
|
+
statuses?: Record<string, number>;
|
|
60
65
|
headers?: Record<string, unknown>;
|
|
61
66
|
json?: unknown;
|
|
62
67
|
text?: unknown;
|
|
@@ -129,6 +134,24 @@ export interface SnapshotStep {
|
|
|
129
134
|
/** Keys whose values are masked in addition to dates and UUIDs. */
|
|
130
135
|
mask?: string[];
|
|
131
136
|
}
|
|
137
|
+
/**
|
|
138
|
+
* The app sent mail matching `mail:` (strings are exact addresses for to/from and substrings
|
|
139
|
+
* otherwise; `{ $regex }` also works). Waits up to `within` ms (default 5000). `capture` reads
|
|
140
|
+
* the last match: `subject`, `text`, `links.0`.
|
|
141
|
+
*/
|
|
142
|
+
export interface MailStep {
|
|
143
|
+
mail: {
|
|
144
|
+
to?: unknown;
|
|
145
|
+
from?: unknown;
|
|
146
|
+
subject?: unknown;
|
|
147
|
+
text?: unknown;
|
|
148
|
+
html?: unknown;
|
|
149
|
+
};
|
|
150
|
+
/** Exact number of matching messages. Default: at least one. */
|
|
151
|
+
times?: number;
|
|
152
|
+
within?: number;
|
|
153
|
+
capture?: Record<string, string>;
|
|
154
|
+
}
|
|
132
155
|
export declare class YamlScenarioError extends Error {
|
|
133
156
|
}
|
|
134
157
|
export declare function parseScenarioFile(text: string, file: string): YamlFile;
|