slicetest 0.5.0 → 0.6.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/dist/x509.js ADDED
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Just enough X.509 to impersonate hosts in tests: a throwaway certificate
3
+ * authority, leaf certificates for the intercepted hosts, and the CA in the
4
+ * formats apps read it from (PEM bundle, and a PKCS#12 trust store for Java).
5
+ * DER is written by hand so the package needs no crypto dependency.
6
+ */
7
+ import { createHash, createPublicKey, generateKeyPairSync, randomBytes, sign, X509Certificate } from "node:crypto";
8
+ // --- DER ---
9
+ function len(n) {
10
+ if (n < 0x80)
11
+ return Buffer.from([n]);
12
+ const bytes = [];
13
+ for (let v = n; v > 0; v >>= 8)
14
+ bytes.unshift(v & 0xff);
15
+ return Buffer.from([0x80 | bytes.length, ...bytes]);
16
+ }
17
+ const tlv = (tag, ...content) => {
18
+ const body = Buffer.concat(content);
19
+ return Buffer.concat([Buffer.from([tag]), len(body.length), body]);
20
+ };
21
+ export const seq = (...items) => tlv(0x30, ...items);
22
+ const set = (...items) => tlv(0x31, ...items);
23
+ const octets = (b) => tlv(0x04, b);
24
+ const bits = (b) => tlv(0x03, Buffer.from([0]), b);
25
+ const utf8 = (s) => tlv(0x0c, Buffer.from(s, "utf8"));
26
+ const bmp = (s) => tlv(0x1e, Buffer.from(s, "utf16le").swap16());
27
+ const bool = (v) => tlv(0x01, Buffer.from([v ? 0xff : 0]));
28
+ const explicit = (n, ...content) => tlv(0xa0 + n, ...content);
29
+ const int = (b) => {
30
+ let i = 0;
31
+ while (i < b.length - 1 && b[i] === 0)
32
+ i++;
33
+ const v = b.subarray(i);
34
+ return tlv(0x02, v[0] & 0x80 ? Buffer.concat([Buffer.from([0]), v]) : v);
35
+ };
36
+ const oid = (dotted) => {
37
+ const [a, b, ...rest] = dotted.split(".").map(Number);
38
+ const out = [a * 40 + b];
39
+ for (const n of rest) {
40
+ const chunk = [];
41
+ let v = n;
42
+ do {
43
+ chunk.unshift(v & 0x7f);
44
+ v = Math.floor(v / 128);
45
+ } while (v > 0);
46
+ for (let i = 0; i < chunk.length - 1; i++)
47
+ chunk[i] |= 0x80;
48
+ out.push(...chunk);
49
+ }
50
+ return tlv(0x06, Buffer.from(out));
51
+ };
52
+ const time = (d) => {
53
+ // UTCTime up to 2049, GeneralizedTime after, as RFC 5280 requires.
54
+ const iso = d.toISOString().replace(/[-:T]/g, "").slice(0, 14);
55
+ return d.getUTCFullYear() < 2050 ? tlv(0x17, Buffer.from(`${iso.slice(2)}Z`)) : tlv(0x18, Buffer.from(`${iso}Z`));
56
+ };
57
+ const OID = {
58
+ ecdsaSha256: "1.2.840.10045.4.3.2",
59
+ commonName: "2.5.4.3",
60
+ organization: "2.5.4.10",
61
+ basicConstraints: "2.5.29.19",
62
+ keyUsage: "2.5.29.15",
63
+ extKeyUsage: "2.5.29.37",
64
+ serverAuth: "1.3.6.1.5.5.7.3.1",
65
+ subjectAltName: "2.5.29.17",
66
+ subjectKeyId: "2.5.29.14",
67
+ authorityKeyId: "2.5.29.35",
68
+ data: "1.2.840.113549.1.7.1",
69
+ certBag: "1.2.840.113549.1.12.10.1.3",
70
+ x509Certificate: "1.2.840.113549.1.9.22.1",
71
+ friendlyName: "1.2.840.113549.1.9.20",
72
+ // Java only treats a PKCS#12 certificate as trusted when it carries this attribute.
73
+ oracleTrustedKeyUsage: "2.16.840.1.113894.746875.1.1",
74
+ anyExtendedKeyUsage: "2.5.29.37.0",
75
+ };
76
+ const name = (cn) => seq(set(seq(oid(OID.organization), utf8("slicetest"))), set(seq(oid(OID.commonName), utf8(cn))));
77
+ const extension = (id, critical, value) => seq(oid(id), ...(critical ? [bool(true)] : []), octets(value));
78
+ const keyId = (key) => createHash("sha1").update(createPublicKey(key).export({ type: "spki", format: "der" })).digest();
79
+ function certificate(opts) {
80
+ const now = Date.now();
81
+ const ski = keyId(opts.publicKey);
82
+ const extensions = [
83
+ extension(OID.basicConstraints, true, seq(...(opts.ca ? [bool(true)] : []))),
84
+ // CA: keyCertSign + cRLSign. Leaf: digitalSignature.
85
+ extension(OID.keyUsage, true, opts.ca ? tlv(0x03, Buffer.from([1, 0x06])) : tlv(0x03, Buffer.from([7, 0x80]))),
86
+ extension(OID.subjectKeyId, false, octets(ski)),
87
+ ...(opts.ca
88
+ ? []
89
+ : [
90
+ extension(OID.authorityKeyId, false, seq(tlv(0x80, opts.issuerKeyId))),
91
+ extension(OID.extKeyUsage, false, seq(oid(OID.serverAuth))),
92
+ extension(OID.subjectAltName, false, seq(...opts.hosts.map((h) => tlv(0x82, Buffer.from(h, "ascii"))))),
93
+ ]),
94
+ ];
95
+ const algorithm = seq(oid(OID.ecdsaSha256));
96
+ const tbs = seq(explicit(0, int(Buffer.from([2]))), // v3
97
+ int(randomBytes(16)), algorithm, name(opts.issuer), seq(time(new Date(now - 60 * 60_000)), time(new Date(now + (opts.ca ? 7 : 2) * 24 * 60 * 60_000))), name(opts.subject), createPublicKey(opts.publicKey).export({ type: "spki", format: "der" }), explicit(3, seq(...extensions)));
98
+ return seq(tbs, algorithm, bits(sign("sha256", tbs, opts.signer)));
99
+ }
100
+ const toPem = (der) => `-----BEGIN CERTIFICATE-----\n${der.toString("base64").replace(/.{64}/g, "$&\n").replace(/\n?$/, "\n")}-----END CERTIFICATE-----\n`;
101
+ /** A certificate authority that exists for one test run. */
102
+ export class Authority {
103
+ key;
104
+ der;
105
+ pem;
106
+ #keyId;
107
+ #leaves = new Map();
108
+ constructor() {
109
+ const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
110
+ this.key = privateKey;
111
+ this.#keyId = keyId(privateKey);
112
+ this.der = certificate({ subject: "slicetest test CA", publicKey: privateKey, issuer: "slicetest test CA", signer: privateKey, issuerKeyId: this.#keyId, ca: true });
113
+ this.pem = toPem(this.der);
114
+ }
115
+ /** A certificate for `host`, signed by this authority (cached). */
116
+ leaf(host) {
117
+ let issued = this.#leaves.get(host);
118
+ if (!issued) {
119
+ const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
120
+ const der = certificate({ subject: host, publicKey: privateKey, issuer: "slicetest test CA", signer: this.key, issuerKeyId: this.#keyId, ca: false, hosts: [host] });
121
+ issued = { key: privateKey, der, pem: toPem(der) };
122
+ this.#leaves.set(host, issued);
123
+ }
124
+ return issued;
125
+ }
126
+ }
127
+ /** PEM bundle of `extra` plus the usual public roots, for SSL_CERT_FILE and friends (which replace the default store). */
128
+ export function pemBundle(roots, extra) {
129
+ return `${extra}${roots.map((r) => (r.endsWith("\n") ? r : `${r}\n`)).join("")}`;
130
+ }
131
+ /**
132
+ * A PKCS#12 trust store of certificates, readable by Java without a password
133
+ * (`-Djavax.net.ssl.trustStoreType=PKCS12`, no `trustStorePassword`): no MAC and
134
+ * unencrypted certificate bags, each marked as a trusted certificate entry.
135
+ */
136
+ export function pkcs12TrustStore(certs) {
137
+ const bags = certs.map(({ alias, der }) => seq(oid(OID.certBag), explicit(0, seq(oid(OID.x509Certificate), explicit(0, octets(der)))), set(seq(oid(OID.friendlyName), set(bmp(alias))), seq(oid(OID.oracleTrustedKeyUsage), set(oid(OID.anyExtendedKeyUsage))))));
138
+ const safeContents = seq(...bags);
139
+ const authSafe = seq(seq(oid(OID.data), explicit(0, octets(safeContents))));
140
+ return seq(int(Buffer.from([3])), seq(oid(OID.data), explicit(0, octets(authSafe))));
141
+ }
142
+ /** DER of a PEM certificate, or undefined when it doesn't parse. */
143
+ export function pemToDer(pem) {
144
+ try {
145
+ return Buffer.from(new X509Certificate(pem).raw);
146
+ }
147
+ catch {
148
+ return undefined;
149
+ }
150
+ }
@@ -5,6 +5,7 @@ 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 { sse } from "./stub.js";
8
9
  /** Registers the scenarios of one parsed YAML file. Called from the module the plugin generates for it. */
9
10
  export function defineYamlScenarios(doc) {
10
11
  for (const sc of doc.scenarios) {
@@ -40,6 +41,8 @@ async function runSteps(doc, sc, steps, ctx, vars) {
40
41
  }
41
42
  }
42
43
  function describeStep(step) {
44
+ if ("submit" in step)
45
+ return step.submit === true ? "submit" : `submit ${JSON.stringify(step.submit)}`;
43
46
  if ("request" in step)
44
47
  return step.concurrency ? `${step.request} ×${step.concurrency}` : step.request;
45
48
  if ("stub" in step)
@@ -149,7 +152,10 @@ async function runStep(step, ctx, vars) {
149
152
  if (step.delay !== undefined)
150
153
  route = route.delay(step.delay);
151
154
  // Replies are interpolated when a call arrives, so they can echo it: {{call.params.id}}, {{call.json.name}}.
152
- const answer = (template) => (call) => interpolate(template, { ...vars, call: callVars(call) });
155
+ const answer = (template) => (call) => {
156
+ const { sse: events, ...response } = interpolate(template, { ...vars, call: callVars(call) });
157
+ return events ? sse(events, response) : response;
158
+ };
153
159
  if (step.networkError)
154
160
  route.networkError();
155
161
  else if (step.sequence) {
@@ -161,6 +167,21 @@ async function runStep(step, ctx, vars) {
161
167
  route.reply(answer(step.reply ?? {}));
162
168
  return;
163
169
  }
170
+ if ("submit" in step) {
171
+ const page = ctx.http.history.at(-1);
172
+ if (!page)
173
+ throw new Error("submit needs a page with a form: request it first, e.g. `- request: GET /signup`");
174
+ const res = await ctx.http.submit(page, {
175
+ button: step.submit === true ? undefined : interpolate(step.submit, vars),
176
+ form: interpolate(step.form, vars),
177
+ fields: interpolate(step.fields, vars),
178
+ headers: interpolate(step.headers, vars),
179
+ follow: step.follow,
180
+ });
181
+ verifyResponse(res, step.expect, vars);
182
+ capture(step.capture, { status: res.status, json: res.json, text: res.text, headers: Object.fromEntries(res.headers) }, vars);
183
+ return;
184
+ }
164
185
  if ("request" in step) {
165
186
  const [method, rawPath] = splitCall(step.request);
166
187
  const path = interpolate(rawPath, vars);
@@ -181,18 +202,7 @@ async function runStep(step, ctx, vars) {
181
202
  opts.headers = { "content-type": signed.type, ...signWebhook(signed.body, interpolate(step.webhook, vars)), ...opts.headers };
182
203
  }
183
204
  const e = step.expect;
184
- const verify = (res) => {
185
- if (e?.status !== undefined)
186
- expect(res).toHaveStatus(interpolate(e.status, vars));
187
- if (e?.headers !== undefined) {
188
- const expected = Object.fromEntries(Object.entries(e.headers).map(([k, v]) => [k.toLowerCase(), v]));
189
- check(Object.fromEntries(res.headers), expected, vars, "response headers");
190
- }
191
- if (e?.json !== undefined)
192
- check(res.json, e.json, vars, "response JSON");
193
- if (e?.text !== undefined)
194
- check(res.text, e.text, vars, "response text");
195
- };
205
+ const verify = (res) => verifyResponse(res, e, vars);
196
206
  if (step.concurrency !== undefined) {
197
207
  // Each request gets its own body: a URLSearchParams body can only be read once.
198
208
  const all = await ctx.http.concurrently(step.concurrency, () => ctx.http.request(method, path, body instanceof URLSearchParams ? new URLSearchParams(body) : body, opts));
@@ -396,3 +406,15 @@ function interpolateTitle(name, row, index) {
396
406
  return v === undefined ? m : typeof v === "object" ? JSON.stringify(v) : String(v);
397
407
  });
398
408
  }
409
+ function verifyResponse(res, e, vars) {
410
+ if (e?.status !== undefined)
411
+ expect(res).toHaveStatus(interpolate(e.status, vars));
412
+ if (e?.headers !== undefined) {
413
+ const expected = Object.fromEntries(Object.entries(e.headers).map(([k, v]) => [k.toLowerCase(), v]));
414
+ check(Object.fromEntries(res.headers), expected, vars, "response headers");
415
+ }
416
+ if (e?.json !== undefined)
417
+ check(res.json, e.json, vars, "response JSON");
418
+ if (e?.text !== undefined)
419
+ check(res.text, e.text, vars, "response text");
420
+ }
package/dist/yaml.d.ts CHANGED
@@ -21,7 +21,7 @@ export interface YamlScenario {
21
21
  timeout?: number;
22
22
  steps: Step[];
23
23
  }
24
- export type Step = (StubStep | RequestStep | InsertStep | MakeStep | ChaosStep | SqlStep | DbStep | ReceivedStep | ChangesStep | CheckpointStep | LogStep | SnapshotStep | MailStep) & {
24
+ export type Step = (StubStep | RequestStep | SubmitStep | InsertStep | MakeStep | ChaosStep | SqlStep | DbStep | ReceivedStep | ChangesStep | CheckpointStep | LogStep | SnapshotStep | MailStep) & {
25
25
  line: number;
26
26
  name?: string;
27
27
  };
@@ -29,15 +29,26 @@ export interface StubStep {
29
29
  stub: string;
30
30
  on: string;
31
31
  when?: Conditions;
32
+ /** `sse`: a streamed reply, a list of { event, data, id } (instead of `body`). */
32
33
  reply?: {
33
34
  status?: number;
34
35
  headers?: Record<string, string>;
35
36
  body?: unknown;
37
+ sse?: {
38
+ event?: string;
39
+ data: unknown;
40
+ id?: string;
41
+ }[];
36
42
  };
37
43
  sequence?: {
38
44
  status?: number;
39
45
  headers?: Record<string, string>;
40
46
  body?: unknown;
47
+ sse?: {
48
+ event?: string;
49
+ data: unknown;
50
+ id?: string;
51
+ }[];
41
52
  }[];
42
53
  networkError?: boolean;
43
54
  times?: number;
@@ -85,6 +96,26 @@ export interface RequestStep {
85
96
  };
86
97
  capture?: Record<string, string>;
87
98
  }
99
+ /**
100
+ * Submit a form of the page the last request returned, as a browser with JavaScript off would.
101
+ * `submit:` names the button to press (its text, value, name or id), or is `true` for the form's only button.
102
+ */
103
+ export interface SubmitStep {
104
+ submit: string | true;
105
+ /** The form by id, name or 0-based position, when the page has several. */
106
+ form?: string | number;
107
+ /** 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)[]>;
109
+ headers?: Record<string, string>;
110
+ follow?: boolean;
111
+ expect?: {
112
+ status?: number;
113
+ headers?: Record<string, unknown>;
114
+ json?: unknown;
115
+ text?: unknown;
116
+ };
117
+ capture?: Record<string, string>;
118
+ }
88
119
  export interface InsertStep {
89
120
  insert: string;
90
121
  rows: Record<string, unknown> | Record<string, unknown>[];
package/dist/yaml.js CHANGED
@@ -2,6 +2,7 @@ import { isMap, isSeq, LineCounter, parseDocument } from "yaml";
2
2
  const KINDS = {
3
3
  stub: ["on", "when", "reply", "sequence", "networkError", "times", "delay"],
4
4
  request: ["headers", "query", "json", "form", "body", "follow", "auth", "webhook", "concurrency", "expect", "capture"],
5
+ submit: ["form", "fields", "headers", "follow", "expect", "capture"],
5
6
  insert: ["rows", "capture"],
6
7
  sql: ["params", "expect", "capture", "within"],
7
8
  db: ["where", "orderBy", "expect", "capture", "within"],
@@ -16,6 +17,7 @@ const KINDS = {
16
17
  };
17
18
  const EXPECT_KEYS = {
18
19
  request: ["status", "statuses", "headers", "json", "text", "queries"],
20
+ submit: ["status", "headers", "json", "text"],
19
21
  sql: ["rows", "count"],
20
22
  db: ["rows", "count"],
21
23
  };
@@ -24,7 +26,7 @@ const WEBHOOK_KEYS = ["provider", "secret", "event", "stale", "invalidSignature"
24
26
  const WEBHOOK_PROVIDERS = ["stripe", "github", "slack", "shopify", "standard"];
25
27
  const MAIL_KEYS = ["to", "from", "subject", "text", "html"];
26
28
  const CHANGE_KEYS = ["inserted", "updated", "deleted"];
27
- const RESPONSE_KEYS = ["status", "headers", "body"];
29
+ const RESPONSE_KEYS = ["status", "headers", "body", "sse"];
28
30
  const SCENARIO_KEYS = ["name", "steps", "each", "skip", "only", "timeout"];
29
31
  const CALL = /^([A-Za-z]+|\*)\s+(\/\S*)$/;
30
32
  /** A request may also go to a captured URL of the app, e.g. a link from a mail: `GET {{link}}`. */
@@ -124,6 +126,22 @@ function parseStep(node, fail, lineOf) {
124
126
  if (!MAIL_KEYS.includes(k))
125
127
  fail(at(kind), `unknown key "${k}" in mail (allowed: ${MAIL_KEYS.join(", ")})`);
126
128
  }
129
+ else if (kind === "submit") {
130
+ if (raw.submit !== true && (typeof raw.submit !== "string" || !raw.submit.trim()))
131
+ fail(at(kind), "`submit:` names the button to press (its text), or is `true` for the form's only button");
132
+ if (raw.form !== undefined && !(typeof raw.form === "string" || (Number.isInteger(raw.form) && raw.form >= 0)))
133
+ fail(at("form"), "`form` is the form's id or name, or its position on the page (0 for the first)");
134
+ const fields = raw.fields;
135
+ if (fields !== undefined) {
136
+ if (!fields || typeof fields !== "object" || Array.isArray(fields))
137
+ fail(at("fields"), "`fields` must map field names to values, e.g. { email: a@b.test }");
138
+ for (const [k, v] of Object.entries(fields)) {
139
+ 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`);
142
+ }
143
+ }
144
+ }
127
145
  else if (kind === "checkpoint") {
128
146
  if (raw.checkpoint !== true)
129
147
  fail(at(kind), "use `checkpoint: true`");
@@ -175,6 +193,18 @@ function parseStep(node, fail, lineOf) {
175
193
  if (answers.length !== 1)
176
194
  fail(node, "a stub step needs exactly one of reply / sequence / networkError");
177
195
  keysOf("reply", RESPONSE_KEYS);
196
+ for (const [where, r] of [["reply", raw.reply], ...(Array.isArray(raw.sequence) ? raw.sequence : []).map((r) => ["sequence", r])]) {
197
+ if (!r || typeof r !== "object")
198
+ continue;
199
+ const { body, sse } = r;
200
+ if (sse === undefined)
201
+ continue;
202
+ if (body !== undefined)
203
+ fail(at(where), "a reply has either `body` or `sse`, not both");
204
+ if (!Array.isArray(sse) || sse.some((e) => !e || typeof e !== "object" || Array.isArray(e) || !("data" in e) || Object.keys(e).some((k) => !["event", "data", "id"].includes(k)))) {
205
+ fail(at(where), "`sse` must be a list of events such as { event: message_start, data: { ... } }");
206
+ }
207
+ }
178
208
  if (raw.sequence !== undefined && (!Array.isArray(raw.sequence) || raw.sequence.length === 0))
179
209
  fail(at("sequence"), "`sequence` must be a non-empty list of responses");
180
210
  number("times");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slicetest",
3
- "version": "0.5.0",
3
+ "version": "0.6.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",
@@ -53,6 +53,7 @@
53
53
  },
54
54
  "files": [
55
55
  "dist",
56
+ "preload",
56
57
  "schema"
57
58
  ],
58
59
  "engines": {
@@ -66,9 +67,9 @@
66
67
  "prepublishOnly": "npm run typecheck && npm run build"
67
68
  },
68
69
  "peerDependencies": {
69
- "vitest": ">=5",
70
+ "@testcontainers/mysql": ">=11",
70
71
  "mysql2": ">=3",
71
- "@testcontainers/mysql": ">=11"
72
+ "vitest": ">=4"
72
73
  },
73
74
  "dependencies": {
74
75
  "@testcontainers/postgresql": "^12.2.0",
@@ -79,6 +80,7 @@
79
80
  "yaml": "^2.9.1"
80
81
  },
81
82
  "devDependencies": {
83
+ "@neondatabase/serverless": "^1.1.0",
82
84
  "@testcontainers/mysql": "^12.2.0",
83
85
  "@types/node": "^26.6.3",
84
86
  "@types/pg": "^8.23.1",
@@ -0,0 +1,19 @@
1
+ // Loaded into Node apps (NODE_OPTIONS=--require) while slicetest intercepts their outbound calls.
2
+ // NODE_USE_ENV_PROXY covers fetch and the default agents; libraries that make their own
3
+ // http(s).Agent (the Stripe SDK, many API clients) would otherwise go straight to the real
4
+ // host. Agents made from here on get the proxy settings too, unless they set their own.
5
+ "use strict";
6
+ const http = require("node:http");
7
+ const https = require("node:https");
8
+ const { syncBuiltinESMExports } = require("node:module");
9
+
10
+ for (const mod of [http, https]) {
11
+ const Original = mod.Agent;
12
+ class Agent extends Original {
13
+ constructor(options = {}) {
14
+ super({ proxyEnv: process.env, ...options });
15
+ }
16
+ }
17
+ mod.Agent = Agent;
18
+ }
19
+ syncBuiltinESMExports();
@@ -111,7 +111,33 @@
111
111
  },
112
112
  "body": {
113
113
  "description": "String, or anything else to send as JSON. May use {{call.params.x}}, {{call.json.x}}, {{call.query.x}}"
114
+ },
115
+ "sse": {
116
+ "type": "array",
117
+ "description": "A streamed reply (Server-Sent Events), instead of body: a list of { event, data, id }. Data that isn't a string is sent as JSON",
118
+ "items": {
119
+ "type": "object",
120
+ "additionalProperties": false,
121
+ "required": [
122
+ "data"
123
+ ],
124
+ "properties": {
125
+ "event": {
126
+ "type": "string"
127
+ },
128
+ "data": {},
129
+ "id": {
130
+ "type": "string"
131
+ }
132
+ }
133
+ }
114
134
  }
135
+ },
136
+ "not": {
137
+ "required": [
138
+ "body",
139
+ "sse"
140
+ ]
115
141
  }
116
142
  },
117
143
  "sequence": {
@@ -131,7 +157,33 @@
131
157
  },
132
158
  "body": {
133
159
  "description": "String, or anything else to send as JSON. May use {{call.params.x}}, {{call.json.x}}, {{call.query.x}}"
160
+ },
161
+ "sse": {
162
+ "type": "array",
163
+ "description": "A streamed reply (Server-Sent Events), instead of body: a list of { event, data, id }. Data that isn't a string is sent as JSON",
164
+ "items": {
165
+ "type": "object",
166
+ "additionalProperties": false,
167
+ "required": [
168
+ "data"
169
+ ],
170
+ "properties": {
171
+ "event": {
172
+ "type": "string"
173
+ },
174
+ "data": {},
175
+ "id": {
176
+ "type": "string"
177
+ }
178
+ }
179
+ }
134
180
  }
181
+ },
182
+ "not": {
183
+ "required": [
184
+ "body",
185
+ "sse"
186
+ ]
135
187
  }
136
188
  },
137
189
  "minItems": 1
@@ -304,6 +356,94 @@
304
356
  }
305
357
  }
306
358
  },
359
+ {
360
+ "type": "object",
361
+ "required": [
362
+ "submit"
363
+ ],
364
+ "additionalProperties": false,
365
+ "properties": {
366
+ "submit": {
367
+ "oneOf": [
368
+ {
369
+ "type": "string",
370
+ "minLength": 1
371
+ },
372
+ {
373
+ "const": true
374
+ }
375
+ ],
376
+ "description": "Submit a form of the page the last request returned, as a browser with JavaScript off would (hidden fields such as CSRF tokens and Next.js server action ids included). Names the button to press by its text, value, name or id; `true` for the form's only button"
377
+ },
378
+ "name": {
379
+ "type": "string",
380
+ "description": "Label shown when this step fails"
381
+ },
382
+ "form": {
383
+ "type": [
384
+ "string",
385
+ "integer"
386
+ ],
387
+ "description": "The form by id, name or 0-based position, when the page has several"
388
+ },
389
+ "fields": {
390
+ "type": "object",
391
+ "description": "Values typed into the form by field name; true / false / a list of values check checkboxes and radios",
392
+ "additionalProperties": {
393
+ "oneOf": [
394
+ {
395
+ "type": [
396
+ "string",
397
+ "number",
398
+ "boolean"
399
+ ]
400
+ },
401
+ {
402
+ "type": "array",
403
+ "items": {
404
+ "type": [
405
+ "string",
406
+ "number"
407
+ ]
408
+ }
409
+ }
410
+ ]
411
+ }
412
+ },
413
+ "headers": {
414
+ "type": "object"
415
+ },
416
+ "follow": {
417
+ "type": "boolean"
418
+ },
419
+ "expect": {
420
+ "type": "object",
421
+ "additionalProperties": false,
422
+ "properties": {
423
+ "status": {
424
+ "type": [
425
+ "integer",
426
+ "string"
427
+ ]
428
+ },
429
+ "headers": {
430
+ "type": "object"
431
+ },
432
+ "json": {
433
+ "description": "Subset of the response JSON"
434
+ },
435
+ "text": {}
436
+ }
437
+ },
438
+ "capture": {
439
+ "type": "object",
440
+ "description": "Save values as variables for later steps, e.g. { pollId: json.id }",
441
+ "additionalProperties": {
442
+ "type": "string"
443
+ }
444
+ }
445
+ }
446
+ },
307
447
  {
308
448
  "type": "object",
309
449
  "required": [