slicetest 0.4.0 → 0.6.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.
Files changed (58) hide show
  1. package/README.md +293 -7
  2. package/dist/app.js +2 -2
  3. package/dist/auth.d.ts +55 -0
  4. package/dist/auth.js +140 -0
  5. package/dist/cli.d.ts +1 -1
  6. package/dist/cli.js +18 -6
  7. package/dist/config.d.ts +72 -5
  8. package/dist/config.js +74 -6
  9. package/dist/containers.d.ts +2 -0
  10. package/dist/containers.js +20 -1
  11. package/dist/db.d.ts +28 -0
  12. package/dist/db.js +68 -0
  13. package/dist/doctor.js +17 -5
  14. package/dist/drivers/driver.d.ts +27 -0
  15. package/dist/drivers/mysql.d.ts +2 -1
  16. package/dist/drivers/mysql.js +38 -0
  17. package/dist/drivers/postgres.d.ts +2 -1
  18. package/dist/drivers/postgres.js +28 -0
  19. package/dist/drivers/sqlite.d.ts +2 -1
  20. package/dist/drivers/sqlite.js +52 -0
  21. package/dist/factory.d.ts +21 -0
  22. package/dist/factory.js +129 -0
  23. package/dist/form.d.ts +45 -0
  24. package/dist/form.js +226 -0
  25. package/dist/gen.d.ts +1 -0
  26. package/dist/gen.js +9 -4
  27. package/dist/global-setup.d.ts +1 -0
  28. package/dist/global-setup.js +90 -34
  29. package/dist/http.d.ts +24 -1
  30. package/dist/http.js +86 -9
  31. package/dist/index.d.ts +6 -1
  32. package/dist/index.js +3 -0
  33. package/dist/init.js +245 -24
  34. package/dist/intercept.d.ts +37 -0
  35. package/dist/intercept.js +199 -0
  36. package/dist/neon.d.ts +14 -0
  37. package/dist/neon.js +113 -0
  38. package/dist/openapi.d.ts +10 -0
  39. package/dist/openapi.js +22 -0
  40. package/dist/query-log.d.ts +49 -0
  41. package/dist/query-log.js +261 -0
  42. package/dist/runtime.d.ts +18 -0
  43. package/dist/runtime.js +163 -23
  44. package/dist/setup-file.js +19 -2
  45. package/dist/stub.d.ts +46 -0
  46. package/dist/stub.js +109 -0
  47. package/dist/vitest.d.ts +11 -1
  48. package/dist/vitest.js +27 -2
  49. package/dist/webhook.d.ts +40 -0
  50. package/dist/webhook.js +52 -0
  51. package/dist/x509.d.ts +37 -0
  52. package/dist/x509.js +150 -0
  53. package/dist/yaml-runtime.js +76 -16
  54. package/dist/yaml.d.ts +67 -1
  55. package/dist/yaml.js +87 -3
  56. package/package.json +12 -4
  57. package/preload/node-proxy.cjs +19 -0
  58. package/schema/scenario.schema.json +320 -0
package/dist/yaml.js CHANGED
@@ -1,7 +1,8 @@
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", "concurrency", "expect", "capture"],
4
+ request: ["headers", "query", "json", "form", "body", "follow", "auth", "webhook", "concurrency", "expect", "capture"],
5
+ submit: ["form", "fields", "headers", "follow", "expect", "capture"],
5
6
  insert: ["rows", "capture"],
6
7
  sql: ["params", "expect", "capture", "within"],
7
8
  db: ["where", "orderBy", "expect", "capture", "within"],
@@ -11,16 +12,21 @@ const KINDS = {
11
12
  checkpoint: [],
12
13
  snapshot: ["mask"],
13
14
  mail: ["times", "within", "capture"],
15
+ make: ["rows", "count", "capture"],
16
+ chaos: ["failFirst", "errorRate", "statuses", "networkErrorRate", "latency", "seed"],
14
17
  };
15
18
  const EXPECT_KEYS = {
16
- request: ["status", "statuses", "headers", "json", "text"],
19
+ request: ["status", "statuses", "headers", "json", "text", "queries"],
20
+ submit: ["status", "headers", "json", "text"],
17
21
  sql: ["rows", "count"],
18
22
  db: ["rows", "count"],
19
23
  };
