slicetest 0.7.0 → 0.8.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"],
@@ -22,13 +25,13 @@ const EXPECT_KEYS = {
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`");
@@ -264,7 +310,13 @@ function parseStep(node, fail, lineOf) {
264
310
  for (const [where, r] of [["reply", raw.reply], ...(Array.isArray(raw.sequence) ? raw.sequence : []).map((r) => ["sequence", r])]) {
265
311
  if (!r || typeof r !== "object")
266
312
  continue;
267
- const { body, sse, data, errors } = r;
313
+ const { body, sse, data, errors, file } = r;
314
+ if (file !== undefined) {
315
+ if (typeof file !== "string" || !file)
316
+ fail(at(where), "`file` is a path relative to the scenario file, e.g. fixtures/charge.json");
317
+ if (body !== undefined || sse !== undefined || data !== undefined || errors !== undefined)
318
+ fail(at(where), "a reply has `file` instead of `body`, `sse`, `data` or `errors`");
319
+ }
268
320
  if (data !== undefined || errors !== undefined) {
269
321
  if (raw.graphql === undefined)
270
322
  fail(at(where), "`data` and `errors` answer a GraphQL operation: use them with `graphql: <operation>`, or send `body`");
@@ -292,8 +344,27 @@ function parseStep(node, fail, lineOf) {
292
344
  }
293
345
  case "request":
294
346
  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");
347
+ if (["json", "form", "multipart", "body", "graphql"].filter((k) => raw[k] !== undefined).length > 1)
348
+ fail(node, "use only one of json / form / multipart / body / graphql");
349
+ if (raw.multipart !== undefined) {
350
+ const m = raw.multipart;
351
+ if (!m || typeof m !== "object" || Array.isArray(m))
352
+ fail(at("multipart"), "`multipart` maps field names to values, e.g. { name: ada, avatar: { file: ada.png } }");
353
+ for (const [k, v] of Object.entries(m)) {
354
+ for (const part of Array.isArray(v) ? v : [v]) {
355
+ if (part && typeof part === "object") {
356
+ const keys = Object.keys(part);
357
+ const bad = keys.find((x) => !["file", "content", "filename", "type"].includes(x));
358
+ if (bad)
359
+ fail(at("multipart"), `unknown key "${bad}" in multipart field "${k}" (allowed: file, content, filename, type)`);
360
+ if (("file" in part) === ("content" in part))
361
+ fail(at("multipart"), `multipart field "${k}" needs one of \`file\` (a path relative to the scenario file) or \`content\``);
362
+ }
363
+ else if (part === null)
364
+ fail(at("multipart"), `multipart field "${k}" has no value`);
365
+ }
366
+ }
367
+ }
297
368
  if (raw.graphql !== undefined) {
298
369
  const g = raw.graphql;
299
370
  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 +389,14 @@ function parseStep(node, fail, lineOf) {
318
389
  fail(at("auth"), "`auth` must be true or the token's claims, e.g. { sub: u1, roles: [admin] }");
319
390
  if (raw.concurrency !== undefined && !(Number.isInteger(raw.concurrency) && raw.concurrency >= 1))
320
391
  fail(at("concurrency"), "`concurrency` must be a positive whole number");
392
+ if (raw.timeout !== undefined && !(typeof raw.timeout === "number" && raw.timeout > 0))
393
+ fail(at("timeout"), "`timeout` must be a positive number of milliseconds");
394
+ if (raw.every !== undefined && !(typeof raw.every === "number" && raw.every > 0))
395
+ fail(at("every"), "`every` must be a positive number of milliseconds");
396
+ if (raw.every !== undefined && raw.within === undefined)
397
+ fail(at("every"), "`every` sets the pause between attempts of a `within` step; add `within: <ms>`");
398
+ if (raw.within !== undefined && raw.concurrency !== undefined)
399
+ fail(at("within"), "`within` repeats one request until it passes; it can't be combined with `concurrency`");
321
400
  {
322
401
  const q = raw.expect?.queries;
323
402
  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.8.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",
@@ -86,6 +86,14 @@
86
86
  "items": {
87
87
  "$ref": "#/$defs/step"
88
88
  }
89
+ },
90
+ "tags": {
91
+ "type": "array",
92
+ "items": {
93
+ "type": "string",
94
+ "pattern": "^[^\\s,!]+$"
95
+ },
96
+ "description": "Labels to select scenarios by: npx slicetest --tag smoke (or !slow to leave some out)"
89
97
  }
90
98
  }
91
99
  }
