slicetest 0.6.1 → 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 +177 -24
- package/dist/cli.js +66 -2
- package/dist/config.d.ts +18 -0
- package/dist/config.js +13 -0
- package/dist/connection.d.ts +5 -0
- package/dist/connection.js +31 -0
- package/dist/db.d.ts +12 -1
- package/dist/db.js +33 -6
- package/dist/diagram.d.ts +13 -0
- package/dist/diagram.js +89 -0
- 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 +74 -9
- package/dist/graphql.d.ts +34 -0
- package/dist/graphql.js +56 -0
- package/dist/har.d.ts +49 -0
- package/dist/har.js +107 -0
- package/dist/http.d.ts +21 -3
- package/dist/http.js +47 -10
- package/dist/index.d.ts +5 -3
- package/dist/init.js +184 -15
- package/dist/list.d.ts +26 -0
- package/dist/list.js +71 -0
- package/dist/matchers.d.ts +16 -0
- package/dist/matchers.js +81 -1
- package/dist/openapi.d.ts +21 -0
- package/dist/openapi.js +32 -1
- package/dist/provided.d.ts +1 -0
- package/dist/record.d.ts +1 -1
- package/dist/record.js +3 -1
- package/dist/recording.d.ts +1 -1
- package/dist/recording.js +2 -2
- package/dist/runtime.d.ts +12 -5
- package/dist/runtime.js +78 -22
- package/dist/scenario.d.ts +14 -4
- package/dist/scenario.js +36 -17
- package/dist/schema.d.ts +5 -0
- package/dist/schema.js +73 -0
- package/dist/stub.d.ts +63 -0
- package/dist/stub.js +292 -5
- package/dist/timeline.d.ts +9 -0
- package/dist/timeline.js +6 -0
- 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 +308 -51
- package/dist/yaml.d.ts +82 -6
- package/dist/yaml.js +187 -21
- package/package.json +2 -1
- package/schema/scenario.schema.json +640 -14
package/dist/yaml.d.ts
CHANGED
|
@@ -10,6 +10,11 @@ export interface YamlFile {
|
|
|
10
10
|
path?: string;
|
|
11
11
|
/** Steps run at the start of every scenario in the file. */
|
|
12
12
|
setup: Step[];
|
|
13
|
+
/** Named step lists that `use:` steps run, with `with:` values for their `params`. */
|
|
14
|
+
define: Record<string, {
|
|
15
|
+
params: string[];
|
|
16
|
+
steps: Step[];
|
|
17
|
+
}>;
|
|
13
18
|
scenarios: YamlScenario[];
|
|
14
19
|
}
|
|
15
20
|
export interface YamlScenario {
|
|
@@ -19,46 +24,71 @@ export interface YamlScenario {
|
|
|
19
24
|
skip?: boolean;
|
|
20
25
|
only?: boolean;
|
|
21
26
|
timeout?: number;
|
|
27
|
+
/** Labels to select scenarios by (`npx slicetest --tag smoke`). */
|
|
28
|
+
tags?: string[];
|
|
22
29
|
steps: Step[];
|
|
23
30
|
}
|
|
24
|
-
export type Step = (StubStep | RequestStep | SubmitStep | InsertStep | MakeStep | ChaosStep | SqlStep | DbStep | ReceivedStep | ChangesStep | CheckpointStep | LogStep | SnapshotStep | MailStep) & {
|
|
31
|
+
export type Step = (StubStep | RequestStep | SubmitStep | InsertStep | MakeStep | ChaosStep | SqlStep | DbStep | ReceivedStep | ChangesStep | CheckpointStep | SetStep | OrderStep | LogStep | SnapshotStep | MailStep | UseStep) & {
|
|
25
32
|
line: number;
|
|
26
33
|
name?: string;
|
|
27
34
|
};
|
|
35
|
+
type GraphQLReply = {
|
|
36
|
+
data?: unknown;
|
|
37
|
+
errors?: (string | {
|
|
38
|
+
message: string;
|
|
39
|
+
})[];
|
|
40
|
+
};
|
|
41
|
+
/** Run the steps of a `define:` entry. Values in `with` become variables inside it; what it captures is visible afterwards. */
|
|
42
|
+
export interface UseStep {
|
|
43
|
+
use: string;
|
|
44
|
+
with?: Record<string, unknown>;
|
|
45
|
+
}
|
|
28
46
|
export interface StubStep {
|
|
29
47
|
stub: string;
|
|
30
|
-
|
|
48
|
+
/** METHOD /path; or `graphql` instead. */
|
|
49
|
+
on?: string;
|
|
50
|
+
/** A GraphQL operation name, answered at any path. */
|
|
51
|
+
graphql?: string;
|
|
31
52
|
when?: Conditions;
|
|
32
53
|
/** `sse`: a streamed reply, a list of { event, data, id } (instead of `body`). */
|
|
54
|
+
/** `file`: the body from a file relative to the scenario (.json / .yaml as JSON, with `{{call.*}}`; others as they are). */
|
|
33
55
|
reply?: {
|
|
34
56
|
status?: number;
|
|
35
57
|
headers?: Record<string, string>;
|
|
36
58
|
body?: unknown;
|
|
59
|
+
file?: string;
|
|
37
60
|
sse?: {
|
|
38
61
|
event?: string;
|
|
39
62
|
data: unknown;
|
|
40
63
|
id?: string;
|
|
41
64
|
}[];
|
|
42
|
-
};
|
|
43
|
-
sequence?: {
|
|
65
|
+
} & GraphQLReply;
|
|
66
|
+
sequence?: ({
|
|
44
67
|
status?: number;
|
|
45
68
|
headers?: Record<string, string>;
|
|
46
69
|
body?: unknown;
|
|
70
|
+
file?: string;
|
|
47
71
|
sse?: {
|
|
48
72
|
event?: string;
|
|
49
73
|
data: unknown;
|
|
50
74
|
id?: string;
|
|
51
75
|
}[];
|
|
52
|
-
}[];
|
|
76
|
+
} & GraphQLReply)[];
|
|
53
77
|
networkError?: boolean;
|
|
54
78
|
times?: number;
|
|
55
79
|
delay?: number;
|
|
80
|
+
/** The app may not call this route; `strictStubs` doesn't report it. */
|
|
81
|
+
optional?: boolean;
|
|
56
82
|
}
|
|
57
83
|
export interface Conditions {
|
|
58
84
|
query?: Record<string, unknown>;
|
|
59
85
|
headers?: Record<string, unknown>;
|
|
60
86
|
json?: unknown;
|
|
87
|
+
/** Subset of a form-encoded body's fields, bracket keys nested (`metadata[order]` → `metadata: { order }`). */
|
|
88
|
+
form?: Record<string, unknown>;
|
|
61
89
|
body?: unknown;
|
|
90
|
+
/** Subset of a GraphQL request's variables (with `graphql`). */
|
|
91
|
+
variables?: unknown;
|
|
62
92
|
}
|
|
63
93
|
export interface RequestStep {
|
|
64
94
|
request: string;
|
|
@@ -66,7 +96,15 @@ export interface RequestStep {
|
|
|
66
96
|
query?: Record<string, unknown>;
|
|
67
97
|
json?: unknown;
|
|
68
98
|
form?: Record<string, unknown>;
|
|
99
|
+
/** A `multipart/form-data` body: values are fields, `{ file, type, filename }` uploads a file relative to the scenario, `{ content, filename, type }` an inline one. */
|
|
100
|
+
multipart?: Record<string, unknown>;
|
|
69
101
|
body?: string;
|
|
102
|
+
/** A GraphQL operation sent as the JSON body. */
|
|
103
|
+
graphql?: string | {
|
|
104
|
+
query: string;
|
|
105
|
+
variables?: Record<string, unknown>;
|
|
106
|
+
operationName?: string;
|
|
107
|
+
};
|
|
70
108
|
follow?: boolean;
|
|
71
109
|
/** Send `Authorization: Bearer` with a token from the `auth` issuer: `true`, or the claims, e.g. { sub: u1, roles: [admin] }. */
|
|
72
110
|
auth?: true | Record<string, unknown>;
|
|
@@ -85,7 +123,14 @@ export interface RequestStep {
|
|
|
85
123
|
};
|
|
86
124
|
/** Send the request this many times at once. `expect` applies to every response; `statuses` counts them. */
|
|
87
125
|
concurrency?: number;
|
|
126
|
+
/** Repeat the request until `expect` passes, for up to this many ms: polling a job the app finishes later. */
|
|
127
|
+
within?: number;
|
|
128
|
+
/** With `within`: ms between attempts. Default 200. */
|
|
129
|
+
every?: number;
|
|
130
|
+
/** Fail when the app hasn't answered within this many ms (overrides `http.timeout`). */
|
|
131
|
+
timeout?: number;
|
|
88
132
|
/** `queries`: at most this many SQL statements (not counting BEGIN/COMMIT), with `db.queries` on. */
|
|
133
|
+
/** `schema`: a JSON Schema for the response JSON, inline or `file#/pointer` relative to the scenario file. */
|
|
89
134
|
expect?: {
|
|
90
135
|
status?: number;
|
|
91
136
|
statuses?: Record<string, number>;
|
|
@@ -93,6 +138,7 @@ export interface RequestStep {
|
|
|
93
138
|
json?: unknown;
|
|
94
139
|
text?: unknown;
|
|
95
140
|
queries?: number;
|
|
141
|
+
schema?: string | object;
|
|
96
142
|
};
|
|
97
143
|
capture?: Record<string, string>;
|
|
98
144
|
}
|
|
@@ -105,7 +151,8 @@ export interface SubmitStep {
|
|
|
105
151
|
/** The form by id, name or 0-based position, when the page has several. */
|
|
106
152
|
form?: string | number;
|
|
107
153
|
/** Values typed into the form by field name; true / false / a list check checkboxes and radios. */
|
|
108
|
-
|
|
154
|
+
/** `{ file: path }` (relative to the scenario) or `{ content, filename, type }` for a file input. */
|
|
155
|
+
fields?: Record<string, string | number | boolean | (string | number)[] | FilePart>;
|
|
109
156
|
headers?: Record<string, string>;
|
|
110
157
|
follow?: boolean;
|
|
111
158
|
expect?: {
|
|
@@ -113,6 +160,7 @@ export interface SubmitStep {
|
|
|
113
160
|
headers?: Record<string, unknown>;
|
|
114
161
|
json?: unknown;
|
|
115
162
|
text?: unknown;
|
|
163
|
+
schema?: string | object;
|
|
116
164
|
};
|
|
117
165
|
capture?: Record<string, string>;
|
|
118
166
|
}
|
|
@@ -164,6 +212,8 @@ export interface DbStep {
|
|
|
164
212
|
export interface ReceivedStep {
|
|
165
213
|
received: string;
|
|
166
214
|
call?: string;
|
|
215
|
+
/** A GraphQL operation name, instead of `call`. */
|
|
216
|
+
graphql?: string;
|
|
167
217
|
when?: Conditions;
|
|
168
218
|
/** Exact number of matching calls. Default: at least one. */
|
|
169
219
|
times?: number;
|
|
@@ -182,6 +232,8 @@ export type ChangeSpec = {
|
|
|
182
232
|
export interface ChangesStep {
|
|
183
233
|
changes: Record<string, ChangeSpec>;
|
|
184
234
|
within?: number;
|
|
235
|
+
/** Columns or tables to leave out: `updated_at`, `orders.synced_at`, `sessions.*`. */
|
|
236
|
+
ignore?: string[];
|
|
185
237
|
}
|
|
186
238
|
/** Wait until the app (or `from:` a service) prints a line matching `log` (a regex) during the scenario. */
|
|
187
239
|
export interface LogStep {
|
|
@@ -190,6 +242,29 @@ export interface LogStep {
|
|
|
190
242
|
/** Milliseconds to wait. Default 5000. */
|
|
191
243
|
within?: number;
|
|
192
244
|
}
|
|
245
|
+
/**
|
|
246
|
+
* Calls to stubs happened in this order (others may come in between):
|
|
247
|
+
* `order: ["stripe POST /v1/charges", { stub: mail, call: POST /send, when: { json: { to: a@b.test } } }]`.
|
|
248
|
+
*/
|
|
249
|
+
export interface OrderStep {
|
|
250
|
+
order: (string | {
|
|
251
|
+
stub: string;
|
|
252
|
+
call: string;
|
|
253
|
+
when?: Conditions;
|
|
254
|
+
})[];
|
|
255
|
+
within?: number;
|
|
256
|
+
}
|
|
257
|
+
/** A file to upload: read from `file` (relative to the scenario file), or `content` given inline. */
|
|
258
|
+
export interface FilePart {
|
|
259
|
+
file?: string;
|
|
260
|
+
content?: unknown;
|
|
261
|
+
filename?: string;
|
|
262
|
+
type?: string;
|
|
263
|
+
}
|
|
264
|
+
/** Define variables for later steps: `set: { orderId: "{{$uuid}}" }`. */
|
|
265
|
+
export interface SetStep {
|
|
266
|
+
set: Record<string, unknown>;
|
|
267
|
+
}
|
|
193
268
|
/** Later `changes` steps only see what happens after this step. */
|
|
194
269
|
export interface CheckpointStep {
|
|
195
270
|
checkpoint: true;
|
|
@@ -221,3 +296,4 @@ export interface MailStep {
|
|
|
221
296
|
export declare class YamlScenarioError extends Error {
|
|
222
297
|
}
|
|
223
298
|
export declare function parseScenarioFile(text: string, file: string): YamlFile;
|
|
299
|
+
export {};
|
package/dist/yaml.js
CHANGED
|
@@ -1,33 +1,37 @@
|
|
|
1
1
|
import { isMap, isSeq, LineCounter, parseDocument } from "yaml";
|
|
2
|
+
import { WEBHOOK_PROVIDERS as PROVIDERS } from "./webhook.js";
|
|
2
3
|
const KINDS = {
|
|
3
|
-
stub: ["on", "when", "reply", "sequence", "networkError", "times", "delay"],
|
|
4
|
-
request: ["headers", "query", "json", "form", "body", "follow", "auth", "webhook", "concurrency", "expect", "capture"],
|
|
4
|
+
stub: ["on", "graphql", "when", "reply", "sequence", "networkError", "times", "delay", "optional"],
|
|
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
|
-
received: ["call", "when", "times", "within"],
|
|
10
|
-
changes: ["within"],
|
|
10
|
+
received: ["call", "graphql", "when", "times", "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"],
|
|
16
19
|
chaos: ["failFirst", "errorRate", "statuses", "networkErrorRate", "latency", "seed"],
|
|
20
|
+
use: ["with"],
|
|
17
21
|
};
|
|
18
22
|
const EXPECT_KEYS = {
|
|
19
|
-
request: ["status", "statuses", "headers", "json", "text", "queries"],
|
|
20
|
-
submit: ["status", "headers", "json", "text"],
|
|
23
|
+
request: ["status", "statuses", "headers", "json", "text", "queries", "schema"],
|
|
24
|
+
submit: ["status", "headers", "json", "text", "schema"],
|
|
21
25
|
sql: ["rows", "count"],
|
|
22
26
|
db: ["rows", "count"],
|
|
23
27
|
};
|
|
24
|
-
const CONDITION_KEYS = ["query", "headers", "json", "body"];
|
|
25
|
-
const WEBHOOK_KEYS = ["provider", "secret", "event", "stale", "invalidSignature"];
|
|
26
|
-
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;
|
|
27
31
|
const MAIL_KEYS = ["to", "from", "subject", "text", "html"];
|
|
28
32
|
const CHANGE_KEYS = ["inserted", "updated", "deleted"];
|
|
29
|
-
const RESPONSE_KEYS = ["status", "headers", "body", "sse"];
|
|
30
|
-
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"];
|
|
31
35
|
const CALL = /^([A-Za-z]+|\*)\s+(\/\S*)$/;
|
|
32
36
|
/** A request may also go to a captured URL of the app, e.g. a link from a mail: `GET {{link}}`. */
|
|
33
37
|
const REQUEST = /^[A-Za-z]+\s+(\/\S*|\{\{[^}]+\}\}\S*)$/;
|
|
@@ -48,8 +52,8 @@ export function parseScenarioFile(text, file) {
|
|
|
48
52
|
return fail(root, "expected a mapping with a `scenarios:` list");
|
|
49
53
|
const top = doc.toJS();
|
|
50
54
|
for (const key of Object.keys(top))
|
|
51
|
-
if (
|
|
52
|
-
fail(root, `unknown top-level key "${key}" (expected scenarios, setup)`);
|
|
55
|
+
if (!["scenarios", "setup", "define"].includes(key))
|
|
56
|
+
fail(root, `unknown top-level key "${key}" (expected scenarios, setup, define)`);
|
|
53
57
|
const stepsOf = (seqNode, what) => {
|
|
54
58
|
if (!isSeq(seqNode))
|
|
55
59
|
return fail(seqNode, `${what} must be a list of steps`);
|
|
@@ -59,9 +63,56 @@ export function parseScenarioFile(text, file) {
|
|
|
59
63
|
if (!isSeq(scenariosNode) || scenariosNode.items.length === 0)
|
|
60
64
|
return fail(scenariosNode ?? root, "`scenarios:` must be a non-empty list");
|
|
61
65
|
const setupNode = root.get("setup", true);
|
|
62
|
-
|
|
66
|
+
const define = {};
|
|
67
|
+
const defineNode = root.get("define", true);
|
|
68
|
+
if (defineNode !== undefined) {
|
|
69
|
+
if (!isMap(defineNode))
|
|
70
|
+
return fail(defineNode, "`define:` maps names to step lists, e.g. `define: { login: [ ...steps ] }`");
|
|
71
|
+
for (const pair of defineNode.items) {
|
|
72
|
+
const name = String(pair.key?.value ?? pair.key);
|
|
73
|
+
const value = pair.value;
|
|
74
|
+
if (isSeq(value)) {
|
|
75
|
+
define[name] = { params: [], steps: stepsOf(value, `define "${name}"`) };
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (!isMap(value))
|
|
79
|
+
return fail(value ?? pair.key, `define "${name}" must be a list of steps, or { params, steps }`);
|
|
80
|
+
const raw = value.toJSON();
|
|
81
|
+
for (const k of Object.keys(raw))
|
|
82
|
+
if (k !== "params" && k !== "steps")
|
|
83
|
+
fail(value, `unknown key "${k}" in define "${name}" (allowed: params, steps)`);
|
|
84
|
+
if (raw.params !== undefined && !(Array.isArray(raw.params) && raw.params.every((p) => typeof p === "string" && p)))
|
|
85
|
+
fail(value.get("params", true) ?? value, "`params` must be a list of names, e.g. [email, password]");
|
|
86
|
+
define[name] = { params: raw.params ?? [], steps: stepsOf(value.get("steps", true), `define "${name}": steps`) };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// `use:` must name a definition and give it exactly its params; definitions can't use themselves.
|
|
90
|
+
const checkUses = (steps, chain) => {
|
|
91
|
+
for (const step of steps) {
|
|
92
|
+
if (!("use" in step))
|
|
93
|
+
continue;
|
|
94
|
+
const def = define[step.use];
|
|
95
|
+
const at = `${file}:${step.line}`;
|
|
96
|
+
if (!def)
|
|
97
|
+
throw new YamlScenarioError(`${at}: no definition "${step.use}" (defined: ${Object.keys(define).join(", ") || "none; add a top-level \`define:\`"})`);
|
|
98
|
+
if (chain.includes(step.use))
|
|
99
|
+
throw new YamlScenarioError(`${at}: "${step.use}" uses itself (${[...chain, step.use].join(" → ")})`);
|
|
100
|
+
const given = Object.keys(step.with ?? {});
|
|
101
|
+
const missing = def.params.filter((p) => !given.includes(p));
|
|
102
|
+
const extra = given.filter((g) => !def.params.includes(g));
|
|
103
|
+
if (missing.length)
|
|
104
|
+
throw new YamlScenarioError(`${at}: use ${step.use} needs \`with: { ${missing.join(", ")} }\``);
|
|
105
|
+
if (extra.length)
|
|
106
|
+
throw new YamlScenarioError(`${at}: "${extra.join('", "')}" ${extra.length > 1 ? "aren't params" : "isn't a param"} of ${step.use} (params: ${def.params.join(", ") || "none"})`);
|
|
107
|
+
checkUses(def.steps, [...chain, step.use]);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
for (const [name, def] of Object.entries(define))
|
|
111
|
+
checkUses(def.steps, [name]);
|
|
112
|
+
const parsed = {
|
|
63
113
|
file,
|
|
64
114
|
setup: setupNode ? stepsOf(setupNode, "setup") : [],
|
|
115
|
+
define,
|
|
65
116
|
scenarios: scenariosNode.items.map((node) => {
|
|
66
117
|
if (!isMap(node))
|
|
67
118
|
return fail(node, "each scenario must be a mapping with `name` and `steps`");
|
|
@@ -76,6 +127,9 @@ export function parseScenarioFile(text, file) {
|
|
|
76
127
|
}
|
|
77
128
|
if (raw.timeout !== undefined && typeof raw.timeout !== "number")
|
|
78
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
|
+
}
|
|
79
133
|
return {
|
|
80
134
|
name: raw.name,
|
|
81
135
|
line: lineOf(node),
|
|
@@ -83,10 +137,15 @@ export function parseScenarioFile(text, file) {
|
|
|
83
137
|
skip: raw.skip === true,
|
|
84
138
|
only: raw.only === true,
|
|
85
139
|
timeout: raw.timeout,
|
|
140
|
+
tags: raw.tags,
|
|
86
141
|
steps: stepsOf(node.get("steps", true), `scenario "${raw.name}": steps`),
|
|
87
142
|
};
|
|
88
143
|
}),
|
|
89
144
|
};
|
|
145
|
+
checkUses(parsed.setup, []);
|
|
146
|
+
for (const sc of parsed.scenarios)
|
|
147
|
+
checkUses(sc.steps, []);
|
|
148
|
+
return parsed;
|
|
90
149
|
}
|
|
91
150
|
function parseStep(node, fail, lineOf) {
|
|
92
151
|
if (!isMap(node))
|
|
@@ -104,6 +163,8 @@ function parseStep(node, fail, lineOf) {
|
|
|
104
163
|
fail(at(key), `unknown key "${key}" in a ${kind} step (allowed: ${[...allowed].join(", ")})`);
|
|
105
164
|
}
|
|
106
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.*]");
|
|
107
168
|
const tables = raw.changes;
|
|
108
169
|
if (!tables || typeof tables !== "object" || Array.isArray(tables))
|
|
109
170
|
fail(at(kind), "`changes:` must map table names to { inserted, updated, deleted }");
|
|
@@ -137,11 +198,54 @@ function parseStep(node, fail, lineOf) {
|
|
|
137
198
|
fail(at("fields"), "`fields` must map field names to values, e.g. { email: a@b.test }");
|
|
138
199
|
for (const [k, v] of Object.entries(fields)) {
|
|
139
200
|
const scalar = (x) => typeof x === "string" || typeof x === "number";
|
|
140
|
-
|
|
141
|
-
|
|
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 })`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
else if (kind === "use") {
|
|
208
|
+
if (typeof raw.use !== "string" || !raw.use)
|
|
209
|
+
fail(at(kind), "`use:` names a step list from `define:`");
|
|
210
|
+
if (raw.with !== undefined && (!raw.with || typeof raw.with !== "object" || Array.isArray(raw.with)))
|
|
211
|
+
fail(at("with"), "`with` maps params to values, e.g. { email: a@b.test }");
|
|
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(", ")})`);
|
|
142
239
|
}
|
|
143
240
|
}
|
|
144
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
|
+
}
|
|
145
249
|
else if (kind === "checkpoint") {
|
|
146
250
|
if (raw.checkpoint !== true)
|
|
147
251
|
fail(at(kind), "use `checkpoint: true`");
|
|
@@ -183,11 +287,21 @@ function parseStep(node, fail, lineOf) {
|
|
|
183
287
|
}
|
|
184
288
|
if (kind in EXPECT_KEYS)
|
|
185
289
|
keysOf("expect", EXPECT_KEYS[kind]);
|
|
290
|
+
{
|
|
291
|
+
const schema = raw.expect?.schema;
|
|
292
|
+
if (schema !== undefined && !((typeof schema === "string" && schema) || (schema && typeof schema === "object" && !Array.isArray(schema)))) {
|
|
293
|
+
fail(at("expect"), "`expect.schema` is a JSON Schema, or a file such as openapi.yaml#/components/schemas/Poll");
|
|
294
|
+
}
|
|
295
|
+
}
|
|
186
296
|
keysOf("when", CONDITION_KEYS);
|
|
297
|
+
if (raw.graphql !== undefined && kind !== "request" && (typeof raw.graphql !== "string" || !raw.graphql))
|
|
298
|
+
fail(at("graphql"), "`graphql` is the operation's name, e.g. `graphql: GetUser`");
|
|
299
|
+
if (raw.when?.variables !== undefined && raw.graphql === undefined)
|
|
300
|
+
fail(at("when"), "`when.variables` needs `graphql: <operation>`");
|
|
187
301
|
switch (kind) {
|
|
188
302
|
case "stub": {
|
|
189
|
-
if (raw.on === undefined)
|
|
190
|
-
fail(node, "a stub step needs `on
|
|
303
|
+
if ((raw.on === undefined) === (raw.graphql === undefined))
|
|
304
|
+
fail(node, "a stub step needs one of `on` (e.g. `on: POST /hook`) or `graphql` (e.g. `graphql: GetUser`)");
|
|
191
305
|
call("on");
|
|
192
306
|
const answers = ["reply", "sequence", "networkError"].filter((k) => raw[k] !== undefined);
|
|
193
307
|
if (answers.length !== 1)
|
|
@@ -196,7 +310,22 @@ function parseStep(node, fail, lineOf) {
|
|
|
196
310
|
for (const [where, r] of [["reply", raw.reply], ...(Array.isArray(raw.sequence) ? raw.sequence : []).map((r) => ["sequence", r])]) {
|
|
197
311
|
if (!r || typeof r !== "object")
|
|
198
312
|
continue;
|
|
199
|
-
const { body, sse } = 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
|
+
}
|
|
320
|
+
if (data !== undefined || errors !== undefined) {
|
|
321
|
+
if (raw.graphql === undefined)
|
|
322
|
+
fail(at(where), "`data` and `errors` answer a GraphQL operation: use them with `graphql: <operation>`, or send `body`");
|
|
323
|
+
if (body !== undefined || sse !== undefined)
|
|
324
|
+
fail(at(where), "a GraphQL reply has `data` / `errors`, not `body` or `sse`");
|
|
325
|
+
if (errors !== undefined && !(Array.isArray(errors) && errors.every((e) => typeof e === "string" || (e && typeof e === "object" && typeof e.message === "string")))) {
|
|
326
|
+
fail(at(where), "`errors` must be a list of messages or { message, path, extensions }");
|
|
327
|
+
}
|
|
328
|
+
}
|
|
200
329
|
if (sse === undefined)
|
|
201
330
|
continue;
|
|
202
331
|
if (body !== undefined)
|
|
@@ -209,12 +338,39 @@ function parseStep(node, fail, lineOf) {
|
|
|
209
338
|
fail(at("sequence"), "`sequence` must be a non-empty list of responses");
|
|
210
339
|
number("times");
|
|
211
340
|
number("delay");
|
|
341
|
+
if (raw.optional !== undefined && typeof raw.optional !== "boolean")
|
|
342
|
+
fail(at("optional"), "`optional` must be true or false");
|
|
212
343
|
break;
|
|
213
344
|
}
|
|
214
345
|
case "request":
|
|
215
346
|
call("request", REQUEST);
|
|
216
|
-
if (["json", "form", "body"].filter((k) => raw[k] !== undefined).length > 1)
|
|
217
|
-
fail(node, "use only one of json / form / body");
|
|
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
|
+
}
|
|
368
|
+
if (raw.graphql !== undefined) {
|
|
369
|
+
const g = raw.graphql;
|
|
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)));
|
|
371
|
+
if (!ok)
|
|
372
|
+
fail(at("graphql"), "`graphql` is the query, or { query, variables, operationName }");
|
|
373
|
+
}
|
|
218
374
|
if (raw.webhook !== undefined) {
|
|
219
375
|
const w = raw.webhook;
|
|
220
376
|
if (!w || typeof w !== "object" || Array.isArray(w))
|
|
@@ -233,6 +389,14 @@ function parseStep(node, fail, lineOf) {
|
|
|
233
389
|
fail(at("auth"), "`auth` must be true or the token's claims, e.g. { sub: u1, roles: [admin] }");
|
|
234
390
|
if (raw.concurrency !== undefined && !(Number.isInteger(raw.concurrency) && raw.concurrency >= 1))
|
|
235
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`");
|
|
236
400
|
{
|
|
237
401
|
const q = raw.expect?.queries;
|
|
238
402
|
if (q !== undefined && !(Number.isInteger(q) && q >= 0))
|
|
@@ -296,6 +460,8 @@ function parseStep(node, fail, lineOf) {
|
|
|
296
460
|
break;
|
|
297
461
|
case "received":
|
|
298
462
|
call("call");
|
|
463
|
+
if (raw.call !== undefined && raw.graphql !== undefined)
|
|
464
|
+
fail(node, "a received step takes `call` or `graphql`, not both");
|
|
299
465
|
number("times");
|
|
300
466
|
break;
|
|
301
467
|
case "mail":
|
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",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"language-agnostic",
|
|
20
20
|
"openapi",
|
|
21
21
|
"contract-testing",
|
|
22
|
+
"graphql",
|
|
22
23
|
"jwt",
|
|
23
24
|
"oidc",
|
|
24
25
|
"webhooks",
|