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/webhook.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createHmac, randomUUID } from "node:crypto";
|
|
2
|
+
const hmac = (algorithm, key, data) => createHmac(algorithm, key).update(data);
|
|
3
|
+
/**
|
|
4
|
+
* The headers a provider sends with `body`, signed with `secret` the way its
|
|
5
|
+
* SDK verifies them: Stripe's `Stripe-Signature`, GitHub's
|
|
6
|
+
* `X-Hub-Signature-256`, Slack's `v0` signature, Shopify's base64 HMAC and
|
|
7
|
+
* Standard Webhooks (Svix, Resend, Clerk, …).
|
|
8
|
+
*/
|
|
9
|
+
export function signWebhook(body, opts) {
|
|
10
|
+
const now = Math.floor(Date.now() / 1000);
|
|
11
|
+
const ts = opts.timestamp ?? (opts.stale ? now - 600 : now);
|
|
12
|
+
const secret = opts.invalidSignature ? `${opts.secret}-not-the-real-secret` : opts.secret;
|
|
13
|
+
const p = opts.provider;
|
|
14
|
+
if (typeof p === "object") {
|
|
15
|
+
if (!p.header)
|
|
16
|
+
throw new Error("slicetest: a custom webhook provider needs `header`, e.g. { header: \"X-Signature\", prefix: \"sha256=\" }");
|
|
17
|
+
return { [p.header]: `${p.prefix ?? ""}${hmac(p.algorithm ?? "sha256", secret, body).digest(p.encoding ?? "hex")}` };
|
|
18
|
+
}
|
|
19
|
+
switch (p) {
|
|
20
|
+
case "stripe":
|
|
21
|
+
return { "stripe-signature": `t=${ts},v1=${hmac("sha256", secret, `${ts}.${body}`).digest("hex")}` };
|
|
22
|
+
case "github":
|
|
23
|
+
return {
|
|
24
|
+
"x-hub-signature-256": `sha256=${hmac("sha256", secret, body).digest("hex")}`,
|
|
25
|
+
"x-github-event": opts.event ?? "ping",
|
|
26
|
+
"x-github-delivery": randomUUID(),
|
|
27
|
+
};
|
|
28
|
+
case "slack":
|
|
29
|
+
return { "x-slack-request-timestamp": String(ts), "x-slack-signature": `v0=${hmac("sha256", secret, `v0:${ts}:${body}`).digest("hex")}` };
|
|
30
|
+
case "shopify":
|
|
31
|
+
return {
|
|
32
|
+
"x-shopify-hmac-sha256": hmac("sha256", secret, body).digest("base64"),
|
|
33
|
+
...(opts.event ? { "x-shopify-topic": opts.event } : {}),
|
|
34
|
+
"x-shopify-webhook-id": randomUUID(),
|
|
35
|
+
};
|
|
36
|
+
case "standard": {
|
|
37
|
+
const id = opts.id ?? `msg_${randomUUID().replace(/-/g, "")}`;
|
|
38
|
+
const key = secret.startsWith("whsec_") ? Buffer.from(secret.slice(6), "base64") : Buffer.from(secret);
|
|
39
|
+
return { "webhook-id": id, "webhook-timestamp": String(ts), "webhook-signature": `v1,${hmac("sha256", key, `${id}.${ts}.${body}`).digest("base64")}` };
|
|
40
|
+
}
|
|
41
|
+
default:
|
|
42
|
+
throw new Error(`slicetest: unknown webhook provider ${JSON.stringify(p)} (expected stripe, github, slack, shopify, standard or { header, prefix, encoding })`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** The bytes to send and their content type: objects as JSON, URLSearchParams as a form (Slack commands), strings as-is. */
|
|
46
|
+
export function webhookBody(payload) {
|
|
47
|
+
if (typeof payload === "string")
|
|
48
|
+
return { body: payload, type: "application/json" };
|
|
49
|
+
if (payload instanceof URLSearchParams)
|
|
50
|
+
return { body: payload.toString(), type: "application/x-www-form-urlencoded" };
|
|
51
|
+
return { body: JSON.stringify(payload ?? {}), type: "application/json" };
|
|
52
|
+
}
|
package/dist/yaml-runtime.d.ts
CHANGED
package/dist/yaml-runtime.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
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";
|
|
7
|
+
import { signWebhook, webhookBody } from "./webhook.js";
|
|
5
8
|
/** Registers the scenarios of one parsed YAML file. Called from the module the plugin generates for it. */
|
|
6
9
|
export function defineYamlScenarios(doc) {
|
|
7
10
|
for (const sc of doc.scenarios) {
|
|
@@ -17,11 +20,17 @@ async function runSteps(doc, sc, steps, ctx, vars) {
|
|
|
17
20
|
for (const [i, step] of steps.entries()) {
|
|
18
21
|
try {
|
|
19
22
|
// A log step waits by itself; `within` is its timeout.
|
|
20
|
-
|
|
23
|
+
// Log and mail steps wait by themselves; `within` is their timeout.
|
|
24
|
+
await retry("within" in step && !("log" in step) && !("mail" in step) ? step.within : undefined, () => runStep(step, ctx, vars));
|
|
21
25
|
}
|
|
22
26
|
catch (e) {
|
|
23
27
|
const label = step.name ?? describeStep(step);
|
|
24
28
|
const where = `${doc.file}:${step.line} (${sc.name}, step ${i + 1}: ${label})`;
|
|
29
|
+
const ciDir = inject("slicetestDb")?.ciDir;
|
|
30
|
+
if (ciDir && doc.path) {
|
|
31
|
+
const message = (e instanceof Error ? e.message : String(e)).replace(/\x1b\[[0-9;]*m/g, "");
|
|
32
|
+
await recordYamlFailure(ciDir, { file: doc.path, line: step.line, scenario: sc.name, step: `step ${i + 1}: ${label}`, message }).catch(() => { });
|
|
33
|
+
}
|
|
25
34
|
if (e instanceof Error) {
|
|
26
35
|
e.message = `${where}\n${e.message}`;
|
|
27
36
|
throw e;
|
|
@@ -32,13 +41,17 @@ async function runSteps(doc, sc, steps, ctx, vars) {
|
|
|
32
41
|
}
|
|
33
42
|
function describeStep(step) {
|
|
34
43
|
if ("request" in step)
|
|
35
|
-
return step.request;
|
|
44
|
+
return step.concurrency ? `${step.request} ×${step.concurrency}` : step.request;
|
|
36
45
|
if ("stub" in step)
|
|
37
46
|
return `stub ${step.stub} ${step.on}`;
|
|
38
47
|
if ("received" in step)
|
|
39
48
|
return `received ${step.received}${step.call ? ` ${step.call}` : ""}`;
|
|
40
49
|
if ("insert" in step)
|
|
41
50
|
return `insert ${step.insert}`;
|
|
51
|
+
if ("make" in step)
|
|
52
|
+
return `make ${step.make}`;
|
|
53
|
+
if ("chaos" in step)
|
|
54
|
+
return `chaos ${step.chaos}`;
|
|
42
55
|
if ("db" in step)
|
|
43
56
|
return `db ${step.db}`;
|
|
44
57
|
if ("changes" in step)
|
|
@@ -47,6 +60,8 @@ function describeStep(step) {
|
|
|
47
60
|
return "checkpoint";
|
|
48
61
|
if ("snapshot" in step)
|
|
49
62
|
return "snapshot";
|
|
63
|
+
if ("mail" in step)
|
|
64
|
+
return `mail${Object.entries(step.mail).map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`).join(",")}`;
|
|
50
65
|
if ("log" in step)
|
|
51
66
|
return `log ${step.from ? `from ${step.from} ` : ""}/${step.log}/`;
|
|
52
67
|
return "sql";
|
|
@@ -84,6 +99,24 @@ async function runStep(step, ctx, vars) {
|
|
|
84
99
|
await target.waitForLog(new RegExp(interpolate(step.log, vars)), step.within ?? 5000);
|
|
85
100
|
return;
|
|
86
101
|
}
|
|
102
|
+
if ("mail" in step) {
|
|
103
|
+
const filter = Object.fromEntries(Object.entries(interpolate(step.mail, vars)).map(([k, v]) => {
|
|
104
|
+
const re = v && typeof v === "object" && "$regex" in v ? new RegExp(String(v.$regex)) : undefined;
|
|
105
|
+
return [k, re ?? String(v)];
|
|
106
|
+
}));
|
|
107
|
+
const box = ctx.mail;
|
|
108
|
+
await retry(step.within ?? 5000, async () => {
|
|
109
|
+
const found = box.messages(filter);
|
|
110
|
+
if (step.times === undefined ? found.length === 0 : found.length !== step.times) {
|
|
111
|
+
const want = step.times === undefined ? "at least one message" : `${step.times} message(s)`;
|
|
112
|
+
throw new Error(`expected ${want} matching ${JSON.stringify(step.mail)}, got ${found.length}\n${box.describe()}`);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
const last = box.last(filter);
|
|
116
|
+
if (last)
|
|
117
|
+
capture(step.capture, last, vars);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
87
120
|
if ("changes" in step) {
|
|
88
121
|
const actual = await ctx.db.changes();
|
|
89
122
|
const expected = interpolate(step.changes, vars);
|
|
@@ -131,28 +164,56 @@ async function runStep(step, ctx, vars) {
|
|
|
131
164
|
if ("request" in step) {
|
|
132
165
|
const [method, rawPath] = splitCall(step.request);
|
|
133
166
|
const path = interpolate(rawPath, vars);
|
|
167
|
+
const headers = interpolate(step.headers, vars);
|
|
134
168
|
const opts = {
|
|
135
|
-
headers: interpolate(step.
|
|
169
|
+
headers: step.auth ? { ...ctx.auth.header(step.auth === true ? {} : interpolate(step.auth, vars)), ...headers } : headers,
|
|
136
170
|
query: interpolate(step.query, vars),
|
|
137
171
|
follow: step.follow,
|
|
138
172
|
};
|
|
139
|
-
|
|
173
|
+
let body = step.json !== undefined
|
|
140
174
|
? interpolate(step.json, vars)
|
|
141
175
|
: step.form !== undefined
|
|
142
176
|
? ctx.http.form(interpolate(step.form, vars))
|
|
143
177
|
: interpolate(step.body, vars);
|
|
144
|
-
|
|
178
|
+
if (step.webhook) {
|
|
179
|
+
const signed = webhookBody(body);
|
|
180
|
+
body = signed.body;
|
|
181
|
+
opts.headers = { "content-type": signed.type, ...signWebhook(signed.body, interpolate(step.webhook, vars)), ...opts.headers };
|
|
182
|
+
}
|
|
145
183
|
const e = step.expect;
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
184
|
+
const verify = (res) => {
|
|
185
|
+
if (e?.status !== undefined)
|
|
186
|
+
expect(res).toHaveStatus(interpolate(e.status, vars));
|
|
187
|
+
if (e?.headers !== undefined) {
|
|
188
|
+
const expected = Object.fromEntries(Object.entries(e.headers).map(([k, v]) => [k.toLowerCase(), v]));
|
|
189
|
+
check(Object.fromEntries(res.headers), expected, vars, "response headers");
|
|
190
|
+
}
|
|
191
|
+
if (e?.json !== undefined)
|
|
192
|
+
check(res.json, e.json, vars, "response JSON");
|
|
193
|
+
if (e?.text !== undefined)
|
|
194
|
+
check(res.text, e.text, vars, "response text");
|
|
195
|
+
};
|
|
196
|
+
if (step.concurrency !== undefined) {
|
|
197
|
+
// Each request gets its own body: a URLSearchParams body can only be read once.
|
|
198
|
+
const all = await ctx.http.concurrently(step.concurrency, () => ctx.http.request(method, path, body instanceof URLSearchParams ? new URLSearchParams(body) : body, opts));
|
|
199
|
+
if (e?.statuses !== undefined)
|
|
200
|
+
expect(all).toHaveStatuses(interpolate(e.statuses, vars));
|
|
201
|
+
all.forEach(verify);
|
|
202
|
+
return;
|
|
151
203
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
204
|
+
const max = e?.queries === undefined ? undefined : interpolate(e.queries, vars);
|
|
205
|
+
let res;
|
|
206
|
+
const queries = await (max === undefined ? undefined : ctx.db.queries(async () => (res = await ctx.http.request(method, path, body, opts))));
|
|
207
|
+
if (!queries)
|
|
208
|
+
res = await ctx.http.request(method, path, body, opts);
|
|
209
|
+
if (queries && max !== undefined) {
|
|
210
|
+
const ran = queries.withoutTransactions();
|
|
211
|
+
if (ran.length > max) {
|
|
212
|
+
const shapes = ran.shapes().map((s) => ` ${s.count > 1 ? `×${s.count} ` : ""}${s.sql}`);
|
|
213
|
+
throw new Error(`expected at most ${max} SQL statements for ${method} ${path}, the app ran ${ran.length}:\n${shapes.join("\n")}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
verify(res);
|
|
156
217
|
capture(step.capture, { status: res.status, json: res.json, text: res.text, headers: Object.fromEntries(res.headers) }, vars);
|
|
157
218
|
return;
|
|
158
219
|
}
|
|
@@ -161,6 +222,22 @@ async function runStep(step, ctx, vars) {
|
|
|
161
222
|
capture(step.capture, { rows, row: rows[0] }, vars);
|
|
162
223
|
return;
|
|
163
224
|
}
|
|
225
|
+
if ("chaos" in step) {
|
|
226
|
+
const { chaos, name: _, ...opts } = step;
|
|
227
|
+
ctx.stub(chaos).chaos(opts);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if ("make" in step) {
|
|
231
|
+
const given = interpolate(step.rows, vars) ?? {};
|
|
232
|
+
const rows = [];
|
|
233
|
+
if (Array.isArray(given))
|
|
234
|
+
for (const r of given)
|
|
235
|
+
rows.push(await ctx.db.make(step.make, r));
|
|
236
|
+
else
|
|
237
|
+
rows.push(...(await ctx.db.makeMany(step.make, step.count ?? 1, given)));
|
|
238
|
+
capture(step.capture, { rows, row: rows[0] }, vars);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
164
241
|
if ("sql" in step) {
|
|
165
242
|
const rows = await ctx.db.query(step.sql, interpolate(step.params, vars) ?? []);
|
|
166
243
|
expectRows(step.expect, rows, vars);
|
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 | MakeStep | ChaosStep | SqlStep | DbStep | ReceivedStep | ChangesStep | CheckpointStep | LogStep | SnapshotStep | MailStep) & {
|
|
23
25
|
line: number;
|
|
24
26
|
name?: string;
|
|
25
27
|
};
|
|
@@ -55,11 +57,31 @@ export interface RequestStep {
|
|
|
55
57
|
form?: Record<string, unknown>;
|
|
56
58
|
body?: string;
|
|
57
59
|
follow?: boolean;
|
|
60
|
+
/** Send `Authorization: Bearer` with a token from the `auth` issuer: `true`, or the claims, e.g. { sub: u1, roles: [admin] }. */
|
|
61
|
+
auth?: true | Record<string, unknown>;
|
|
62
|
+
/** Sign the body as this provider would deliver a webhook: { provider: stripe, secret: whsec_test, event, stale, invalidSignature }. */
|
|
63
|
+
webhook?: {
|
|
64
|
+
provider: string | {
|
|
65
|
+
header: string;
|
|
66
|
+
algorithm?: string;
|
|
67
|
+
encoding?: "hex" | "base64";
|
|
68
|
+
prefix?: string;
|
|
69
|
+
};
|
|
70
|
+
secret: string;
|
|
71
|
+
event?: string;
|
|
72
|
+
stale?: boolean;
|
|
73
|
+
invalidSignature?: boolean;
|
|
74
|
+
};
|
|
75
|
+
/** Send the request this many times at once. `expect` applies to every response; `statuses` counts them. */
|
|
76
|
+
concurrency?: number;
|
|
77
|
+
/** `queries`: at most this many SQL statements (not counting BEGIN/COMMIT), with `db.queries` on. */
|
|
58
78
|
expect?: {
|
|
59
79
|
status?: number;
|
|
80
|
+
statuses?: Record<string, number>;
|
|
60
81
|
headers?: Record<string, unknown>;
|
|
61
82
|
json?: unknown;
|
|
62
83
|
text?: unknown;
|
|
84
|
+
queries?: number;
|
|
63
85
|
};
|
|
64
86
|
capture?: Record<string, string>;
|
|
65
87
|
}
|
|
@@ -68,6 +90,24 @@ export interface InsertStep {
|
|
|
68
90
|
rows: Record<string, unknown> | Record<string, unknown>[];
|
|
69
91
|
capture?: Record<string, string>;
|
|
70
92
|
}
|
|
93
|
+
/** Rows built by `db.make()`: only the columns given are fixed, the rest is filled from the schema. */
|
|
94
|
+
export interface MakeStep {
|
|
95
|
+
make: string;
|
|
96
|
+
rows?: Record<string, unknown> | Record<string, unknown>[];
|
|
97
|
+
/** Make this many rows, each with `rows` (a mapping). */
|
|
98
|
+
count?: number;
|
|
99
|
+
capture?: Record<string, string>;
|
|
100
|
+
}
|
|
101
|
+
/** Faults injected into a stub's answers for the rest of the scenario, like `stub(name).chaos()`. */
|
|
102
|
+
export interface ChaosStep {
|
|
103
|
+
chaos: string;
|
|
104
|
+
failFirst?: number;
|
|
105
|
+
errorRate?: number;
|
|
106
|
+
statuses?: number[];
|
|
107
|
+
networkErrorRate?: number;
|
|
108
|
+
latency?: number | [number, number];
|
|
109
|
+
seed?: number;
|
|
110
|
+
}
|
|
71
111
|
export interface SqlStep {
|
|
72
112
|
sql: string;
|
|
73
113
|
params?: unknown[];
|
|
@@ -129,6 +169,24 @@ export interface SnapshotStep {
|
|
|
129
169
|
/** Keys whose values are masked in addition to dates and UUIDs. */
|
|
130
170
|
mask?: string[];
|
|
131
171
|
}
|
|
172
|
+
/**
|
|
173
|
+
* The app sent mail matching `mail:` (strings are exact addresses for to/from and substrings
|
|
174
|
+
* otherwise; `{ $regex }` also works). Waits up to `within` ms (default 5000). `capture` reads
|
|
175
|
+
* the last match: `subject`, `text`, `links.0`.
|
|
176
|
+
*/
|
|
177
|
+
export interface MailStep {
|
|
178
|
+
mail: {
|
|
179
|
+
to?: unknown;
|
|
180
|
+
from?: unknown;
|
|
181
|
+
subject?: unknown;
|
|
182
|
+
text?: unknown;
|
|
183
|
+
html?: unknown;
|
|
184
|
+
};
|
|
185
|
+
/** Exact number of matching messages. Default: at least one. */
|
|
186
|
+
times?: number;
|
|
187
|
+
within?: number;
|
|
188
|
+
capture?: Record<string, string>;
|
|
189
|
+
}
|
|
132
190
|
export declare class YamlScenarioError extends Error {
|
|
133
191
|
}
|
|
134
192
|
export declare function parseScenarioFile(text: string, file: string): YamlFile;
|
package/dist/yaml.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { isMap, isSeq, LineCounter, parseDocument } from "yaml";
|
|
2
2
|
const KINDS = {
|
|
3
3
|
stub: ["on", "when", "reply", "sequence", "networkError", "times", "delay"],
|
|
4
|
-
request: ["headers", "query", "json", "form", "body", "follow", "expect", "capture"],
|
|
4
|
+
request: ["headers", "query", "json", "form", "body", "follow", "auth", "webhook", "concurrency", "expect", "capture"],
|
|
5
5
|
insert: ["rows", "capture"],
|
|
6
6
|
sql: ["params", "expect", "capture", "within"],
|
|
7
7
|
db: ["where", "orderBy", "expect", "capture", "within"],
|
|
@@ -10,17 +10,25 @@ const KINDS = {
|
|
|
10
10
|
log: ["from", "within"],
|
|
11
11
|
checkpoint: [],
|
|
12
12
|
snapshot: ["mask"],
|
|
13
|
+
mail: ["times", "within", "capture"],
|
|
14
|
+
make: ["rows", "count", "capture"],
|
|
15
|
+
chaos: ["failFirst", "errorRate", "statuses", "networkErrorRate", "latency", "seed"],
|
|
13
16
|
};
|
|
14
17
|
const EXPECT_KEYS = {
|
|
15
|
-
request: ["status", "headers", "json", "text"],
|
|
18
|
+
request: ["status", "statuses", "headers", "json", "text", "queries"],
|
|
16
19
|
sql: ["rows", "count"],
|
|
17
20
|
db: ["rows", "count"],
|
|
18
21
|
};
|
|
19
22
|
const CONDITION_KEYS = ["query", "headers", "json", "body"];
|
|
23
|
+
const WEBHOOK_KEYS = ["provider", "secret", "event", "stale", "invalidSignature"];
|
|
24
|
+
const WEBHOOK_PROVIDERS = ["stripe", "github", "slack", "shopify", "standard"];
|
|
25
|
+
const MAIL_KEYS = ["to", "from", "subject", "text", "html"];
|
|
20
26
|
const CHANGE_KEYS = ["inserted", "updated", "deleted"];
|
|
21
27
|
const RESPONSE_KEYS = ["status", "headers", "body"];
|
|
22
28
|
const SCENARIO_KEYS = ["name", "steps", "each", "skip", "only", "timeout"];
|
|
23
29
|
const CALL = /^([A-Za-z]+|\*)\s+(\/\S*)$/;
|
|
30
|
+
/** A request may also go to a captured URL of the app, e.g. a link from a mail: `GET {{link}}`. */
|
|
31
|
+
const REQUEST = /^[A-Za-z]+\s+(\/\S*|\{\{[^}]+\}\}\S*)$/;
|
|
24
32
|
export class YamlScenarioError extends Error {
|
|
25
33
|
}
|
|
26
34
|
export function parseScenarioFile(text, file) {
|
|
@@ -108,6 +116,14 @@ function parseStep(node, fail, lineOf) {
|
|
|
108
116
|
}
|
|
109
117
|
}
|
|
110
118
|
}
|
|
119
|
+
else if (kind === "mail") {
|
|
120
|
+
const filter = raw.mail;
|
|
121
|
+
if (!filter || typeof filter !== "object" || Array.isArray(filter))
|
|
122
|
+
fail(at(kind), "`mail:` must be a mapping such as { to: alice@example.com, subject: Welcome } ({} for any)");
|
|
123
|
+
for (const k of Object.keys(filter))
|
|
124
|
+
if (!MAIL_KEYS.includes(k))
|
|
125
|
+
fail(at(kind), `unknown key "${k}" in mail (allowed: ${MAIL_KEYS.join(", ")})`);
|
|
126
|
+
}
|
|
111
127
|
else if (kind === "checkpoint") {
|
|
112
128
|
if (raw.checkpoint !== true)
|
|
113
129
|
fail(at(kind), "use `checkpoint: true`");
|
|
@@ -131,8 +147,8 @@ function parseStep(node, fail, lineOf) {
|
|
|
131
147
|
fail(at(key), `unknown key "${k}" in ${key} (allowed: ${allowedKeys.join(", ")})`);
|
|
132
148
|
}
|
|
133
149
|
};
|
|
134
|
-
const call = (key) => {
|
|
135
|
-
if (raw[key] !== undefined && !
|
|
150
|
+
const call = (key, pattern = CALL) => {
|
|
151
|
+
if (raw[key] !== undefined && !pattern.test(String(raw[key])))
|
|
136
152
|
fail(at(key), `\`${key}\` must look like "POST /path", got "${raw[key]}"`);
|
|
137
153
|
};
|
|
138
154
|
if (raw.within !== undefined && (typeof raw.within !== "number" || raw.within <= 0))
|
|
@@ -166,14 +182,78 @@ function parseStep(node, fail, lineOf) {
|
|
|
166
182
|
break;
|
|
167
183
|
}
|
|
168
184
|
case "request":
|
|
169
|
-
call("request");
|
|
185
|
+
call("request", REQUEST);
|
|
170
186
|
if (["json", "form", "body"].filter((k) => raw[k] !== undefined).length > 1)
|
|
171
187
|
fail(node, "use only one of json / form / body");
|
|
188
|
+
if (raw.webhook !== undefined) {
|
|
189
|
+
const w = raw.webhook;
|
|
190
|
+
if (!w || typeof w !== "object" || Array.isArray(w))
|
|
191
|
+
fail(at("webhook"), "`webhook` must be a mapping such as { provider: stripe, secret: whsec_test }");
|
|
192
|
+
for (const k of Object.keys(w))
|
|
193
|
+
if (!WEBHOOK_KEYS.includes(k))
|
|
194
|
+
fail(at("webhook"), `unknown key "${k}" in webhook (allowed: ${WEBHOOK_KEYS.join(", ")})`);
|
|
195
|
+
if (typeof w.secret !== "string" || !w.secret)
|
|
196
|
+
fail(at("webhook"), "`webhook.secret` is required: the signing secret the app is configured with");
|
|
197
|
+
const p = w.provider;
|
|
198
|
+
if (!(typeof p === "string" ? WEBHOOK_PROVIDERS.includes(p) : p && typeof p === "object" && typeof p.header === "string")) {
|
|
199
|
+
fail(at("webhook"), `\`webhook.provider\` must be one of ${WEBHOOK_PROVIDERS.join(", ")} or { header, prefix, encoding }`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (raw.auth !== undefined && raw.auth !== true && !(raw.auth && typeof raw.auth === "object" && !Array.isArray(raw.auth)))
|
|
203
|
+
fail(at("auth"), "`auth` must be true or the token's claims, e.g. { sub: u1, roles: [admin] }");
|
|
204
|
+
if (raw.concurrency !== undefined && !(Number.isInteger(raw.concurrency) && raw.concurrency >= 1))
|
|
205
|
+
fail(at("concurrency"), "`concurrency` must be a positive whole number");
|
|
206
|
+
{
|
|
207
|
+
const q = raw.expect?.queries;
|
|
208
|
+
if (q !== undefined && !(Number.isInteger(q) && q >= 0))
|
|
209
|
+
fail(at("expect"), "`expect.queries` is the most SQL statements the request may run, e.g. 3");
|
|
210
|
+
if (q !== undefined && raw.concurrency !== undefined)
|
|
211
|
+
fail(at("expect"), "`expect.queries` can't be used with `concurrency`");
|
|
212
|
+
}
|
|
213
|
+
if (raw.concurrency !== undefined && raw.capture !== undefined)
|
|
214
|
+
fail(at("capture"), "`capture` can't be used with `concurrency`: there is more than one response");
|
|
215
|
+
{
|
|
216
|
+
const statuses = raw.expect?.statuses;
|
|
217
|
+
if (statuses !== undefined) {
|
|
218
|
+
if (raw.concurrency === undefined)
|
|
219
|
+
fail(at("expect"), "`expect.statuses` needs `concurrency`");
|
|
220
|
+
if (!statuses || typeof statuses !== "object" || Object.entries(statuses).some(([k, v]) => !/^\d{3}$/.test(k) || !Number.isInteger(v))) {
|
|
221
|
+
fail(at("expect"), "`expect.statuses` maps status codes to counts, e.g. { 201: 1, 409: 9 }");
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
172
225
|
break;
|
|
173
226
|
case "insert":
|
|
174
227
|
if (!raw.rows || typeof raw.rows !== "object")
|
|
175
228
|
fail(at("rows"), "an insert step needs `rows` (a mapping or a list of mappings)");
|
|
176
229
|
break;
|
|
230
|
+
case "chaos":
|
|
231
|
+
for (const k of ["errorRate", "networkErrorRate"]) {
|
|
232
|
+
if (raw[k] !== undefined && !(typeof raw[k] === "number" && raw[k] >= 0 && raw[k] <= 1))
|
|
233
|
+
fail(at(k), `\`${k}\` must be a number between 0 and 1`);
|
|
234
|
+
}
|
|
235
|
+
for (const k of ["failFirst", "seed"])
|
|
236
|
+
if (raw[k] !== undefined && !Number.isInteger(raw[k]))
|
|
237
|
+
fail(at(k), `\`${k}\` must be a whole number`);
|
|
238
|
+
if (raw.statuses !== undefined && !(Array.isArray(raw.statuses) && raw.statuses.length > 0 && raw.statuses.every((s) => Number.isInteger(s) && s >= 400 && s <= 599))) {
|
|
239
|
+
fail(at("statuses"), "`statuses` must be a list of 4xx/5xx codes, e.g. [503]");
|
|
240
|
+
}
|
|
241
|
+
{
|
|
242
|
+
const l = raw.latency;
|
|
243
|
+
if (l !== undefined && !(typeof l === "number" || (Array.isArray(l) && l.length === 2 && l.every((x) => typeof x === "number"))))
|
|
244
|
+
fail(at("latency"), "`latency` must be milliseconds or [min, max]");
|
|
245
|
+
}
|
|
246
|
+
break;
|
|
247
|
+
case "make":
|
|
248
|
+
if (raw.rows !== undefined && (!raw.rows || typeof raw.rows !== "object"))
|
|
249
|
+
fail(at("rows"), "`rows` must be a mapping or a list of mappings");
|
|
250
|
+
if (raw.count !== undefined) {
|
|
251
|
+
if (!(Number.isInteger(raw.count) && raw.count >= 1))
|
|
252
|
+
fail(at("count"), "`count` must be a positive whole number");
|
|
253
|
+
if (Array.isArray(raw.rows))
|
|
254
|
+
fail(at("count"), "use either `count` or a list of `rows`");
|
|
255
|
+
}
|
|
256
|
+
break;
|
|
177
257
|
case "log":
|
|
178
258
|
try {
|
|
179
259
|
new RegExp(raw.log);
|
|
@@ -188,6 +268,9 @@ function parseStep(node, fail, lineOf) {
|
|
|
188
268
|
call("call");
|
|
189
269
|
number("times");
|
|
190
270
|
break;
|
|
271
|
+
case "mail":
|
|
272
|
+
number("times");
|
|
273
|
+
break;
|
|
191
274
|
}
|
|
192
275
|
return { ...raw, line: lineOf(node) };
|
|
193
276
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "slicetest",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Framework-agnostic slice tests: drive a real app process over HTTP with a real Postgres and stubbed outbound APIs, checked against OpenAPI contracts.",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Framework-agnostic slice tests: drive a real app process over HTTP with a real Postgres, MySQL or SQLite and stubbed outbound APIs, checked against OpenAPI contracts.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"testing",
|
|
7
7
|
"integration-testing",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"api-testing",
|
|
10
10
|
"postgres",
|
|
11
11
|
"mysql",
|
|
12
|
+
"sqlite",
|
|
12
13
|
"stub",
|
|
13
14
|
"mock-server",
|
|
14
15
|
"vitest",
|
|
@@ -17,7 +18,13 @@
|
|
|
17
18
|
"testcontainers",
|
|
18
19
|
"language-agnostic",
|
|
19
20
|
"openapi",
|
|
20
|
-
"contract-testing"
|
|
21
|
+
"contract-testing",
|
|
22
|
+
"jwt",
|
|
23
|
+
"oidc",
|
|
24
|
+
"webhooks",
|
|
25
|
+
"chaos-testing",
|
|
26
|
+
"n-plus-one",
|
|
27
|
+
"test-data-factory"
|
|
21
28
|
],
|
|
22
29
|
"author": "pon-bok",
|
|
23
30
|
"repository": {
|
|
@@ -55,7 +62,7 @@
|
|
|
55
62
|
"build": "tsc -p tsconfig.build.json",
|
|
56
63
|
"typecheck": "tsc --noEmit",
|
|
57
64
|
"test": "vitest run",
|
|
58
|
-
"test:dist": "npm run build && tsc -p examples/tsconfig.dist.json && vitest run --config examples/vitest.dist.config.ts && node dist/cli.js --config examples/slicetest.config.yaml",
|
|
65
|
+
"test:dist": "npm run build && tsc -p examples/tsconfig.dist.json && vitest run --config examples/vitest.dist.config.ts && node dist/cli.js --config examples/slicetest.config.yaml && node test/record-dist.mjs",
|
|
59
66
|
"prepublishOnly": "npm run typecheck && npm run build"
|
|
60
67
|
},
|
|
61
68
|
"peerDependencies": {
|