@@ -127,6 +135,10 @@
127
135
  "json": {
128
136
  "description": "Subset of the request JSON"
129
137
  },
138
+ "form": {
139
+ "type": "object",
140
+ "description": "Subset of a form-encoded body's fields; bracket keys are nested (metadata[order] → metadata: { order })"
141
+ },
130
142
  "body": {},
131
143
  "variables": {
132
144
  "description": "Subset of the GraphQL variables (with `graphql`)"
@@ -149,6 +161,10 @@
149
161
  "body": {
150
162
  "description": "String, or anything else to send as JSON. May use {{call.params.x}}, {{call.json.x}}, {{call.query.x}}"
151
163
  },
164
+ "file": {
165
+ "type": "string",
166
+ "description": "Answer with this file's content, relative to the scenario file: .json and .yaml are sent as JSON (and may use {{call.*}}), other files as they are, with a content type from the extension"
167
+ },
152
168
  "sse": {
153
169
  "type": "array",
154
170
  "description": "A streamed reply (Server-Sent Events), instead of body: a list of { event, data, id }. Data that isn't a string is sent as JSON",
@@ -221,6 +237,30 @@
221
237
  "sse",
222
238
  "errors"
223
239
  ]
240
+ },
241
+ {
242
+ "required": [
243
+ "file",
244
+ "body"
245
+ ]
246
+ },
247
+ {
248
+ "required": [
249
+ "file",
250
+ "sse"
251
+ ]
252
+ },
253
+ {
254
+ "required": [
255
+ "file",
256
+ "data"
257
+ ]
258
+ },
259
+ {
260
+ "required": [
261
+ "file",
262
+ "errors"
263
+ ]
224
264
  }
225
265
  ]
226
266
  }
@@ -243,6 +283,10 @@
243
283
  "body": {
244
284
  "description": "String, or anything else to send as JSON. May use {{call.params.x}}, {{call.json.x}}, {{call.query.x}}"
245
285
  },
286
+ "file": {
287
+ "type": "string",
288
+ "description": "Answer with this file's content, relative to the scenario file: .json and .yaml are sent as JSON (and may use {{call.*}}), other files as they are, with a content type from the extension"
289
+ },
246
290
  "sse": {
247
291
  "type": "array",
248
292
  "description": "A streamed reply (Server-Sent Events), instead of body: a list of { event, data, id }. Data that isn't a string is sent as JSON",
@@ -315,6 +359,30 @@
315
359
  "sse",
316
360
  "errors"
317
361
  ]
362
+ },
363
+ {
364
+ "required": [
365
+ "file",
366
+ "body"
367
+ ]
368
+ },
369
+ {
370
+ "required": [
371
+ "file",
372
+ "sse"
373
+ ]
374
+ },
375
+ {
376
+ "required": [
377
+ "file",
378
+ "data"
379
+ ]
380
+ },
381
+ {
382
+ "required": [
383
+ "file",
384
+ "errors"
385
+ ]
318
386
  }
319
387
  ]
320
388
  }
@@ -389,6 +457,103 @@
389
457
  "form": {
390
458
  "type": "object"
391
459
  },
460
+ "multipart": {
461
+ "type": "object",
462
+ "description": "A multipart/form-data body: fields, and files as { file: avatar.png } (relative to the scenario file) or { content, filename, type }",
463
+ "additionalProperties": {
464
+ "oneOf": [
465
+ {
466
+ "oneOf": [
467
+ {
468
+ "type": [
469
+ "string",
470
+ "number",
471
+ "boolean"
472
+ ]
473
+ },
474
+ {
475
+ "type": "object",
476
+ "additionalProperties": false,
477
+ "properties": {
478
+ "file": {
479
+ "type": "string",
480
+ "description": "Path relative to the scenario file"
481
+ },
482
+ "content": {
483
+ "description": "Inline file content; objects are sent as JSON"
484
+ },
485
+ "filename": {
486
+ "type": "string"
487
+ },
488
+ "type": {
489
+ "type": "string",
490
+ "description": "Content type; from the file extension by default"
491
+ }
492
+ },
493
+ "oneOf": [
494
+ {
495
+ "required": [
496
+ "file"
497
+ ]
498
+ },
499
+ {
500
+ "required": [
501
+ "content"
502
+ ]
503
+ }
504
+ ]
505
+ }
506
+ ]
507
+ },
508
+ {
509
+ "type": "array",
510
+ "items": {
511
+ "oneOf": [
512
+ {
513
+ "type": [
514
+ "string",
515
+ "number",
516
+ "boolean"
517
+ ]
518
+ },
519
+ {
520
+ "type": "object",
521
+ "additionalProperties": false,
522
+ "properties": {
523
+ "file": {
524
+ "type": "string",
525
+ "description": "Path relative to the scenario file"
526
+ },
527
+ "content": {
528
+ "description": "Inline file content; objects are sent as JSON"
529
+ },
530
+ "filename": {
531
+ "type": "string"
532
+ },
533
+ "type": {
534
+ "type": "string",
535
+ "description": "Content type; from the file extension by default"
536
+ }
537
+ },
538
+ "oneOf": [
539
+ {
540
+ "required": [
541
+ "file"
542
+ ]
543
+ },
544
+ {
545
+ "required": [
546
+ "content"
547
+ ]
548
+ }
549
+ ]
550
+ }
551
+ ]
552
+ }
553
+ }
554
+ ]
555
+ }
556
+ },
392
557
  "body": {
393
558
  "type": "string"
394
559
  },
