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.
@@ -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}`, message }).catch(() => { });
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 [method, path] = splitCall(step.on);
149
- let route = ctx.stub(step.stub).on(method, interpolate(path, vars), conditions(step.when, vars));
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
- // Replies are interpolated when a call arrives, so they can echo it: {{call.params.id}}, {{call.json.name}}.
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
- let body = step.json !== undefined
195
- ? interpolate(step.json, vars)
196
- : step.form !== undefined
197
- ? ctx.http.form(interpolate(step.form, vars))
198
- : interpolate(step.body, vars);
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) => verifyResponse(res, e, vars);
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
- on: string;
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 (key !== "scenarios" && key !== "setup")
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
- return {
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`, e.g. `on: POST /hook`");
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.6.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",