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.
Files changed (51) hide show
  1. package/README.md +177 -24
  2. package/dist/cli.js +66 -2
  3. package/dist/config.d.ts +18 -0
  4. package/dist/config.js +13 -0
  5. package/dist/connection.d.ts +5 -0
  6. package/dist/connection.js +31 -0
  7. package/dist/db.d.ts +12 -1
  8. package/dist/db.js +33 -6
  9. package/dist/diagram.d.ts +13 -0
  10. package/dist/diagram.js +89 -0
  11. package/dist/form.d.ts +7 -2
  12. package/dist/form.js +72 -7
  13. package/dist/global-setup.d.ts +5 -0
  14. package/dist/global-setup.js +74 -9
  15. package/dist/graphql.d.ts +34 -0
  16. package/dist/graphql.js +56 -0
  17. package/dist/har.d.ts +49 -0
  18. package/dist/har.js +107 -0
  19. package/dist/http.d.ts +21 -3
  20. package/dist/http.js +47 -10
  21. package/dist/index.d.ts +5 -3
  22. package/dist/init.js +184 -15
  23. package/dist/list.d.ts +26 -0
  24. package/dist/list.js +71 -0
  25. package/dist/matchers.d.ts +16 -0
  26. package/dist/matchers.js +81 -1
  27. package/dist/openapi.d.ts +21 -0
  28. package/dist/openapi.js +32 -1
  29. package/dist/provided.d.ts +1 -0
  30. package/dist/record.d.ts +1 -1
  31. package/dist/record.js +3 -1
  32. package/dist/recording.d.ts +1 -1
  33. package/dist/recording.js +2 -2
  34. package/dist/runtime.d.ts +12 -5
  35. package/dist/runtime.js +78 -22
  36. package/dist/scenario.d.ts +14 -4
  37. package/dist/scenario.js +36 -17
  38. package/dist/schema.d.ts +5 -0
  39. package/dist/schema.js +73 -0
  40. package/dist/stub.d.ts +63 -0
  41. package/dist/stub.js +292 -5
  42. package/dist/timeline.d.ts +9 -0
  43. package/dist/timeline.js +6 -0
  44. package/dist/webhook.d.ts +16 -7
  45. package/dist/webhook.js +47 -7
  46. package/dist/yaml-runtime.d.ts +3 -2
  47. package/dist/yaml-runtime.js +308 -51
  48. package/dist/yaml.d.ts +82 -6
  49. package/dist/yaml.js +187 -21
  50. package/package.json +2 -1
  51. 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
- on: string;
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
- fields?: Record<string, string | number | boolean | (string | number)[]>;
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 = ["stripe", "github", "slack", "shopify", "standard"];
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 (key !== "scenarios" && key !== "setup")
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
- return {
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
- if (!(scalar(v) || typeof v === "boolean" || (Array.isArray(v) && v.every(scalar))))
141
- fail(at("fields"), `field "${k}" must be a string, number, true / false or a list`);
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`, e.g. `on: POST /hook`");
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.6.1",
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",