@@ -423,7 +588,14 @@
423
588
  "github",
424
589
  "slack",
425
590
  "shopify",
426
- "standard"
591
+ "standard",
592
+ "line",
593
+ "paddle",
594
+ "linear",
595
+ "gitlab",
596
+ "zoom",
597
+ "twitch",
598
+ "twilio"
427
599
  ]
428
600
  },
429
601
  {
@@ -467,6 +639,10 @@
467
639
  "invalidSignature": {
468
640
  "type": "boolean",
469
641
  "description": "Signed with a different secret"
642
+ },
643
+ "url": {
644
+ "type": "string",
645
+ "description": "The URL the app validates against, for providers that sign it (twilio). Default: the app's address and the request path"
470
646
  }
471
647
  }
472
648
  },
@@ -553,6 +729,21 @@
553
729
  }
554
730
  }
555
731
  ]
732
+ },
733
+ "within": {
734
+ "type": "integer",
735
+ "minimum": 1,
736
+ "description": "Repeat the request until `expect` passes, for up to this many milliseconds (polling a job the app finishes later)"
737
+ },
738
+ "every": {
739
+ "type": "integer",
740
+ "minimum": 1,
741
+ "description": "With `within`: milliseconds between attempts. Default 200"
742
+ },
743
+ "timeout": {
744
+ "type": "integer",
745
+ "minimum": 1,
746
+ "description": "Fail when the app hasn't answered within this many milliseconds (overrides http.timeout)"
556
747
  }
557
748
  },
558
749
  "not": {
@@ -574,8 +765,43 @@
574
765
  "graphql",
575
766
  "body"
576
767
  ]
768
+ },
769
+ {
770
+ "required": [
771
+ "multipart",
772
+ "json"
773
+ ]
774
+ },
775
+ {
776
+ "required": [
777
+ "multipart",
778
+ "form"
779
+ ]
780
+ },
781
+ {
782
+ "required": [
783
+ "multipart",
784
+ "body"
785
+ ]
786
+ },
787
+ {
788
+ "required": [
789
+ "multipart",
790
+ "graphql"
791
+ ]
792
+ },
793
+ {
794
+ "required": [
795
+ "within",
796
+ "concurrency"
797
+ ]
577
798
  }
578
799
  ]
800
+ },
801
+ "dependentRequired": {
802
+ "every": [
803
+ "within"
804
+ ]
579
805
  }
580
806
  },
581
807
  {
@@ -610,7 +836,7 @@
610
836
  },
611
837
  "fields": {
612
838
  "type": "object",
613
- "description": "Values typed into the form by field name; true / false / a list of values check checkboxes and radios",
839
+ "description": "Values typed into the form by field name; true / false / a list of values check checkboxes and radios; { file: path } uploads a file through a file input",
614
840
  "additionalProperties": {
615
841
  "oneOf": [
616
842
  {
@@ -628,6 +854,36 @@
628
854
  "number"
629
855
  ]
630
856
  }
857
+ },
858
+ {
859
+ "type": "object",
860
+ "additionalProperties": false,
861
+ "properties": {
862
+ "file": {
863
+ "type": "string",
864
+ "description": "Path relative to the scenario file"
865
+ },
866
+ "content": {},
867
+ "filename": {
868
+ "type": "string"
869
+ },
870
+ "type": {
871
+ "type": "string"
872
+ }
873
+ },
874
+ "oneOf": [
875
+ {
876
+ "required": [
877
+ "file"
878
+ ]
879
+ },
880
+ {
881
+ "required": [
882
+ "content"
883
+ ]
884
+ }
885
+ ],
886
+ "description": "A file for a file input"
631
887
  }
632
888
  ]
