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/README.md +71 -13
- package/dist/cli.js +20 -2
- package/dist/config.d.ts +6 -0
- package/dist/config.js +3 -0
- package/dist/db.d.ts +12 -1
- package/dist/db.js +33 -6
- package/dist/form.d.ts +7 -2
- package/dist/form.js +72 -7
- package/dist/global-setup.d.ts +5 -0
- package/dist/global-setup.js +29 -5
- package/dist/http.d.ts +11 -3
- package/dist/http.js +32 -9
- package/dist/index.d.ts +4 -3
- package/dist/list.d.ts +26 -0
- package/dist/list.js +71 -0
- package/dist/matchers.d.ts +7 -0
- package/dist/matchers.js +34 -0
- package/dist/record.d.ts +1 -1
- package/dist/record.js +3 -1
- package/dist/runtime.js +1 -0
- package/dist/scenario.d.ts +14 -4
- package/dist/scenario.js +36 -20
- package/dist/stub.d.ts +22 -0
- package/dist/stub.js +105 -2
- 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 +244 -34
- package/dist/yaml.d.ts +43 -2
- package/dist/yaml.js +91 -12
- package/package.json +1 -1
- package/schema/scenario.schema.json +361 -2
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 =
|
|
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`");
|
|
@@ -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.
|
|
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
|
},
|