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/README.md +183 -5
- package/dist/app.js +2 -2
- package/dist/auth.d.ts +10 -0
- package/dist/auth.js +13 -1
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +10 -3
- package/dist/config.d.ts +58 -5
- package/dist/config.js +61 -9
- package/dist/containers.d.ts +2 -0
- package/dist/containers.js +20 -1
- package/dist/db.d.ts +2 -0
- package/dist/db.js +18 -0
- package/dist/doctor.js +17 -5
- package/dist/form.d.ts +45 -0
- package/dist/form.js +226 -0
- package/dist/global-setup.d.ts +1 -0
- package/dist/global-setup.js +90 -34
- package/dist/http.d.ts +17 -1
- package/dist/http.js +76 -9
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/init.js +203 -25
- package/dist/intercept.d.ts +37 -0
- package/dist/intercept.js +199 -0
- package/dist/neon.d.ts +14 -0
- package/dist/neon.js +113 -0
- package/dist/openapi.d.ts +2 -0
- package/dist/openapi.js +4 -0
- package/dist/runtime.d.ts +12 -0
- package/dist/runtime.js +127 -25
- package/dist/setup-file.js +19 -2
- package/dist/stub.d.ts +14 -0
- package/dist/stub.js +22 -0
- package/dist/vitest.d.ts +11 -1
- package/dist/vitest.js +27 -2
- package/dist/x509.d.ts +37 -0
- package/dist/x509.js +150 -0
- package/dist/yaml-runtime.js +35 -13
- package/dist/yaml.d.ts +32 -1
- package/dist/yaml.js +31 -1
- package/package.json +5 -3
- package/preload/node-proxy.cjs +19 -0
- package/schema/scenario.schema.json +140 -0
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
|
+
}
|
package/dist/yaml-runtime.js
CHANGED
|
@@ -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) =>
|
|
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.
|
|
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
|
-
"
|
|
70
|
+
"@testcontainers/mysql": ">=11",
|
|
70
71
|
"mysql2": ">=3",
|
|
71
|
-
"
|
|
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": [
|