633
889
  }
@@ -940,6 +1196,10 @@
940
1196
  "json": {
941
1197
  "description": "Subset of the request JSON"
942
1198
  },
1199
+ "form": {
1200
+ "type": "object",
1201
+ "description": "Subset of a form-encoded body's fields; bracket keys are nested (metadata[order] → metadata: { order })"
1202
+ },
943
1203
  "body": {},
944
1204
  "variables": {
945
1205
  "description": "Subset of the GraphQL variables (with `graphql`)"
@@ -1038,6 +1298,105 @@
1038
1298
  "type": "integer",
1039
1299
  "minimum": 1,
1040
1300
  "description": "Retry this step for up to this many milliseconds, for effects the app applies asynchronously"
1301
+ },
1302
+ "ignore": {
1303
+ "type": "array",
1304
+ "items": {
1305
+ "type": "string"
1306
+ },
1307
+ "description": "Columns or tables to leave out: updated_at (in every table), orders.synced_at, sessions.*"
1308
+ }
1309
+ }
1310
+ },
1311
+ {
1312
+ "type": "object",
1313
+ "required": [
1314
+ "set"
1315
+ ],
1316
+ "additionalProperties": false,
1317
+ "properties": {
1318
+ "set": {
1319
+ "type": "object",
1320
+ "minProperties": 1,
1321
+ "propertyNames": {
1322
+ "pattern": "^[A-Za-z_]\\w*$"
1323
+ },
1324
+ "description": "Variables for later steps, e.g. { orderId: \"{{$uuid}}\", expires: \"{{$now+1d}}\" }"
1325
+ },
1326
+ "name": {
1327
+ "type": "string",
1328
+ "description": "Label shown when this step fails"
1329
+ }
1330
+ }
1331
+ },
1332
+ {
1333
+ "type": "object",
1334
+ "required": [
1335
+ "order"
1336
+ ],
1337
+ "additionalProperties": false,
1338
+ "properties": {
1339
+ "order": {
1340
+ "type": "array",
1341
+ "minItems": 2,
1342
+ "description": "Calls to stubs in the order they must have happened (others may come in between): \"<stub> METHOD /path\" or { stub, call, when }",
1343
+ "items": {
1344
+ "oneOf": [
1345
+ {
1346
+ "type": "string",
1347
+ "pattern": "^\\S+\\s+([A-Za-z]+|\\*)\\s+/\\S*$"
1348
+ },
1349
+ {
1350
+ "type": "object",
1351
+ "additionalProperties": false,
1352
+ "required": [
1353
+ "stub",
1354
+ "call"
1355
+ ],
1356
+ "properties": {
1357
+ "stub": {
1358
+ "type": "string"
1359
+ },
1360
+ "call": {
1361
+ "type": "string",
1362
+ "pattern": "^([A-Za-z]+|\\*)\\s+/\\S*$"
1363
+ },
1364
+ "when": {
1365
+ "type": "object",
1366
+ "additionalProperties": false,
1367
+ "properties": {
1368
+ "query": {
1369
+ "type": "object"
1370
+ },
1371
+ "headers": {
1372
+ "type": "object"
1373
+ },
1374
+ "json": {
1375
+ "description": "Subset of the request JSON"
1376
+ },
1377
+ "form": {
1378
+ "type": "object",
1379
+ "description": "Subset of a form-encoded body's fields; bracket keys are nested (metadata[order] → metadata: { order })"
1380
+ },
1381
+ "body": {},
1382
+ "variables": {
1383
+ "description": "Subset of the GraphQL variables (with `graphql`)"
1384
+ }
1385
+ }
1386
+ }
1387
+ }
1388
+ }
1389
+ ]
1390
+ }
1391
+ },
1392
+ "within": {
1393
+ "type": "integer",
1394
+ "minimum": 1,
1395
+ "description": "Retry this step for up to this many milliseconds, for effects the app applies asynchronously"
1396
+ },
1397
+ "name": {
1398
+ "type": "string",
1399
+ "description": "Label shown when this step fails"
1041
1400
  }
1042
1401
  }
1043
1402
  },