20
24
  const CONDITION_KEYS = ["query", "headers", "json", "body"];
25
+ const WEBHOOK_KEYS = ["provider", "secret", "event", "stale", "invalidSignature"];
26
+ const WEBHOOK_PROVIDERS = ["stripe", "github", "slack", "shopify", "standard"];
21
27
  const MAIL_KEYS = ["to", "from", "subject", "text", "html"];
22
28
  const CHANGE_KEYS = ["inserted", "updated", "deleted"];
23
- const RESPONSE_KEYS = ["status", "headers", "body"];
29
+ const RESPONSE_KEYS = ["status", "headers", "body", "sse"];
24
30
  const SCENARIO_KEYS = ["name", "steps", "each", "skip", "only", "timeout"];
25
31
  const CALL = /^([A-Za-z]+|\*)\s+(\/\S*)$/;
26
32
  /** A request may also go to a captured URL of the app, e.g. a link from a mail: `GET {{link}}`. */
@@ -120,6 +126,22 @@ function parseStep(node, fail, lineOf) {
120
126
  if (!MAIL_KEYS.includes(k))
121
127
  fail(at(kind), `unknown key "${k}" in mail (allowed: ${MAIL_KEYS.join(", ")})`);
122
128
  }
129
+ else if (kind === "submit") {
130
+ if (raw.submit !== true && (typeof raw.submit !== "string" || !raw.submit.trim()))
131
+ fail(at(kind), "`submit:` names the button to press (its text), or is `true` for the form's only button");
132
+ if (raw.form !== undefined && !(typeof raw.form === "string" || (Number.isInteger(raw.form) && raw.form >= 0)))
133
+ fail(at("form"), "`form` is the form's id or name, or its position on the page (0 for the first)");
134
+ const fields = raw.fields;
135
+ if (fields !== undefined) {
136
+ if (!fields || typeof fields !== "object" || Array.isArray(fields))
137
+ fail(at("fields"), "`fields` must map field names to values, e.g. { email: a@b.test }");
138
+ for (const [k, v] of Object.entries(fields)) {
139
+ const scalar = (x) => typeof x === "string" || typeof x === "number";
140
+ if (!(scalar(v) || typeof v === "boolean" || (Array.isArray(v) && v.every(scalar))))
141
+ fail(at("fields"), `field "${k}" must be a string, number, true / false or a list`);
142
+ }
143
+ }
144
+ }
123
145
  else if (kind === "checkpoint") {
124
146
  if (raw.checkpoint !== true)
125
147
  fail(at(kind), "use `checkpoint: true`");
@@ -171,6 +193,18 @@ function parseStep(node, fail, lineOf) {
171
193
  if (answers.length !== 1)
172
194
  fail(node, "a stub step needs exactly one of reply / sequence / networkError");
173
195
  keysOf("reply", RESPONSE_KEYS);
196
+ for (const [where, r] of [["reply", raw.reply], ...(Array.isArray(raw.sequence) ? raw.sequence : []).map((r) => ["sequence", r])]) {
197
+ if (!r || typeof r !== "object")
198
+ continue;
199
+ const { body, sse } = r;
200
+ if (sse === undefined)
201
+ continue;
202
+ if (body !== undefined)
203
+ fail(at(where), "a reply has either `body` or `sse`, not both");
204
+ if (!Array.isArray(sse) || sse.some((e) => !e || typeof e !== "object" || Array.isArray(e) || !("data" in e) || Object.keys(e).some((k) => !["event", "data", "id"].includes(k)))) {
205
+ fail(at(where), "`sse` must be a list of events such as { event: message_start, data: { ... } }");
206
+ }
207
+ }
174
208
  if (raw.sequence !== undefined && (!Array.isArray(raw.sequence) || raw.sequence.length === 0))
175
209
  fail(at("sequence"), "`sequence` must be a non-empty list of responses");
176
210
  number("times");
@@ -181,8 +215,31 @@ function parseStep(node, fail, lineOf) {
181
215
  call("request", REQUEST);
182
216
  if (["json", "form", "body"].filter((k) => raw[k] !== undefined).length > 1)
183
217
  fail(node, "use only one of json / form / body");
218
+ if (raw.webhook !== undefined) {
219
+ const w = raw.webhook;
220
+ if (!w || typeof w !== "object" || Array.isArray(w))
221
+ fail(at("webhook"), "`webhook` must be a mapping such as { provider: stripe, secret: whsec_test }");
222
+ for (const k of Object.keys(w))
223
+ if (!WEBHOOK_KEYS.includes(k))
224
+ fail(at("webhook"), `unknown key "${k}" in webhook (allowed: ${WEBHOOK_KEYS.join(", ")})`);
225
+ if (typeof w.secret !== "string" || !w.secret)
226
+ fail(at("webhook"), "`webhook.secret` is required: the signing secret the app is configured with");
227
+ const p = w.provider;
228
+ if (!(typeof p === "string" ? WEBHOOK_PROVIDERS.includes(p) : p && typeof p === "object" && typeof p.header === "string")) {
229
+ fail(at("webhook"), `\`webhook.provider\` must be one of ${WEBHOOK_PROVIDERS.join(", ")} or { header, prefix, encoding }`);
230
+ }
231
+ }
232
+ if (raw.auth !== undefined && raw.auth !== true && !(raw.auth && typeof raw.auth === "object" && !Array.isArray(raw.auth)))
233
+ fail(at("auth"), "`auth` must be true or the token's claims, e.g. { sub: u1, roles: [admin] }");
184
234
  if (raw.concurrency !== undefined && !(Number.isInteger(raw.concurrency) && raw.concurrency >= 1))
185
235
  fail(at("concurrency"), "`concurrency` must be a positive whole number");
236
+ {
237
+ const q = raw.expect?.queries;
238
+ if (q !== undefined && !(Number.isInteger(q) && q >= 0))
239
+ fail(at("expect"), "`expect.queries` is the most SQL statements the request may run, e.g. 3");
240
+ if (q !== undefined && raw.concurrency !== undefined)
241
+ fail(at("expect"), "`expect.queries` can't be used with `concurrency`");
242
+ }
186
243
  if (raw.concurrency !== undefined && raw.capture !== undefined)
187
244
  fail(at("capture"), "`capture` can't be used with `concurrency`: there is more than one response");
188
245
  {
@@ -200,6 +257,33 @@ function parseStep(node, fail, lineOf) {
200
257
  if (!raw.rows || typeof raw.rows !== "object")
201
258
  fail(at("rows"), "an insert step needs `rows` (a mapping or a list of mappings)");
202
259
  break;
260
+ case "chaos":
261
+ for (const k of ["errorRate", "networkErrorRate"]) {
262
+ if (raw[k] !== undefined && !(typeof raw[k] === "number" && raw[k] >= 0 && raw[k] <= 1))
263
+ fail(at(k), `\`${k}\` must be a number between 0 and 1`);
264
+ }
265
+ for (const k of ["failFirst", "seed"])
266
+ if (raw[k] !== undefined && !Number.isInteger(raw[k]))
267
+ fail(at(k), `\`${k}\` must be a whole number`);
268
+ if (raw.statuses !== undefined && !(Array.isArray(raw.statuses) && raw.statuses.length > 0 && raw.statuses.every((s) => Number.isInteger(s) && s >= 400 && s <= 599))) {
269
+ fail(at("statuses"), "`statuses` must be a list of 4xx/5xx codes, e.g. [503]");
270
+ }
271
+ {
272
+ const l = raw.latency;
273
+ if (l !== undefined && !(typeof l === "number" || (Array.isArray(l) && l.length === 2 && l.every((x) => typeof x === "number"))))
274
+ fail(at("latency"), "`latency` must be milliseconds or [min, max]");
275
+ }
276
+ break;
277
+ case "make":
278
+ if (raw.rows !== undefined && (!raw.rows || typeof raw.rows !== "object"))
279
+ fail(at("rows"), "`rows` must be a mapping or a list of mappings");
280
+ if (raw.count !== undefined) {
281
+ if (!(Number.isInteger(raw.count) && raw.count >= 1))
282
+ fail(at("count"), "`count` must be a positive whole number");
283
+ if (Array.isArray(raw.rows))
284
+ fail(at("count"), "use either `count` or a list of `rows`");
285
+ }
286
+ break;
203
287
  case "log":
204
288
  try {
205
289
  new RegExp(raw.log);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slicetest",
3
- "version": "0.4.0",
3
+ "version": "0.6.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",
@@ -18,7 +18,13 @@
18
18
  "testcontainers",
19
19
  "language-agnostic",
20
20
  "openapi",
21
- "contract-testing"
21
+ "contract-testing",
22
+ "jwt",
23
+ "oidc",
24
+ "webhooks",
25
+ "chaos-testing",
26
+ "n-plus-one",
27
+ "test-data-factory"
22
28
  ],
23
29
  "author": "pon-bok",
24
30
  "repository": {
@@ -47,6 +53,7 @@
47
53
  },
48
54
  "files": [
49
55
  "dist",
56
+ "preload",
50
57
  "schema"
51
58
  ],
52
59
  "engines": {
@@ -60,9 +67,9 @@
60
67
  "prepublishOnly": "npm run typecheck && npm run build"
61
68
  },
62
69
  "peerDependencies": {
63
- "vitest": ">=5",
70
+ "@testcontainers/mysql": ">=11",
64
71
  "mysql2": ">=3",
65
- "@testcontainers/mysql": ">=11"
72
+ "vitest": ">=4"
66
73
  },
67
74
  "dependencies": {
68
75
  "@testcontainers/postgresql": "^12.2.0",
@@ -73,6 +80,7 @@
73
80
  "yaml": "^2.9.1"
74
81
  },
75
82
  "devDependencies": {
83
+ "@neondatabase/serverless": "^1.1.0",
76
84
  "@testcontainers/mysql": "^12.2.0",
77
85
  "@types/node": "^26.6.3",
78
86
  "@types/pg": "^8.23.1",
@@ -0,0 +1,19 @@
1
+ // Loaded into Node apps (NODE_OPTIONS=--require) while slicetest intercepts their outbound calls.
2
+ // NODE_USE_ENV_PROXY covers fetch and the default agents; libraries that make their own
3
+ // http(s).Agent (the Stripe SDK, many API clients) would otherwise go straight to the real
4
+ // host. Agents made from here on get the proxy settings too, unless they set their own.
5
+ "use strict";
6
+ const http = require("node:http");
7
+ const https = require("node:https");
8
+ const { syncBuiltinESMExports } = require("node:module");
9
+
10
+ for (const mod of [http, https]) {
11
+ const Original = mod.Agent;
12
+ class Agent extends Original {
13
+ constructor(options = {}) {
14
+ super({ proxyEnv: process.env, ...options });
15
+ }
16
+ }
17
+ mod.Agent = Agent;
18
+ }
19
+ syncBuiltinESMExports();
@@ -111,7 +111,33 @@
111
111
  },
112
112
  "body": {
113
113
  "description": "String, or anything else to send as JSON. May use {{call.params.x}}, {{call.json.x}}, {{call.query.x}}"
114
+ },
115
+ "sse": {
116
+ "type": "array",
117
+ "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",
118
+ "items": {
119
+ "type": "object",
120
+ "additionalProperties": false,
121
+ "required": [
122
+ "data"
123
+ ],
124
+ "properties": {
125
+ "event": {
126
+ "type": "string"
127
+ },
128
+ "data": {},
129
+ "id": {
130
+ "type": "string"
131
+ }
132
+ }
133
+ }
114
134
  }
135
+ },
136
+ "not": {
137
+ "required": [
138
+ "body",
139
+ "sse"
140
+ ]
115
141
  }
116
142
  },
117
143
  "sequence": {
@@ -131,7 +157,33 @@
131
157
  },
132
158
  "body": {
133
159
  "description": "String, or anything else to send as JSON. May use {{call.params.x}}, {{call.json.x}}, {{call.query.x}}"
160
+ },
161
+ "sse": {
162
+ "type": "array",
163
+ "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",
164
+ "items": {
165
+ "type": "object",
166
+ "additionalProperties": false,
167
+ "required": [
168
+ "data"
169
+ ],
170
+ "properties": {
171
+ "event": {
172
+ "type": "string"
173
+ },
174
+ "data": {},
175
+ "id": {
176
+ "type": "string"
177
+ }
178
+ }
179
+ }
134
180
  }
181
+ },
182
+ "not": {
183
+ "required": [
184
+ "body",
185
+ "sse"
186
+ ]
135
187
  }
136
188
  },
137
189
  "minItems": 1
@@ -180,6 +232,81 @@
180
232
  "follow": {
181
233
  "type": "boolean"
182
234
  },
235
+ "auth": {
236
+ "description": "Send Authorization: Bearer with a token from the auth issuer: true, or the token's claims",
237
+ "oneOf": [
238
+ {
239
+ "const": true
240
+ },
241
+ {
242
+ "type": "object"
243
+ }
244
+ ]
245
+ },
246
+ "webhook": {
247
+ "type": "object",
248
+ "description": "Sign the body as this provider delivers webhooks",
249
+ "required": [
250
+ "provider",
251
+ "secret"
252
+ ],
253
+ "additionalProperties": false,
254
+ "properties": {
255
+ "provider": {
256
+ "oneOf": [
257
+ {
258
+ "enum": [
259
+ "stripe",
260
+ "github",
261
+ "slack",
262
+ "shopify",
263
+ "standard"
264
+ ]
265
+ },
266
+ {
267
+ "type": "object",
268
+ "required": [
269
+ "header"
270
+ ],
271
+ "additionalProperties": false,
272
+ "properties": {
273
+ "header": {
274
+ "type": "string"
275
+ },
276
+ "algorithm": {
277
+ "type": "string"
278
+ },
279
+ "encoding": {
280
+ "enum": [
281
+ "hex",
282
+ "base64"
283
+ ]
284
+ },
285
+ "prefix": {
286
+ "type": "string"
287
+ }
288
+ }
289
+ }
290
+ ]
291
+ },
292
+ "secret": {
293
+ "type": "string",
294
+ "description": "The signing secret the app is configured with"
295
+ },
296
+ "event": {
297
+ "type": "string",
298
+ "description": "Event type, e.g. X-GitHub-Event"
299
+ },
300
+ "stale": {
301
+ "type": "boolean",
302
+ "description": "Signed ten minutes ago"
303
+ },
304
+ "invalidSignature": {
305
+ "type": "boolean",
306
+ "description": "Signed with a different secret"
307
+ }
308
+ }
309
+ },
183
310
  "concurrency": {
184
311
  "type": "integer",
185
312
  "minimum": 1,
@@ -212,6 +339,11 @@
212
339
  "minimum": 0
213
340
  },
214
341
  "description": "With `concurrency`: how many responses have each status, e.g. { 201: 1, 409: 9 }"
342
+ },
343
+ "queries": {
344
+ "type": "integer",
345
+ "minimum": 0,
346
+ "description": "At most this many SQL statements for this request, not counting BEGIN/COMMIT (needs db.queries)"
215
347
  }
