slicetest 0.4.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.
Files changed (58) hide show
  1. package/README.md +293 -7
  2. package/dist/app.js +2 -2
  3. package/dist/auth.d.ts +55 -0
  4. package/dist/auth.js +140 -0
  5. package/dist/cli.d.ts +1 -1
  6. package/dist/cli.js +18 -6
  7. package/dist/config.d.ts +72 -5
  8. package/dist/config.js +74 -6
  9. package/dist/containers.d.ts +2 -0
  10. package/dist/containers.js +20 -1
  11. package/dist/db.d.ts +28 -0
  12. package/dist/db.js +68 -0
  13. package/dist/doctor.js +17 -5
  14. package/dist/drivers/driver.d.ts +27 -0
  15. package/dist/drivers/mysql.d.ts +2 -1
  16. package/dist/drivers/mysql.js +38 -0
  17. package/dist/drivers/postgres.d.ts +2 -1
  18. package/dist/drivers/postgres.js +28 -0
  19. package/dist/drivers/sqlite.d.ts +2 -1
  20. package/dist/drivers/sqlite.js +52 -0
  21. package/dist/factory.d.ts +21 -0
  22. package/dist/factory.js +129 -0
  23. package/dist/form.d.ts +45 -0
  24. package/dist/form.js +226 -0
  25. package/dist/gen.d.ts +1 -0
  26. package/dist/gen.js +9 -4
  27. package/dist/global-setup.d.ts +1 -0
  28. package/dist/global-setup.js +90 -34
  29. package/dist/http.d.ts +24 -1
  30. package/dist/http.js +86 -9
  31. package/dist/index.d.ts +6 -1
  32. package/dist/index.js +3 -0
  33. package/dist/init.js +245 -24
  34. package/dist/intercept.d.ts +37 -0
  35. package/dist/intercept.js +199 -0
  36. package/dist/neon.d.ts +14 -0
  37. package/dist/neon.js +113 -0
  38. package/dist/openapi.d.ts +10 -0
  39. package/dist/openapi.js +22 -0
  40. package/dist/query-log.d.ts +49 -0
  41. package/dist/query-log.js +261 -0
  42. package/dist/runtime.d.ts +18 -0
  43. package/dist/runtime.js +163 -23
  44. package/dist/setup-file.js +19 -2
  45. package/dist/stub.d.ts +46 -0
  46. package/dist/stub.js +109 -0
  47. package/dist/vitest.d.ts +11 -1
  48. package/dist/vitest.js +27 -2
  49. package/dist/webhook.d.ts +40 -0
  50. package/dist/webhook.js +52 -0
  51. package/dist/x509.d.ts +37 -0
  52. package/dist/x509.js +150 -0
  53. package/dist/yaml-runtime.js +76 -16
  54. package/dist/yaml.d.ts +67 -1
  55. package/dist/yaml.js +87 -3
  56. package/package.json +12 -4
  57. package/preload/node-proxy.cjs +19 -0
  58. package/schema/scenario.schema.json +320 -0
