slicetest 0.6.0 → 0.7.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 +110 -15
- package/dist/app.js +37 -9
- package/dist/cli.js +46 -0
- package/dist/config.d.ts +12 -0
- package/dist/config.js +10 -0
- package/dist/connection.d.ts +5 -0
- package/dist/connection.js +31 -0
- package/dist/diagram.d.ts +13 -0
- package/dist/diagram.js +89 -0
- package/dist/global-setup.js +45 -4
- 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 +12 -0
- package/dist/http.js +71 -16
- package/dist/index.d.ts +2 -1
- package/dist/init.js +184 -15
- package/dist/matchers.d.ts +9 -0
- package/dist/matchers.js +47 -1
- package/dist/openapi.d.ts +21 -0
- package/dist/openapi.js +32 -1
- package/dist/provided.d.ts +1 -0
- package/dist/recording.d.ts +1 -1
- package/dist/recording.js +2 -2
- package/dist/runtime.d.ts +12 -5
- package/dist/runtime.js +77 -22
- package/dist/scenario.js +3 -0
- package/dist/schema.d.ts +5 -0
- package/dist/schema.js +73 -0
- package/dist/stub.d.ts +41 -0
- package/dist/stub.js +187 -3
- package/dist/timeline.d.ts +9 -0
- package/dist/timeline.js +6 -0
- package/dist/yaml-runtime.js +67 -20
- package/dist/yaml.d.ts +40 -5
- package/dist/yaml.js +102 -15
- package/package.json +2 -1
- package/schema/scenario.schema.json +279 -12
package/dist/yaml-runtime.js
CHANGED
|
@@ -5,6 +5,9 @@ import { formatChanges } from "./db.js";
|
|
|
5
5
|
import "./matchers.js";
|
|
6
6
|
import { scenario } from "./scenario.js";
|
|
7
7
|
import { signWebhook, webhookBody } from "./webhook.js";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { graphqlErrors } from "./graphql.js";
|
|
10
|
+
import { schemaProblems } from "./schema.js";
|
|
8
11
|
import { sse } from "./stub.js";
|
|
9
12
|
/** Registers the scenarios of one parsed YAML file. Called from the module the plugin generates for it. */
|
|
10
13
|
export function defineYamlScenarios(doc) {
|
|
@@ -17,20 +20,30 @@ export function defineYamlScenarios(doc) {
|
|
|
17
20
|
});
|
|
18
21
|
}
|
|
19
22
|
}
|
|
20
|
-
async function runSteps(doc, sc, steps, ctx, vars) {
|
|
23
|
+
async function runSteps(doc, sc, steps, ctx, vars, trail = []) {
|
|
21
24
|
for (const [i, step] of steps.entries()) {
|
|
25
|
+
if ("use" in step) {
|
|
26
|
+
// The definition sees the scenario's variables plus `with`; what it captures is kept afterwards.
|
|
27
|
+
const args = interpolate(step.with ?? {}, vars) ?? {};
|
|
28
|
+
const inner = { ...vars, ...args };
|
|
29
|
+
await runSteps(doc, sc, doc.define[step.use].steps, ctx, inner, [...trail, `step ${i + 1}: use ${step.use}`]);
|
|
30
|
+
for (const [k, v] of Object.entries(inner))
|
|
31
|
+
if (!(k in args))
|
|
32
|
+
vars[k] = v;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
22
35
|
try {
|
|
23
36
|
// A log step waits by itself; `within` is its timeout.
|
|
24
37
|
// Log and mail steps wait by themselves; `within` is their timeout.
|
|
25
|
-
await retry("within" in step && !("log" in step) && !("mail" in step) ? step.within : undefined, () => runStep(step, ctx, vars));
|
|
38
|
+
await retry("within" in step && !("log" in step) && !("mail" in step) ? step.within : undefined, () => runStep(step, ctx, vars, doc.path ? path.dirname(doc.path) : undefined));
|
|
26
39
|
}
|
|
27
40
|
catch (e) {
|
|
28
41
|
const label = step.name ?? describeStep(step);
|
|
29
|
-
const where = `${doc.file}:${step.line} (${sc.name}, step ${i + 1}: ${label})`;
|
|
42
|
+
const where = `${doc.file}:${step.line} (${sc.name}, ${[...trail, `step ${i + 1}: ${label}`].join(" → ")})`;
|
|
30
43
|
const ciDir = inject("slicetestDb")?.ciDir;
|
|
31
44
|
if (ciDir && doc.path) {
|
|
32
45
|
const message = (e instanceof Error ? e.message : String(e)).replace(/\x1b\[[0-9;]*m/g, "");
|
|
33
|
-
await recordYamlFailure(ciDir, { file: doc.path, line: step.line, scenario: sc.name, step: `step ${i + 1}: ${label}
|
|
46
|
+
await recordYamlFailure(ciDir, { file: doc.path, line: step.line, scenario: sc.name, step: [...trail, `step ${i + 1}: ${label}`].join(" → "), message }).catch(() => { });
|
|
34
47
|
}
|
|
35
48
|
if (e instanceof Error) {
|
|
36
49
|
e.message = `${where}\n${e.message}`;
|
|
@@ -41,14 +54,16 @@ async function runSteps(doc, sc, steps, ctx, vars) {
|
|
|
41
54
|
}
|
|
42
55
|
}
|
|
43
56
|
function describeStep(step) {
|
|
57
|
+
if ("use" in step)
|
|
58
|
+
return `use ${step.use}`;
|
|
44
59
|
if ("submit" in step)
|
|
45
60
|
return step.submit === true ? "submit" : `submit ${JSON.stringify(step.submit)}`;
|
|
46
61
|
if ("request" in step)
|
|
47
62
|
return step.concurrency ? `${step.request} ×${step.concurrency}` : step.request;
|
|
48
63
|
if ("stub" in step)
|
|
49
|
-
return `stub ${step.stub} ${step.on}`;
|
|
64
|
+
return `stub ${step.stub} ${step.on ?? `GraphQL ${step.graphql}`}`;
|
|
50
65
|
if ("received" in step)
|
|
51
|
-
return `received ${step.received}${step.call ? ` ${step.call}` : ""}`;
|
|
66
|
+
return `received ${step.received}${step.call ? ` ${step.call}` : step.graphql ? ` GraphQL ${step.graphql}` : ""}`;
|
|
52
67
|
if ("insert" in step)
|
|
53
68
|
return `insert ${step.insert}`;
|
|
54
69
|
if ("make" in step)
|
|
@@ -88,7 +103,9 @@ async function retry(within, fn) {
|
|
|
88
103
|
}
|
|
89
104
|
}
|
|
90
105
|
}
|
|
91
|
-
async function runStep(step, ctx, vars) {
|
|
106
|
+
async function runStep(step, ctx, vars, base) {
|
|
107
|
+
if ("use" in step)
|
|
108
|
+
throw new Error("slicetest: use steps are expanded by runSteps");
|
|
92
109
|
if ("checkpoint" in step) {
|
|
93
110
|
await ctx.db.checkpoint();
|
|
94
111
|
return;
|
|
@@ -145,15 +162,28 @@ async function runStep(step, ctx, vars) {
|
|
|
145
162
|
return;
|
|
146
163
|
}
|
|
147
164
|
if ("stub" in step) {
|
|
148
|
-
const
|
|
149
|
-
let route
|
|
165
|
+
const stub = ctx.stub(step.stub);
|
|
166
|
+
let route;
|
|
167
|
+
if (step.graphql !== undefined) {
|
|
168
|
+
const { variables, ...when } = conditions(step.when, vars);
|
|
169
|
+
route = stub.graphql(interpolate(step.graphql, vars), { ...when, variables });
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
const [method, path] = splitCall(step.on);
|
|
173
|
+
route = stub.on(method, interpolate(path, vars), conditions(step.when, vars));
|
|
174
|
+
}
|
|
150
175
|
if (step.times !== undefined)
|
|
151
176
|
route = route.times(step.times);
|
|
152
177
|
if (step.delay !== undefined)
|
|
153
178
|
route = route.delay(step.delay);
|
|
154
|
-
|
|
179
|
+
if (step.optional)
|
|
180
|
+
route = route.optional();
|
|
181
|
+
// Replies are interpolated when a call arrives, so they can echo it: {{call.params.id}}, {{call.json.name}}, {{call.variables.id}}.
|
|
155
182
|
const answer = (template) => (call) => {
|
|
156
|
-
const { sse: events, ...response } = interpolate(template, { ...vars, call: callVars(call) });
|
|
183
|
+
const { sse: events, data, errors, ...response } = interpolate(template, { ...vars, call: callVars(call) });
|
|
184
|
+
if (data !== undefined || errors !== undefined) {
|
|
185
|
+
return { status: 200, ...response, body: { ...(errors ? { errors: graphqlErrors(errors) } : {}), ...(data !== undefined ? { data } : {}) } };
|
|
186
|
+
}
|
|
157
187
|
return events ? sse(events, response) : response;
|
|
158
188
|
};
|
|
159
189
|
if (step.networkError)
|
|
@@ -178,7 +208,7 @@ async function runStep(step, ctx, vars) {
|
|
|
178
208
|
headers: interpolate(step.headers, vars),
|
|
179
209
|
follow: step.follow,
|
|
180
210
|
});
|
|
181
|
-
verifyResponse(res, step.expect, vars);
|
|
211
|
+
verifyResponse(res, step.expect, vars, base);
|
|
182
212
|
capture(step.capture, { status: res.status, json: res.json, text: res.text, headers: Object.fromEntries(res.headers) }, vars);
|
|
183
213
|
return;
|
|
184
214
|
}
|
|
@@ -191,18 +221,27 @@ async function runStep(step, ctx, vars) {
|
|
|
191
221
|
query: interpolate(step.query, vars),
|
|
192
222
|
follow: step.follow,
|
|
193
223
|
};
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
224
|
+
const gql = step.graphql === undefined ? undefined : interpolate(typeof step.graphql === "string" ? { query: step.graphql } : step.graphql, vars);
|
|
225
|
+
let body = gql !== undefined
|
|
226
|
+
? gql
|
|
227
|
+
: step.json !== undefined
|
|
228
|
+
? interpolate(step.json, vars)
|
|
229
|
+
: step.form !== undefined
|
|
230
|
+
? ctx.http.form(interpolate(step.form, vars))
|
|
231
|
+
: interpolate(step.body, vars);
|
|
199
232
|
if (step.webhook) {
|
|
200
233
|
const signed = webhookBody(body);
|
|
201
234
|
body = signed.body;
|
|
202
235
|
opts.headers = { "content-type": signed.type, ...signWebhook(signed.body, interpolate(step.webhook, vars)), ...opts.headers };
|
|
203
236
|
}
|
|
204
237
|
const e = step.expect;
|
|
205
|
-
const verify = (res) =>
|
|
238
|
+
const verify = (res) => {
|
|
239
|
+
// GraphQL servers answer errors with 200, so a status check alone would pass them.
|
|
240
|
+
const expectsErrors = !!e?.json && typeof e.json === "object" && "errors" in e.json;
|
|
241
|
+
if (gql && !expectsErrors && Array.isArray(res.json?.errors) && res.json.errors.length)
|
|
242
|
+
expect(res).toHaveGraphQLData();
|
|
243
|
+
verifyResponse(res, e, vars, base);
|
|
244
|
+
};
|
|
206
245
|
if (step.concurrency !== undefined) {
|
|
207
246
|
// Each request gets its own body: a URLSearchParams body can only be read once.
|
|
208
247
|
const all = await ctx.http.concurrently(step.concurrency, () => ctx.http.request(method, path, body instanceof URLSearchParams ? new URLSearchParams(body) : body, opts));
|
|
@@ -263,7 +302,9 @@ async function runStep(step, ctx, vars) {
|
|
|
263
302
|
// received
|
|
264
303
|
const [method, path] = step.call ? splitCall(step.call) : [undefined, undefined];
|
|
265
304
|
const stub = ctx.stub(step.received);
|
|
266
|
-
const match = conditions(step.when, vars);
|
|
305
|
+
const { variables, ...match } = conditions(step.when, vars);
|
|
306
|
+
if (step.graphql !== undefined)
|
|
307
|
+
match.graphql = { operation: interpolate(step.graphql, vars), variables };
|
|
267
308
|
const p = path && interpolate(path, vars);
|
|
268
309
|
if (step.times === undefined)
|
|
269
310
|
expect(stub).toHaveReceived(method ?? "*", p ?? /.*/, match);
|
|
@@ -311,6 +352,7 @@ function callVars(call) {
|
|
|
311
352
|
headers: call.headers,
|
|
312
353
|
json: call.json,
|
|
313
354
|
body: call.body,
|
|
355
|
+
variables: call.graphql?.variables,
|
|
314
356
|
};
|
|
315
357
|
}
|
|
316
358
|
function splitCall(s) {
|
|
@@ -406,7 +448,7 @@ function interpolateTitle(name, row, index) {
|
|
|
406
448
|
return v === undefined ? m : typeof v === "object" ? JSON.stringify(v) : String(v);
|
|
407
449
|
});
|
|
408
450
|
}
|
|
409
|
-
function verifyResponse(res, e, vars) {
|
|
451
|
+
function verifyResponse(res, e, vars, base) {
|
|
410
452
|
if (e?.status !== undefined)
|
|
411
453
|
expect(res).toHaveStatus(interpolate(e.status, vars));
|
|
412
454
|
if (e?.headers !== undefined) {
|
|
@@ -417,4 +459,9 @@ function verifyResponse(res, e, vars) {
|
|
|
417
459
|
check(res.json, e.json, vars, "response JSON");
|
|
418
460
|
if (e?.text !== undefined)
|
|
419
461
|
check(res.text, e.text, vars, "response text");
|
|
462
|
+
if (e?.schema !== undefined) {
|
|
463
|
+
const problems = schemaProblems(e.schema, res.json, base);
|
|
464
|
+
if (problems.length)
|
|
465
|
+
throw new Error(`${res.method} ${res.url}: the response JSON doesn't match ${typeof e.schema === "string" ? e.schema : "the schema"}:\n${problems.map((p) => ` ${p}`).join("\n")}`);
|
|
466
|
+
}
|
|
420
467
|
}
|
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 {
|
|
@@ -21,13 +26,27 @@ export interface YamlScenario {
|
|
|
21
26
|
timeout?: number;
|
|
22
27
|
steps: Step[];
|
|
23
28
|
}
|
|
24
|
-
export type Step = (StubStep | RequestStep | SubmitStep | InsertStep | MakeStep | ChaosStep | SqlStep | DbStep | ReceivedStep | ChangesStep | CheckpointStep | LogStep | SnapshotStep | MailStep) & {
|
|
29
|
+
export type Step = (StubStep | RequestStep | SubmitStep | InsertStep | MakeStep | ChaosStep | SqlStep | DbStep | ReceivedStep | ChangesStep | CheckpointStep | LogStep | SnapshotStep | MailStep | UseStep) & {
|
|
25
30
|
line: number;
|
|
26
31
|
name?: string;
|
|
27
32
|
};
|
|
33
|
+
type GraphQLReply = {
|
|
34
|
+
data?: unknown;
|
|
35
|
+
errors?: (string | {
|
|
36
|
+
message: string;
|
|
37
|
+
})[];
|
|
38
|
+
};
|
|
39
|
+
/** Run the steps of a `define:` entry. Values in `with` become variables inside it; what it captures is visible afterwards. */
|
|
40
|
+
export interface UseStep {
|
|
41
|
+
use: string;
|
|
42
|
+
with?: Record<string, unknown>;
|
|
43
|
+
}
|
|
28
44
|
export interface StubStep {
|
|
29
45
|
stub: string;
|
|
30
|
-
|
|
46
|
+
/** METHOD /path; or `graphql` instead. */
|
|
47
|
+
on?: string;
|
|
48
|
+
/** A GraphQL operation name, answered at any path. */
|
|
49
|
+
graphql?: string;
|
|
31
50
|
when?: Conditions;
|
|
32
51
|
/** `sse`: a streamed reply, a list of { event, data, id } (instead of `body`). */
|
|
33
52
|
reply?: {
|
|
@@ -39,8 +58,8 @@ export interface StubStep {
|
|
|
39
58
|
data: unknown;
|
|
40
59
|
id?: string;
|
|
41
60
|
}[];
|
|
42
|
-
};
|
|
43
|
-
sequence?: {
|
|
61
|
+
} & GraphQLReply;
|
|
62
|
+
sequence?: ({
|
|
44
63
|
status?: number;
|
|
45
64
|
headers?: Record<string, string>;
|
|
46
65
|
body?: unknown;
|
|
@@ -49,16 +68,20 @@ export interface StubStep {
|
|
|
49
68
|
data: unknown;
|
|
50
69
|
id?: string;
|
|
51
70
|
}[];
|
|
52
|
-
}[];
|
|
71
|
+
} & GraphQLReply)[];
|
|
53
72
|
networkError?: boolean;
|
|
54
73
|
times?: number;
|
|
55
74
|
delay?: number;
|
|
75
|
+
/** The app may not call this route; `strictStubs` doesn't report it. */
|
|
76
|
+
optional?: boolean;
|
|
56
77
|
}
|
|
57
78
|
export interface Conditions {
|
|
58
79
|
query?: Record<string, unknown>;
|
|
59
80
|
headers?: Record<string, unknown>;
|
|
60
81
|
json?: unknown;
|
|
61
82
|
body?: unknown;
|
|
83
|
+
/** Subset of a GraphQL request's variables (with `graphql`). */
|
|
84
|
+
variables?: unknown;
|
|
62
85
|
}
|
|
63
86
|
export interface RequestStep {
|
|
64
87
|
request: string;
|
|
@@ -67,6 +90,12 @@ export interface RequestStep {
|
|
|
67
90
|
json?: unknown;
|
|
68
91
|
form?: Record<string, unknown>;
|
|
69
92
|
body?: string;
|
|
93
|
+
/** A GraphQL operation sent as the JSON body. */
|
|
94
|
+
graphql?: string | {
|
|
95
|
+
query: string;
|
|
96
|
+
variables?: Record<string, unknown>;
|
|
97
|
+
operationName?: string;
|
|
98
|
+
};
|
|
70
99
|
follow?: boolean;
|
|
71
100
|
/** Send `Authorization: Bearer` with a token from the `auth` issuer: `true`, or the claims, e.g. { sub: u1, roles: [admin] }. */
|
|
72
101
|
auth?: true | Record<string, unknown>;
|
|
@@ -86,6 +115,7 @@ export interface RequestStep {
|
|
|
86
115
|
/** Send the request this many times at once. `expect` applies to every response; `statuses` counts them. */
|
|
87
116
|
concurrency?: number;
|
|
88
117
|
/** `queries`: at most this many SQL statements (not counting BEGIN/COMMIT), with `db.queries` on. */
|
|
118
|
+
/** `schema`: a JSON Schema for the response JSON, inline or `file#/pointer` relative to the scenario file. */
|
|
89
119
|
expect?: {
|
|
90
120
|
status?: number;
|
|
91
121
|
statuses?: Record<string, number>;
|
|
@@ -93,6 +123,7 @@ export interface RequestStep {
|
|
|
93
123
|
json?: unknown;
|
|
94
124
|
text?: unknown;
|
|
95
125
|
queries?: number;
|
|
126
|
+
schema?: string | object;
|
|
96
127
|
};
|
|
97
128
|
capture?: Record<string, string>;
|
|
98
129
|
}
|
|
@@ -113,6 +144,7 @@ export interface SubmitStep {
|
|
|
113
144
|
headers?: Record<string, unknown>;
|
|
114
145
|
json?: unknown;
|
|
115
146
|
text?: unknown;
|
|
147
|
+
schema?: string | object;
|
|
116
148
|
};
|
|
117
149
|
capture?: Record<string, string>;
|
|
118
150
|
}
|
|
@@ -164,6 +196,8 @@ export interface DbStep {
|
|
|
164
196
|
export interface ReceivedStep {
|
|
165
197
|
received: string;
|
|
166
198
|
call?: string;
|
|
199
|
+
/** A GraphQL operation name, instead of `call`. */
|
|
200
|
+
graphql?: string;
|
|
167
201
|
when?: Conditions;
|
|
168
202
|
/** Exact number of matching calls. Default: at least one. */
|
|
169
203
|
times?: number;
|
|
@@ -221,3 +255,4 @@ export interface MailStep {
|
|
|
221
255
|
export declare class YamlScenarioError extends Error {
|
|
222
256
|
}
|
|
223
257
|
export declare function parseScenarioFile(text: string, file: string): YamlFile;
|
|
258
|
+
export {};
|
package/dist/yaml.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { isMap, isSeq, LineCounter, parseDocument } from "yaml";
|
|
2
2
|
const KINDS = {
|
|
3
|
-
stub: ["on", "when", "reply", "sequence", "networkError", "times", "delay"],
|
|
4
|
-
request: ["headers", "query", "json", "form", "body", "follow", "auth", "webhook", "concurrency", "expect", "capture"],
|
|
3
|
+
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
5
|
submit: ["form", "fields", "headers", "follow", "expect", "capture"],
|
|
6
6
|
insert: ["rows", "capture"],
|
|
7
7
|
sql: ["params", "expect", "capture", "within"],
|
|
8
8
|
db: ["where", "orderBy", "expect", "capture", "within"],
|
|
9
|
-
received: ["call", "when", "times", "within"],
|
|
9
|
+
received: ["call", "graphql", "when", "times", "within"],
|
|
10
10
|
changes: ["within"],
|
|
11
11
|
log: ["from", "within"],
|
|
12
12
|
checkpoint: [],
|
|
@@ -14,19 +14,20 @@ const KINDS = {
|
|
|
14
14
|
mail: ["times", "within", "capture"],
|
|
15
15
|
make: ["rows", "count", "capture"],
|
|
16
16
|
chaos: ["failFirst", "errorRate", "statuses", "networkErrorRate", "latency", "seed"],
|
|
17
|
+
use: ["with"],
|
|
17
18
|
};
|
|
18
19
|
const EXPECT_KEYS = {
|
|
19
|
-
request: ["status", "statuses", "headers", "json", "text", "queries"],
|
|
20
|
-
submit: ["status", "headers", "json", "text"],
|
|
20
|
+
request: ["status", "statuses", "headers", "json", "text", "queries", "schema"],
|
|
21
|
+
submit: ["status", "headers", "json", "text", "schema"],
|
|
21
22
|
sql: ["rows", "count"],
|
|
22
23
|
db: ["rows", "count"],
|
|
23
24
|
};
|
|
24
|
-
const CONDITION_KEYS = ["query", "headers", "json", "body"];
|
|
25
|
+
const CONDITION_KEYS = ["query", "headers", "json", "body", "variables"];
|
|
25
26
|
const WEBHOOK_KEYS = ["provider", "secret", "event", "stale", "invalidSignature"];
|
|
26
27
|
const WEBHOOK_PROVIDERS = ["stripe", "github", "slack", "shopify", "standard"];
|
|
27
28
|
const MAIL_KEYS = ["to", "from", "subject", "text", "html"];
|
|
28
29
|
const CHANGE_KEYS = ["inserted", "updated", "deleted"];
|
|
29
|
-
const RESPONSE_KEYS = ["status", "headers", "body", "sse"];
|
|
30
|
+
const RESPONSE_KEYS = ["status", "headers", "body", "sse", "data", "errors"];
|
|
30
31
|
const SCENARIO_KEYS = ["name", "steps", "each", "skip", "only", "timeout"];
|
|
31
32
|
const CALL = /^([A-Za-z]+|\*)\s+(\/\S*)$/;
|
|
32
33
|
/** A request may also go to a captured URL of the app, e.g. a link from a mail: `GET {{link}}`. */
|
|
@@ -48,8 +49,8 @@ export function parseScenarioFile(text, file) {
|
|
|
48
49
|
return fail(root, "expected a mapping with a `scenarios:` list");
|
|
49
50
|
const top = doc.toJS();
|
|
50
51
|
for (const key of Object.keys(top))
|
|
51
|
-
if (
|
|
52
|
-
fail(root, `unknown top-level key "${key}" (expected scenarios, setup)`);
|
|
52
|
+
if (!["scenarios", "setup", "define"].includes(key))
|
|
53
|
+
fail(root, `unknown top-level key "${key}" (expected scenarios, setup, define)`);
|
|
53
54
|
const stepsOf = (seqNode, what) => {
|
|
54
55
|
if (!isSeq(seqNode))
|
|
55
56
|
return fail(seqNode, `${what} must be a list of steps`);
|
|
@@ -59,9 +60,56 @@ export function parseScenarioFile(text, file) {
|
|
|
59
60
|
if (!isSeq(scenariosNode) || scenariosNode.items.length === 0)
|
|
60
61
|
return fail(scenariosNode ?? root, "`scenarios:` must be a non-empty list");
|
|
61
62
|
const setupNode = root.get("setup", true);
|
|
62
|
-
|
|
63
|
+
const define = {};
|
|
64
|
+
const defineNode = root.get("define", true);
|
|
65
|
+
if (defineNode !== undefined) {
|
|
66
|
+
if (!isMap(defineNode))
|
|
67
|
+
return fail(defineNode, "`define:` maps names to step lists, e.g. `define: { login: [ ...steps ] }`");
|
|
68
|
+
for (const pair of defineNode.items) {
|
|
69
|
+
const name = String(pair.key?.value ?? pair.key);
|
|
70
|
+
const value = pair.value;
|
|
71
|
+
if (isSeq(value)) {
|
|
72
|
+
define[name] = { params: [], steps: stepsOf(value, `define "${name}"`) };
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (!isMap(value))
|
|
76
|
+
return fail(value ?? pair.key, `define "${name}" must be a list of steps, or { params, steps }`);
|
|
77
|
+
const raw = value.toJSON();
|
|
78
|
+
for (const k of Object.keys(raw))
|
|
79
|
+
if (k !== "params" && k !== "steps")
|
|
80
|
+
fail(value, `unknown key "${k}" in define "${name}" (allowed: params, steps)`);
|
|
81
|
+
if (raw.params !== undefined && !(Array.isArray(raw.params) && raw.params.every((p) => typeof p === "string" && p)))
|
|
82
|
+
fail(value.get("params", true) ?? value, "`params` must be a list of names, e.g. [email, password]");
|
|
83
|
+
define[name] = { params: raw.params ?? [], steps: stepsOf(value.get("steps", true), `define "${name}": steps`) };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// `use:` must name a definition and give it exactly its params; definitions can't use themselves.
|
|
87
|
+
const checkUses = (steps, chain) => {
|
|
88
|
+
for (const step of steps) {
|
|
89
|
+
if (!("use" in step))
|
|
90
|
+
continue;
|
|
91
|
+
const def = define[step.use];
|
|
92
|
+
const at = `${file}:${step.line}`;
|
|
93
|
+
if (!def)
|
|
94
|
+
throw new YamlScenarioError(`${at}: no definition "${step.use}" (defined: ${Object.keys(define).join(", ") || "none; add a top-level \`define:\`"})`);
|
|
95
|
+
if (chain.includes(step.use))
|
|
96
|
+
throw new YamlScenarioError(`${at}: "${step.use}" uses itself (${[...chain, step.use].join(" → ")})`);
|
|
97
|
+
const given = Object.keys(step.with ?? {});
|
|
98
|
+
const missing = def.params.filter((p) => !given.includes(p));
|
|
99
|
+
const extra = given.filter((g) => !def.params.includes(g));
|
|
100
|
+
if (missing.length)
|
|
101
|
+
throw new YamlScenarioError(`${at}: use ${step.use} needs \`with: { ${missing.join(", ")} }\``);
|
|
102
|
+
if (extra.length)
|
|
103
|
+
throw new YamlScenarioError(`${at}: "${extra.join('", "')}" ${extra.length > 1 ? "aren't params" : "isn't a param"} of ${step.use} (params: ${def.params.join(", ") || "none"})`);
|
|
104
|
+
checkUses(def.steps, [...chain, step.use]);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
for (const [name, def] of Object.entries(define))
|
|
108
|
+
checkUses(def.steps, [name]);
|
|
109
|
+
const parsed = {
|
|
63
110
|
file,
|
|
64
111
|
setup: setupNode ? stepsOf(setupNode, "setup") : [],
|
|
112
|
+
define,
|
|
65
113
|
scenarios: scenariosNode.items.map((node) => {
|
|
66
114
|
if (!isMap(node))
|
|
67
115
|
return fail(node, "each scenario must be a mapping with `name` and `steps`");
|
|
@@ -87,6 +135,10 @@ export function parseScenarioFile(text, file) {
|
|
|
87
135
|
};
|
|
88
136
|
}),
|
|
89
137
|
};
|
|
138
|
+
checkUses(parsed.setup, []);
|
|
139
|
+
for (const sc of parsed.scenarios)
|
|
140
|
+
checkUses(sc.steps, []);
|
|
141
|
+
return parsed;
|
|
90
142
|
}
|
|
91
143
|
function parseStep(node, fail, lineOf) {
|
|
92
144
|
if (!isMap(node))
|
|
@@ -142,6 +194,12 @@ function parseStep(node, fail, lineOf) {
|
|
|
142
194
|
}
|
|
143
195
|
}
|
|
144
196
|
}
|
|
197
|
+
else if (kind === "use") {
|
|
198
|
+
if (typeof raw.use !== "string" || !raw.use)
|
|
199
|
+
fail(at(kind), "`use:` names a step list from `define:`");
|
|
200
|
+
if (raw.with !== undefined && (!raw.with || typeof raw.with !== "object" || Array.isArray(raw.with)))
|
|
201
|
+
fail(at("with"), "`with` maps params to values, e.g. { email: a@b.test }");
|
|
202
|
+
}
|
|
145
203
|
else if (kind === "checkpoint") {
|
|
146
204
|
if (raw.checkpoint !== true)
|
|
147
205
|
fail(at(kind), "use `checkpoint: true`");
|
|
@@ -183,11 +241,21 @@ function parseStep(node, fail, lineOf) {
|
|
|
183
241
|
}
|
|
184
242
|
if (kind in EXPECT_KEYS)
|
|
185
243
|
keysOf("expect", EXPECT_KEYS[kind]);
|
|
244
|
+
{
|
|
245
|
+
const schema = raw.expect?.schema;
|
|
246
|
+
if (schema !== undefined && !((typeof schema === "string" && schema) || (schema && typeof schema === "object" && !Array.isArray(schema)))) {
|
|
247
|
+
fail(at("expect"), "`expect.schema` is a JSON Schema, or a file such as openapi.yaml#/components/schemas/Poll");
|
|
248
|
+
}
|
|
249
|
+
}
|
|
186
250
|
keysOf("when", CONDITION_KEYS);
|
|
251
|
+
if (raw.graphql !== undefined && kind !== "request" && (typeof raw.graphql !== "string" || !raw.graphql))
|
|
252
|
+
fail(at("graphql"), "`graphql` is the operation's name, e.g. `graphql: GetUser`");
|
|
253
|
+
if (raw.when?.variables !== undefined && raw.graphql === undefined)
|
|
254
|
+
fail(at("when"), "`when.variables` needs `graphql: <operation>`");
|
|
187
255
|
switch (kind) {
|
|
188
256
|
case "stub": {
|
|
189
|
-
if (raw.on === undefined)
|
|
190
|
-
fail(node, "a stub step needs `on
|
|
257
|
+
if ((raw.on === undefined) === (raw.graphql === undefined))
|
|
258
|
+
fail(node, "a stub step needs one of `on` (e.g. `on: POST /hook`) or `graphql` (e.g. `graphql: GetUser`)");
|
|
191
259
|
call("on");
|
|
192
260
|
const answers = ["reply", "sequence", "networkError"].filter((k) => raw[k] !== undefined);
|
|
193
261
|
if (answers.length !== 1)
|
|
@@ -196,7 +264,16 @@ function parseStep(node, fail, lineOf) {
|
|
|
196
264
|
for (const [where, r] of [["reply", raw.reply], ...(Array.isArray(raw.sequence) ? raw.sequence : []).map((r) => ["sequence", r])]) {
|
|
197
265
|
if (!r || typeof r !== "object")
|
|
198
266
|
continue;
|
|
199
|
-
const { body, sse } = r;
|
|
267
|
+
const { body, sse, data, errors } = r;
|
|
268
|
+
if (data !== undefined || errors !== undefined) {
|
|
269
|
+
if (raw.graphql === undefined)
|
|
270
|
+
fail(at(where), "`data` and `errors` answer a GraphQL operation: use them with `graphql: <operation>`, or send `body`");
|
|
271
|
+
if (body !== undefined || sse !== undefined)
|
|
272
|
+
fail(at(where), "a GraphQL reply has `data` / `errors`, not `body` or `sse`");
|
|
273
|
+
if (errors !== undefined && !(Array.isArray(errors) && errors.every((e) => typeof e === "string" || (e && typeof e === "object" && typeof e.message === "string")))) {
|
|
274
|
+
fail(at(where), "`errors` must be a list of messages or { message, path, extensions }");
|
|
275
|
+
}
|
|
276
|
+
}
|
|
200
277
|
if (sse === undefined)
|
|
201
278
|
continue;
|
|
202
279
|
if (body !== undefined)
|
|
@@ -209,12 +286,20 @@ function parseStep(node, fail, lineOf) {
|
|
|
209
286
|
fail(at("sequence"), "`sequence` must be a non-empty list of responses");
|
|
210
287
|
number("times");
|
|
211
288
|
number("delay");
|
|
289
|
+
if (raw.optional !== undefined && typeof raw.optional !== "boolean")
|
|
290
|
+
fail(at("optional"), "`optional` must be true or false");
|
|
212
291
|
break;
|
|
213
292
|
}
|
|
214
293
|
case "request":
|
|
215
294
|
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");
|
|
295
|
+
if (["json", "form", "body", "graphql"].filter((k) => raw[k] !== undefined).length > 1)
|
|
296
|
+
fail(node, "use only one of json / form / body / graphql");
|
|
297
|
+
if (raw.graphql !== undefined) {
|
|
298
|
+
const g = raw.graphql;
|
|
299
|
+
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)));
|
|
300
|
+
if (!ok)
|
|
301
|
+
fail(at("graphql"), "`graphql` is the query, or { query, variables, operationName }");
|
|
302
|
+
}
|
|
218
303
|
if (raw.webhook !== undefined) {
|
|
219
304
|
const w = raw.webhook;
|
|
220
305
|
if (!w || typeof w !== "object" || Array.isArray(w))
|
|
@@ -296,6 +381,8 @@ function parseStep(node, fail, lineOf) {
|
|
|
296
381
|
break;
|
|
297
382
|
case "received":
|
|
298
383
|
call("call");
|
|
384
|
+
if (raw.call !== undefined && raw.graphql !== undefined)
|
|
385
|
+
fail(node, "a received step takes `call` or `graphql`, not both");
|
|
299
386
|
number("times");
|
|
300
387
|
break;
|
|
301
388
|
case "mail":
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "slicetest",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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",
|