216
348
  }
217
349
  },
@@ -224,6 +356,94 @@
224
356
  }
225
357
  }
226
358
  },
359
+ {
360
+ "type": "object",
361
+ "required": [
362
+ "submit"
363
+ ],
364
+ "additionalProperties": false,
365
+ "properties": {
366
+ "submit": {
367
+ "oneOf": [
368
+ {
369
+ "type": "string",
370
+ "minLength": 1
371
+ },
372
+ {
373
+ "const": true
374
+ }
375
+ ],
376
+ "description": "Submit a form of the page the last request returned, as a browser with JavaScript off would (hidden fields such as CSRF tokens and Next.js server action ids included). Names the button to press by its text, value, name or id; `true` for the form's only button"
377
+ },
378
+ "name": {
379
+ "type": "string",
380
+ "description": "Label shown when this step fails"
381
+ },
382
+ "form": {
383
+ "type": [
384
+ "string",
385
+ "integer"
386
+ ],
387
+ "description": "The form by id, name or 0-based position, when the page has several"
388
+ },
389
+ "fields": {
390
+ "type": "object",
391
+ "description": "Values typed into the form by field name; true / false / a list of values check checkboxes and radios",
392
+ "additionalProperties": {
393
+ "oneOf": [
394
+ {
395
+ "type": [
396
+ "string",
397
+ "number",
398
+ "boolean"
399
+ ]
400
+ },
401
+ {
402
+ "type": "array",
403
+ "items": {
404
+ "type": [
405
+ "string",
406
+ "number"
407
+ ]
408
+ }
409
+ }
410
+ ]
411
+ }
412
+ },
413
+ "headers": {
414
+ "type": "object"
415
+ },
416
+ "follow": {
417
+ "type": "boolean"
418
+ },
419
+ "expect": {
420
+ "type": "object",
421
+ "additionalProperties": false,
422
+ "properties": {
423
+ "status": {
424
+ "type": [
425
+ "integer",
426
+ "string"
427
+ ]
428
+ },
429
+ "headers": {
430
+ "type": "object"
431
+ },
432
+ "json": {
433
+ "description": "Subset of the response JSON"
434
+ },
435
+ "text": {}
436
+ }
437
+ },
438
+ "capture": {
439
+ "type": "object",
440
+ "description": "Save values as variables for later steps, e.g. { pollId: json.id }",
441
+ "additionalProperties": {
442
+ "type": "string"
443
+ }
444
+ }
445
+ }
446
+ },
227
447
  {
228
448
  "type": "object",
229
449
  "required": [
@@ -255,6 +475,106 @@
255
475
  }
256
476
  }
