slicetest 0.7.0 → 0.9.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 +83 -17
- package/dist/cli-run.d.ts +13 -0
- package/dist/cli-run.js +15 -0
- package/dist/cli.js +32 -2
- package/dist/config.d.ts +6 -0
- package/dist/config.js +48 -0
- package/dist/db.d.ts +12 -1
- package/dist/db.js +33 -6
- package/dist/form.d.ts +8 -2
- package/dist/form.js +85 -11
- package/dist/global-setup.d.ts +5 -0
- package/dist/global-setup.js +32 -11
- package/dist/har.js +3 -3
- package/dist/http.d.ts +40 -4
- package/dist/http.js +126 -13
- package/dist/index.d.ts +4 -3
- package/dist/init.js +53 -8
- package/dist/list.d.ts +26 -0
- package/dist/list.js +71 -0
- package/dist/matchers.d.ts +21 -3
- package/dist/matchers.js +91 -2
- package/dist/openapi.d.ts +0 -5
- package/dist/openapi.js +13 -6
- package/dist/record.d.ts +1 -1
- package/dist/record.js +3 -1
- package/dist/recording.d.ts +7 -0
- package/dist/recording.js +38 -4
- package/dist/runtime.js +13 -0
- package/dist/scenario.d.ts +14 -4
- package/dist/scenario.js +36 -20
- package/dist/sql-migrations.d.ts +11 -0
- package/dist/sql-migrations.js +38 -0
- package/dist/stub.d.ts +32 -3
- package/dist/stub.js +148 -8
- package/dist/webhook.d.ts +16 -7
- package/dist/webhook.js +47 -7
- package/dist/yaml-runtime.d.ts +3 -2
- package/dist/yaml-runtime.js +287 -36
- package/dist/yaml.d.ts +51 -4
- package/dist/yaml.js +105 -14
- package/package.json +1 -1
- package/schema/scenario.schema.json +431 -6
package/dist/yaml.js
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import { isMap, isSeq, LineCounter, parseDocument } from "yaml";
|
|
2
|
+
import { WEBHOOK_PROVIDERS as PROVIDERS } from "./webhook.js";
|
|
2
3
|
const KINDS = {
|
|
3
4
|
stub: ["on", "graphql", "when", "reply", "sequence", "networkError", "times", "delay", "optional"],
|
|
4
|
-
request: ["headers", "query", "json", "form", "body", "graphql", "follow", "auth", "webhook", "concurrency", "expect", "capture"],
|
|
5
|
+
request: ["headers", "query", "json", "form", "multipart", "body", "graphql", "follow", "auth", "webhook", "concurrency", "expect", "capture", "within", "every", "timeout"],
|
|
5
6
|
submit: ["form", "fields", "headers", "follow", "expect", "capture"],
|
|
6
7
|
insert: ["rows", "capture"],
|
|
7
8
|
sql: ["params", "expect", "capture", "within"],
|
|
8
9
|
db: ["where", "orderBy", "expect", "capture", "within"],
|
|
9
10
|
received: ["call", "graphql", "when", "times", "within"],
|
|
10
|
-
changes: ["within"],
|
|
11
|
+
changes: ["within", "ignore"],
|
|
11
12
|
log: ["from", "within"],
|
|
12
13
|
checkpoint: [],
|
|
14
|
+
set: [],
|
|
15
|
+
order: ["within"],
|
|
13
16
|
snapshot: ["mask"],
|
|
14
17
|
mail: ["times", "within", "capture"],
|
|
15
18
|
make: ["rows", "count", "capture"],
|
|
@@ -17,18 +20,18 @@ const KINDS = {
|
|
|
17
20
|
use: ["with"],
|
|
18
21
|
};
|
|
19
22
|
const EXPECT_KEYS = {
|
|
20
|
-
request: ["status", "statuses", "headers", "json", "text", "queries", "schema"],
|
|
21
|
-
submit: ["status", "headers", "json", "text", "schema"],
|
|
23
|
+
request: ["status", "statuses", "headers", "json", "text", "queries", "schema", "duration", "cookies", "events"],
|
|
24
|
+
submit: ["status", "headers", "json", "text", "schema", "duration", "cookies", "events"],
|
|
22
25
|
sql: ["rows", "count"],
|
|
23
26
|
db: ["rows", "count"],
|
|
24
27
|
};
|
|
25
|
-
const CONDITION_KEYS = ["query", "headers", "json", "body", "variables"];
|
|
26
|
-
const WEBHOOK_KEYS = ["provider", "secret", "event", "stale", "invalidSignature"];
|
|
27
|
-
const WEBHOOK_PROVIDERS =
|
|
28
|
+
const CONDITION_KEYS = ["query", "headers", "json", "form", "body", "variables"];
|
|
29
|
+
const WEBHOOK_KEYS = ["provider", "secret", "event", "stale", "invalidSignature", "url"];
|
|
30
|
+
const WEBHOOK_PROVIDERS = PROVIDERS;
|
|
28
31
|
const MAIL_KEYS = ["to", "from", "subject", "text", "html"];
|
|
29
32
|
const CHANGE_KEYS = ["inserted", "updated", "deleted"];
|
|
30
|
-
const RESPONSE_KEYS = ["status", "headers", "body", "sse", "data", "errors"];
|
|
31
|
-
const SCENARIO_KEYS = ["name", "steps", "each", "skip", "only", "timeout"];
|
|
33
|
+
const RESPONSE_KEYS = ["status", "headers", "body", "file", "sse", "data", "errors"];
|
|
34
|
+
const SCENARIO_KEYS = ["name", "steps", "each", "skip", "only", "timeout", "tags"];
|
|
32
35
|
const CALL = /^([A-Za-z]+|\*)\s+(\/\S*)$/;
|
|
33
36
|
/** A request may also go to a captured URL of the app, e.g. a link from a mail: `GET {{link}}`. */
|
|
34
37
|
const REQUEST = /^[A-Za-z]+\s+(\/\S*|\{\{[^}]+\}\}\S*)$/;
|
|
@@ -124,6 +127,9 @@ export function parseScenarioFile(text, file) {
|
|
|
124
127
|
}
|
|
125
128
|
if (raw.timeout !== undefined && typeof raw.timeout !== "number")
|
|
126
129
|
fail(node, "`timeout` must be a number of milliseconds");
|
|
130
|
+
if (raw.tags !== undefined && !(Array.isArray(raw.tags) && raw.tags.every((t) => typeof t === "string" && /^[^\s,!]+$/.test(t)))) {
|
|
131
|
+
fail(node.get("tags", true), "`tags` must be a list of words without spaces, commas or `!`, e.g. [smoke, payments]");
|
|
132
|
+
}
|
|
127
133
|
return {
|
|
128
134
|
name: raw.name,
|
|
129
135
|
line: lineOf(node),
|
|
@@ -131,6 +137,7 @@ export function parseScenarioFile(text, file) {
|
|
|
131
137
|
skip: raw.skip === true,
|
|
132
138
|
only: raw.only === true,
|
|
133
139
|
timeout: raw.timeout,
|
|
140
|
+
tags: raw.tags,
|
|
134
141
|
steps: stepsOf(node.get("steps", true), `scenario "${raw.name}": steps`),
|
|
135
142
|
};
|
|
136
143
|
}),
|
|
@@ -156,6 +163,8 @@ function parseStep(node, fail, lineOf) {
|
|
|
156
163
|
fail(at(key), `unknown key "${key}" in a ${kind} step (allowed: ${[...allowed].join(", ")})`);
|
|
157
164
|
}
|
|
158
165
|
if (kind === "changes") {
|
|
166
|
+
if (raw.ignore !== undefined && !(Array.isArray(raw.ignore) && raw.ignore.every((c) => typeof c === "string" && c)))
|
|
167
|
+
fail(at("ignore"), "`ignore` must be a list of columns or tables, e.g. [updated_at, sessions.*]");
|
|
159
168
|
const tables = raw.changes;
|
|
160
169
|
if (!tables || typeof tables !== "object" || Array.isArray(tables))
|
|
161
170
|
fail(at(kind), "`changes:` must map table names to { inserted, updated, deleted }");
|
|
@@ -189,8 +198,9 @@ function parseStep(node, fail, lineOf) {
|
|
|
189
198
|
fail(at("fields"), "`fields` must map field names to values, e.g. { email: a@b.test }");
|
|
190
199
|
for (const [k, v] of Object.entries(fields)) {
|
|
191
200
|
const scalar = (x) => typeof x === "string" || typeof x === "number";
|
|
192
|
-
|
|
193
|
-
|
|
201
|
+
const isFile = !!v && typeof v === "object" && !Array.isArray(v) && Object.keys(v).every((x) => ["file", "content", "filename", "type"].includes(x)) && ("file" in v) !== ("content" in v);
|
|
202
|
+
if (!(scalar(v) || typeof v === "boolean" || (Array.isArray(v) && v.every(scalar)) || isFile))
|
|
203
|
+
fail(at("fields"), `field "${k}" must be a string, number, true / false, a list, or a file ({ file: path } or { content, filename })`);
|
|
194
204
|
}
|
|
195
205
|
}
|
|
196
206
|
}
|
|
@@ -200,6 +210,42 @@ function parseStep(node, fail, lineOf) {
|
|
|
200
210
|
if (raw.with !== undefined && (!raw.with || typeof raw.with !== "object" || Array.isArray(raw.with)))
|
|
201
211
|
fail(at("with"), "`with` maps params to values, e.g. { email: a@b.test }");
|
|
202
212
|
}
|
|
213
|
+
else if (kind === "order") {
|
|
214
|
+
const list = raw.order;
|
|
215
|
+
if (!Array.isArray(list) || list.length < 2)
|
|
216
|
+
fail(at(kind), "`order:` lists at least two calls, e.g. [\"stripe POST /v1/charges\", \"mail POST /send\"]");
|
|
217
|
+
for (const item of list) {
|
|
218
|
+
if (typeof item === "string") {
|
|
219
|
+
if (!/^\S+\s+([A-Za-z]+|\*)\s+\/\S*$/.test(item))
|
|
220
|
+
fail(at(kind), `"${item}" should be "<stub> METHOD /path", e.g. "mail POST /send"`);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (!item || typeof item !== "object" || Array.isArray(item))
|
|
224
|
+
fail(at(kind), "each `order` entry is \"<stub> METHOD /path\" or { stub, call, when }");
|
|
225
|
+
const o = item;
|
|
226
|
+
for (const k of Object.keys(o))
|
|
227
|
+
if (!["stub", "call", "when"].includes(k))
|
|
228
|
+
fail(at(kind), `unknown key "${k}" in an order entry (allowed: stub, call, when)`);
|
|
229
|
+
if (typeof o.stub !== "string" || !o.stub)
|
|
230
|
+
fail(at(kind), "an order entry needs `stub`");
|
|
231
|
+
if (typeof o.call !== "string" || !CALL.test(o.call))
|
|
232
|
+
fail(at(kind), `an order entry's \`call\` must look like "POST /path", got ${JSON.stringify(o.call)}`);
|
|
233
|
+
if (o.when !== undefined) {
|
|
234
|
+
if (!o.when || typeof o.when !== "object" || Array.isArray(o.when))
|
|
235
|
+
fail(at(kind), "`when` in an order entry must be a mapping");
|
|
236
|
+
for (const k of Object.keys(o.when))
|
|
237
|
+
if (!CONDITION_KEYS.includes(k))
|
|
238
|
+
fail(at(kind), `unknown key "${k}" in when (allowed: ${CONDITION_KEYS.join(", ")})`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
else if (kind === "set") {
|
|
243
|
+
if (!raw.set || typeof raw.set !== "object" || Array.isArray(raw.set) || Object.keys(raw.set).length === 0)
|
|
244
|
+
fail(at(kind), "`set:` maps variable names to values, e.g. { orderId: \"{{$uuid}}\" }");
|
|
245
|
+
for (const k of Object.keys(raw.set))
|
|
246
|
+
if (!/^[A-Za-z_][\w]*$/.test(k))
|
|
247
|
+
fail(at(kind), `"${k}" isn't a variable name (letters, digits and _)`);
|
|
248
|
+
}
|
|
203
249
|
else if (kind === "checkpoint") {
|
|
204
250
|
if (raw.checkpoint !== true)
|
|
205
251
|
fail(at(kind), "use `checkpoint: true`");
|
|
@@ -229,6 +275,18 @@ function parseStep(node, fail, lineOf) {
|
|
|
229
275
|
};
|
|
230
276
|
if (raw.within !== undefined && (typeof raw.within !== "number" || raw.within <= 0))
|
|
231
277
|
fail(at("within"), "`within` must be a positive number of milliseconds");
|
|
278
|
+
const events = raw.expect?.events;
|
|
279
|
+
if (events !== undefined && !(Array.isArray(events) && events.every((e) => e && typeof e === "object" && !Array.isArray(e)))) {
|
|
280
|
+
fail(at("expect"), "`expect.events` is a list of events, each a subset of { event, data, id }: [{ event: message_stop }]");
|
|
281
|
+
}
|
|
282
|
+
const cookies = raw.expect?.cookies;
|
|
283
|
+
if (cookies !== undefined && (!cookies || typeof cookies !== "object" || Array.isArray(cookies) || Object.values(cookies).some((c) => c !== null && (typeof c !== "object" || Array.isArray(c))))) {
|
|
284
|
+
fail(at("expect"), "`expect.cookies` maps cookie names to attributes ({ sid: { httpOnly: true } }), {} for any value, or null for a cookie that must not be set");
|
|
285
|
+
}
|
|
286
|
+
const duration = raw.expect?.duration;
|
|
287
|
+
if (duration !== undefined && !((typeof duration === "number" && duration > 0) || (duration && typeof duration === "object" && !Array.isArray(duration)))) {
|
|
288
|
+
fail(at("expect"), "`expect.duration` is a number of milliseconds (at most), or a matcher such as { $lt: 200 }");
|
|
289
|
+
}
|
|
232
290
|
const number = (key) => {
|
|
233
291
|
if (raw[key] !== undefined && (typeof raw[key] !== "number" || raw[key] < 0))
|
|
234
292
|
fail(at(key), `\`${key}\` must be a non-negative number`);
|
|
@@ -264,7 +322,13 @@ function parseStep(node, fail, lineOf) {
|
|
|
264
322
|
for (const [where, r] of [["reply", raw.reply], ...(Array.isArray(raw.sequence) ? raw.sequence : []).map((r) => ["sequence", r])]) {
|
|
265
323
|
if (!r || typeof r !== "object")
|
|
266
324
|
continue;
|
|
267
|
-
const { body, sse, data, errors } = r;
|
|
325
|
+
const { body, sse, data, errors, file } = r;
|
|
326
|
+
if (file !== undefined) {
|
|
327
|
+
if (typeof file !== "string" || !file)
|
|
328
|
+
fail(at(where), "`file` is a path relative to the scenario file, e.g. fixtures/charge.json");
|
|
329
|
+
if (body !== undefined || sse !== undefined || data !== undefined || errors !== undefined)
|
|
330
|
+
fail(at(where), "a reply has `file` instead of `body`, `sse`, `data` or `errors`");
|
|
331
|
+
}
|
|
268
332
|
if (data !== undefined || errors !== undefined) {
|
|
269
333
|
if (raw.graphql === undefined)
|
|
270
334
|
fail(at(where), "`data` and `errors` answer a GraphQL operation: use them with `graphql: <operation>`, or send `body`");
|
|
@@ -292,8 +356,27 @@ function parseStep(node, fail, lineOf) {
|
|
|
292
356
|
}
|
|
293
357
|
case "request":
|
|
294
358
|
call("request", REQUEST);
|
|
295
|
-
if (["json", "form", "body", "graphql"].filter((k) => raw[k] !== undefined).length > 1)
|
|
296
|
-
fail(node, "use only one of json / form / body / graphql");
|
|
359
|
+
if (["json", "form", "multipart", "body", "graphql"].filter((k) => raw[k] !== undefined).length > 1)
|
|
360
|
+
fail(node, "use only one of json / form / multipart / body / graphql");
|
|
361
|
+
if (raw.multipart !== undefined) {
|
|
362
|
+
const m = raw.multipart;
|
|
363
|
+
if (!m || typeof m !== "object" || Array.isArray(m))
|
|
364
|
+
fail(at("multipart"), "`multipart` maps field names to values, e.g. { name: ada, avatar: { file: ada.png } }");
|
|
365
|
+
for (const [k, v] of Object.entries(m)) {
|
|
366
|
+
for (const part of Array.isArray(v) ? v : [v]) {
|
|
367
|
+
if (part && typeof part === "object") {
|
|
368
|
+
const keys = Object.keys(part);
|
|
369
|
+
const bad = keys.find((x) => !["file", "content", "filename", "type"].includes(x));
|
|
370
|
+
if (bad)
|
|
371
|
+
fail(at("multipart"), `unknown key "${bad}" in multipart field "${k}" (allowed: file, content, filename, type)`);
|
|
372
|
+
if (("file" in part) === ("content" in part))
|
|
373
|
+
fail(at("multipart"), `multipart field "${k}" needs one of \`file\` (a path relative to the scenario file) or \`content\``);
|
|
374
|
+
}
|
|
375
|
+
else if (part === null)
|
|
376
|
+
fail(at("multipart"), `multipart field "${k}" has no value`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
297
380
|
if (raw.graphql !== undefined) {
|
|
298
381
|
const g = raw.graphql;
|
|
299
382
|
const ok = (typeof g === "string" && g.trim()) || (g && typeof g === "object" && !Array.isArray(g) && typeof g.query === "string" && Object.keys(g).every((k) => ["query", "variables", "operationName"].includes(k)));
|
|
@@ -318,6 +401,14 @@ function parseStep(node, fail, lineOf) {
|
|
|
318
401
|
fail(at("auth"), "`auth` must be true or the token's claims, e.g. { sub: u1, roles: [admin] }");
|
|
319
402
|
if (raw.concurrency !== undefined && !(Number.isInteger(raw.concurrency) && raw.concurrency >= 1))
|
|
320
403
|
fail(at("concurrency"), "`concurrency` must be a positive whole number");
|
|
404
|
+
if (raw.timeout !== undefined && !(typeof raw.timeout === "number" && raw.timeout > 0))
|
|
405
|
+
fail(at("timeout"), "`timeout` must be a positive number of milliseconds");
|
|
406
|
+
if (raw.every !== undefined && !(typeof raw.every === "number" && raw.every > 0))
|
|
407
|
+
fail(at("every"), "`every` must be a positive number of milliseconds");
|
|
408
|
+
if (raw.every !== undefined && raw.within === undefined)
|
|
409
|
+
fail(at("every"), "`every` sets the pause between attempts of a `within` step; add `within: <ms>`");
|
|
410
|
+
if (raw.within !== undefined && raw.concurrency !== undefined)
|
|
411
|
+
fail(at("within"), "`within` repeats one request until it passes; it can't be combined with `concurrency`");
|
|
321
412
|
{
|
|
322
413
|
const q = raw.expect?.queries;
|
|
323
414
|
if (q !== undefined && !(Number.isInteger(q) && q >= 0))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "slicetest",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
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",
|