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/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 = ["stripe", "github", "slack", "shopify", "standard"];
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
- if (!(scalar(v) || typeof v === "boolean" || (Array.isArray(v) && v.every(scalar))))
193
- fail(at("fields"), `field "${k}" must be a string, number, true / false or a list`);
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.7.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",