257
477
  },
478
+ {
479
+ "type": "object",
480
+ "required": [
481
+ "make"
482
+ ],
483
+ "additionalProperties": false,
484
+ "properties": {
485
+ "make": {
486
+ "type": "string",
487
+ "description": "Table to make rows in: required columns and parent rows are filled from the schema"
488
+ },
489
+ "name": {
490
+ "type": "string",
491
+ "description": "Label shown when this step fails"
492
+ },
493
+ "rows": {
494
+ "type": [
495
+ "object",
496
+ "array"
497
+ ],
498
+ "description": "Columns to set: a mapping, or a list of mappings for several rows"
499
+ },
500
+ "count": {
501
+ "type": "integer",
502
+ "minimum": 1,
503
+ "description": "Make this many rows, each with `rows`"
504
+ },
505
+ "capture": {
506
+ "type": "object",
507
+ "description": "Save values as variables for later steps, e.g. { orderId: row.id }",
508
+ "additionalProperties": {
509
+ "type": "string"
510
+ }
511
+ }
512
+ }
513
+ },
514
+ {
515
+ "type": "object",
516
+ "required": [
517
+ "chaos"
518
+ ],
519
+ "additionalProperties": false,
520
+ "properties": {
521
+ "chaos": {
522
+ "type": "string",
523
+ "description": "Stub to inject faults into, for the rest of the scenario"
524
+ },
525
+ "name": {
526
+ "type": "string",
527
+ "description": "Label shown when this step fails"
528
+ },
529
+ "failFirst": {
530
+ "type": "integer",
531
+ "minimum": 0,
532
+ "description": "Fail the first n calls, then answer normally"
533
+ },
534
+ "errorRate": {
535
+ "type": "number",
536
+ "minimum": 0,
537
+ "maximum": 1,
538
+ "description": "Share of calls answered with an error status"
539
+ },
540
+ "statuses": {
541
+ "type": "array",
542
+ "minItems": 1,
543
+ "items": {
544
+ "type": "integer",
545
+ "minimum": 400,
546
+ "maximum": 599
547
+ },
548
+ "description": "Error statuses to pick from (default 500, 502, 503)"
549
+ },
550
+ "networkErrorRate": {
551
+ "type": "number",
552
+ "minimum": 0,
553
+ "maximum": 1,
554
+ "description": "Share of calls whose connection is dropped"
555
+ },
556
+ "latency": {
557
+ "oneOf": [
558
+ {
559
+ "type": "number"
560
+ },
561
+ {
562
+ "type": "array",
563
+ "items": {
564
+ "type": "number"
565
+ },
566
+ "minItems": 2,
567
+ "maxItems": 2
568
+ }
569
+ ],
570
+ "description": "Extra delay in ms, or [min, max]"
571
+ },
572
+ "seed": {
573
+ "type": "integer",
574
+ "description": "Seed for the random faults"
575
+ }
576
+ }
577
+ },
258
578
  {
259
579
  "type": "object",
260
580
  "required": [