@@ -0,0 +1,40 @@
1
+ /** A custom HMAC scheme: `header: prefix + hmac(secret, body)`. */
2
+ export interface HmacScheme {
3
+ header: string;
4
+ /** Default `sha256`. */
5
+ algorithm?: string;
6
+ /** Default `hex`. */
7
+ encoding?: "hex" | "base64";
8
+ /** Prepended to the digest, e.g. `sha256=`. */
9
+ prefix?: string;
10
+ }
11
+ export type WebhookProvider = "stripe" | "github" | "slack" | "shopify" | "standard" | HmacScheme;
12
+ export interface WebhookOptions {
13
+ provider: WebhookProvider;
14
+ /** The signing secret the app is configured with. For `standard` (Svix), `whsec_<base64>` or the raw key. */
15
+ secret: string;
16
+ /** Event type, sent where the provider puts it: `X-GitHub-Event`, `X-Shopify-Topic`. */
17
+ event?: string;
18
+ /** Unix seconds the signature is made for. Default: now. */
19
+ timestamp?: number;
20
+ /** Signed ten minutes ago: an app that rejects replays must refuse it. */
21
+ stale?: boolean;
22
+ /** Signed with a different secret: the app must refuse it. */
23
+ invalidSignature?: boolean;
24
+ /** Message id for `standard` (`webhook-id`). Default: a random `msg_…`. */
25
+ id?: string;
26
+ /** Extra request headers. */
27
+ headers?: Record<string, string>;
28
+ }
29
+ /**
30
+ * The headers a provider sends with `body`, signed with `secret` the way its
31
+ * SDK verifies them: Stripe's `Stripe-Signature`, GitHub's
32
+ * `X-Hub-Signature-256`, Slack's `v0` signature, Shopify's base64 HMAC and
33
+ * Standard Webhooks (Svix, Resend, Clerk, …).
34
+ */
35
+ export declare function signWebhook(body: string, opts: WebhookOptions): Record<string, string>;
36
+ /** The bytes to send and their content type: objects as JSON, URLSearchParams as a form (Slack commands), strings as-is. */
37
+ export declare function webhookBody(payload: unknown): {
38
+ body: string;
39
+ type: string;
40
+ };
@@ -0,0 +1,52 @@
1
+ import { createHmac, randomUUID } from "node:crypto";
2
+ const hmac = (algorithm, key, data) => createHmac(algorithm, key).update(data);
3
+ /**
4
+ * The headers a provider sends with `body`, signed with `secret` the way its
5
+ * SDK verifies them: Stripe's `Stripe-Signature`, GitHub's
6
+ * `X-Hub-Signature-256`, Slack's `v0` signature, Shopify's base64 HMAC and
7
+ * Standard Webhooks (Svix, Resend, Clerk, …).
8
+ */
9
+ export function signWebhook(body, opts) {
10
+ const now = Math.floor(Date.now() / 1000);
11
+ const ts = opts.timestamp ?? (opts.stale ? now - 600 : now);
12
+ const secret = opts.invalidSignature ? `${opts.secret}-not-the-real-secret` : opts.secret;
13
+ const p = opts.provider;
14
+ if (typeof p === "object") {
15
+ if (!p.header)
16
+ throw new Error("slicetest: a custom webhook provider needs `header`, e.g. { header: \"X-Signature\", prefix: \"sha256=\" }");
17
+ return { [p.header]: `${p.prefix ?? ""}${hmac(p.algorithm ?? "sha256", secret, body).digest(p.encoding ?? "hex")}` };
18
+ }
19
+ switch (p) {
20
+ case "stripe":
21
+ return { "stripe-signature": `t=${ts},v1=${hmac("sha256", secret, `${ts}.${body}`).digest("hex")}` };
22
+ case "github":
23
+ return {
24
+ "x-hub-signature-256": `sha256=${hmac("sha256", secret, body).digest("hex")}`,
25
+ "x-github-event": opts.event ?? "ping",
26
+ "x-github-delivery": randomUUID(),
27
+ };
28
+ case "slack":
29
+ return { "x-slack-request-timestamp": String(ts), "x-slack-signature": `v0=${hmac("sha256", secret, `v0:${ts}:${body}`).digest("hex")}` };
30
+ case "shopify":
31
+ return {
32
+ "x-shopify-hmac-sha256": hmac("sha256", secret, body).digest("base64"),
33
+ ...(opts.event ? { "x-shopify-topic": opts.event } : {}),
34
+ "x-shopify-webhook-id": randomUUID(),
35
+ };
36
+ case "standard": {
37
+ const id = opts.id ?? `msg_${randomUUID().replace(/-/g, "")}`;
38
+ const key = secret.startsWith("whsec_") ? Buffer.from(secret.slice(6), "base64") : Buffer.from(secret);
39
+ return { "webhook-id": id, "webhook-timestamp": String(ts), "webhook-signature": `v1,${hmac("sha256", key, `${id}.${ts}.${body}`).digest("base64")}` };
40
+ }
41
+ default:
42
+ throw new Error(`slicetest: unknown webhook provider ${JSON.stringify(p)} (expected stripe, github, slack, shopify, standard or { header, prefix, encoding })`);
43
+ }
44
+ }
45
+ /** The bytes to send and their content type: objects as JSON, URLSearchParams as a form (Slack commands), strings as-is. */
46
+ export function webhookBody(payload) {
47
+ if (typeof payload === "string")
48
+ return { body: payload, type: "application/json" };
49
+ if (payload instanceof URLSearchParams)
50
+ return { body: payload.toString(), type: "application/x-www-form-urlencoded" };
51
+ return { body: JSON.stringify(payload ?? {}), type: "application/json" };
52
+ }
package/dist/x509.d.ts ADDED
@@ -0,0 +1,37 @@
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 { KeyObject } from "node:crypto";
8
+ export declare const seq: (...items: Buffer[]) => Buffer<ArrayBuffer>;
9
+ interface Issued {
10
+ key: KeyObject;
11
+ der: Buffer;
12
+ pem: string;
13
+ }
14
+ /** A certificate authority that exists for one test run. */
15
+ export declare class Authority {
16
+ #private;
17
+ readonly key: KeyObject;
18
+ readonly der: Buffer;
19
+ readonly pem: string;
20
+ constructor();
21
+ /** A certificate for `host`, signed by this authority (cached). */
22
+ leaf(host: string): Issued;
23
+ }
24
+ /** PEM bundle of `extra` plus the usual public roots, for SSL_CERT_FILE and friends (which replace the default store). */
25
+ export declare function pemBundle(roots: readonly string[], extra: string): string;
26
+ /**
27
+ * A PKCS#12 trust store of certificates, readable by Java without a password
28
+ * (`-Djavax.net.ssl.trustStoreType=PKCS12`, no `trustStorePassword`): no MAC and
29
+ * unencrypted certificate bags, each marked as a trusted certificate entry.
30
+ */
31
+ export declare function pkcs12TrustStore(certs: {
32
+ alias: string;
33
+ der: Buffer;
34
+ }[]): Buffer<ArrayBuffer>;
35
+ /** DER of a PEM certificate, or undefined when it doesn't parse. */
36
+ export declare function pemToDer(pem: string): Buffer<ArrayBuffer> | undefined;
37
+ export {};
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
+ }
@@ -4,6 +4,8 @@ import "./provided.js";
4
4
  import { formatChanges } from "./db.js";
5
5
  import "./matchers.js";
6
6
  import { scenario } from "./scenario.js";
7
+ import { signWebhook, webhookBody } from "./webhook.js";
8
+ import { sse } from "./stub.js";
7
9
  /** Registers the scenarios of one parsed YAML file. Called from the module the plugin generates for it. */
8
10
  export function defineYamlScenarios(doc) {
9
11
  for (const sc of doc.scenarios) {
@@ -39,6 +41,8 @@ async function runSteps(doc, sc, steps, ctx, vars) {
39
41
  }
40
42
  }
41
43
  function describeStep(step) {
44
+ if ("submit" in step)
45
+ return step.submit === true ? "submit" : `submit ${JSON.stringify(step.submit)}`;
42
46
  if ("request" in step)
43
47
  return step.concurrency ? `${step.request} ×${step.concurrency}` : step.request;
44
48
  if ("stub" in step)
@@ -47,6 +51,10 @@ function describeStep(step) {
47
51
  return `received ${step.received}${step.call ? ` ${step.call}` : ""}`;
48
52
  if ("insert" in step)
49
53
  return `insert ${step.insert}`;
54
+ if ("make" in step)
55
+ return `make ${step.make}`;
56
+ if ("chaos" in step)
57
+ return `chaos ${step.chaos}`;
50
58
  if ("db" in step)
51
59
  return `db ${step.db}`;
52
60
  if ("changes" in step)
@@ -144,7 +152,10 @@ async function runStep(step, ctx, vars) {
144
152
  if (step.delay !== undefined)
145
153
  route = route.delay(step.delay);
146
154
  // Replies are interpolated when a call arrives, so they can echo it: {{call.params.id}}, {{call.json.name}}.
147
- 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
+ };
148
159
  if (step.networkError)
149
160
  route.networkError();
150
161
  else if (step.sequence) {
@@ -156,32 +167,42 @@ async function runStep(step, ctx, vars) {
156
167
  route.reply(answer(step.reply ?? {}));
157
168
  return;
158
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
+ }
159
185
  if ("request" in step) {
160
186
  const [method, rawPath] = splitCall(step.request);
161
187
  const path = interpolate(rawPath, vars);
188
+ const headers = interpolate(step.headers, vars);
162
189
  const opts = {
163
- headers: interpolate(step.headers, vars),
190
+ headers: step.auth ? { ...ctx.auth.header(step.auth === true ? {} : interpolate(step.auth, vars)), ...headers } : headers,
164
191
  query: interpolate(step.query, vars),
165
192
  follow: step.follow,
166
193
  };
167
- const body = step.json !== undefined
194
+ let body = step.json !== undefined
168
195
  ? interpolate(step.json, vars)
169
196
  : step.form !== undefined
170
197
  ? ctx.http.form(interpolate(step.form, vars))
171
198
  : interpolate(step.body, vars);
199
+ if (step.webhook) {
200
+ const signed = webhookBody(body);
201
+ body = signed.body;
202
+ opts.headers = { "content-type": signed.type, ...signWebhook(signed.body, interpolate(step.webhook, vars)), ...opts.headers };
203
+ }
172
204
  const e = step.expect;
173
- const verify = (res) => {
174
- if (e?.status !== undefined)
175
- expect(res).toHaveStatus(interpolate(e.status, vars));
176
- if (e?.headers !== undefined) {
177
- const expected = Object.fromEntries(Object.entries(e.headers).map(([k, v]) => [k.toLowerCase(), v]));
178
- check(Object.fromEntries(res.headers), expected, vars, "response headers");
179
- }
180
- if (e?.json !== undefined)
181
- check(res.json, e.json, vars, "response JSON");
182
- if (e?.text !== undefined)
183
- check(res.text, e.text, vars, "response text");
184
- };
205
+ const verify = (res) => verifyResponse(res, e, vars);
185
206
  if (step.concurrency !== undefined) {
186
207
  // Each request gets its own body: a URLSearchParams body can only be read once.
187
208
  const all = await ctx.http.concurrently(step.concurrency, () => ctx.http.request(method, path, body instanceof URLSearchParams ? new URLSearchParams(body) : body, opts));
@@ -190,7 +211,18 @@ async function runStep(step, ctx, vars) {
190
211
  all.forEach(verify);
191
212
  return;
192
213
  }
193
- const res = await ctx.http.request(method, path, body, opts);
214
+ const max = e?.queries === undefined ? undefined : interpolate(e.queries, vars);
215
+ let res;
216
+ const queries = await (max === undefined ? undefined : ctx.db.queries(async () => (res = await ctx.http.request(method, path, body, opts))));
217
+ if (!queries)
218
+ res = await ctx.http.request(method, path, body, opts);
219
+ if (queries && max !== undefined) {
220
+ const ran = queries.withoutTransactions();
221
+ if (ran.length > max) {
222
+ const shapes = ran.shapes().map((s) => ` ${s.count > 1 ? `×${s.count} ` : ""}${s.sql}`);
223
+ throw new Error(`expected at most ${max} SQL statements for ${method} ${path}, the app ran ${ran.length}:\n${shapes.join("\n")}`);
224
+ }
225
+ }
194
226
  verify(res);
195
227
  capture(step.capture, { status: res.status, json: res.json, text: res.text, headers: Object.fromEntries(res.headers) }, vars);
196
228
  return;
@@ -200,6 +232,22 @@ async function runStep(step, ctx, vars) {
200
232
  capture(step.capture, { rows, row: rows[0] }, vars);
201
233
  return;
202
234
  }
235
+ if ("chaos" in step) {
236
+ const { chaos, name: _, ...opts } = step;
237
+ ctx.stub(chaos).chaos(opts);
238
+ return;
239
+ }
240
+ if ("make" in step) {
241
+ const given = interpolate(step.rows, vars) ?? {};
242
+ const rows = [];
243
+ if (Array.isArray(given))
244
+ for (const r of given)
245
+ rows.push(await ctx.db.make(step.make, r));
246
+ else
247
+ rows.push(...(await ctx.db.makeMany(step.make, step.count ?? 1, given)));
248
+ capture(step.capture, { rows, row: rows[0] }, vars);
249
+ return;
250
+ }
203
251
  if ("sql" in step) {
204
252
  const rows = await ctx.db.query(step.sql, interpolate(step.params, vars) ?? []);
205
253
  expectRows(step.expect, rows, vars);
@@ -358,3 +406,15 @@ function interpolateTitle(name, row, index) {
358
406
  return v === undefined ? m : typeof v === "object" ? JSON.stringify(v) : String(v);
359
407
  });
360
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 | 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;
@@ -57,14 +68,51 @@ export interface RequestStep {
57
68
  form?: Record<string, unknown>;
58
69
  body?: string;
59
70
  follow?: boolean;
71
+ /** Send `Authorization: Bearer` with a token from the `auth` issuer: `true`, or the claims, e.g. { sub: u1, roles: [admin] }. */
72
+ auth?: true | Record<string, unknown>;
73
+ /** Sign the body as this provider would deliver a webhook: { provider: stripe, secret: whsec_test, event, stale, invalidSignature }. */
74
+ webhook?: {
75
+ provider: string | {
76
+ header: string;
77
+ algorithm?: string;
78
+ encoding?: "hex" | "base64";
79
+ prefix?: string;
80
+ };
81
+ secret: string;
82
+ event?: string;
83
+ stale?: boolean;
84
+ invalidSignature?: boolean;
85
+ };
60
86
  /** Send the request this many times at once. `expect` applies to every response; `statuses` counts them. */
61
87
  concurrency?: number;
88
+ /** `queries`: at most this many SQL statements (not counting BEGIN/COMMIT), with `db.queries` on. */
62
89
  expect?: {
63
90
  status?: number;
64
91
  statuses?: Record<string, number>;
65
92
  headers?: Record<string, unknown>;
66
93
  json?: unknown;
67
94
  text?: unknown;
95
+ queries?: number;
96
+ };
97
+ capture?: Record<string, string>;
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;
68
116
  };
69
117
  capture?: Record<string, string>;
70
118
  }
@@ -73,6 +121,24 @@ export interface InsertStep {
73
121
  rows: Record<string, unknown> | Record<string, unknown>[];
74
122
  capture?: Record<string, string>;
75
123
  }
124
+ /** Rows built by `db.make()`: only the columns given are fixed, the rest is filled from the schema. */
125
+ export interface MakeStep {
126
+ make: string;
127
+ rows?: Record<string, unknown> | Record<string, unknown>[];
128
+ /** Make this many rows, each with `rows` (a mapping). */
129
+ count?: number;
130
+ capture?: Record<string, string>;
131
+ }
132
+ /** Faults injected into a stub's answers for the rest of the scenario, like `stub(name).chaos()`. */
133
+ export interface ChaosStep {
134
+ chaos: string;
135
+ failFirst?: number;
136
+ errorRate?: number;
137
+ statuses?: number[];
138
+ networkErrorRate?: number;
139
+ latency?: number | [number, number];
140
+ seed?: number;
141
+ }
76
142
  export interface SqlStep {
77
143
  sql: string;
78
144
  params?: unknown[];