slicetest 0.1.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/stub.js ADDED
@@ -0,0 +1,228 @@
1
+ import http from "node:http";
2
+ import { isDeepStrictEqual } from "node:util";
3
+ /**
4
+ * A fake outbound service. The app is pointed at `url`; tests register routes
5
+ * with `on()` and inspect what the app sent with `calls()`.
6
+ */
7
+ export class Stub {
8
+ name;
9
+ #server;
10
+ #routes = [];
11
+ #calls = [];
12
+ url = "";
13
+ constructor(name) {
14
+ this.name = name;
15
+ this.#server = http.createServer((req, res) => {
16
+ // An aborted request rejects while reading the body; don't let that crash the worker.
17
+ this.#handle(req, res).catch(() => res.destroy());
18
+ });
19
+ }
20
+ static async start(name) {
21
+ const stub = new Stub(name);
22
+ await new Promise((resolve) => stub.#server.listen(0, "127.0.0.1", resolve));
23
+ stub.url = `http://127.0.0.1:${stub.#server.address().port}`;
24
+ return stub;
25
+ }
26
+ /**
27
+ * Answer `method path`. `path` may contain `:name` segments (captured into
28
+ * `call.params`) or be a RegExp; `method` may be `*`. Later routes win.
29
+ */
30
+ on(method, path, match = {}) {
31
+ const { pattern, paramNames } = compilePath(path);
32
+ const route = {
33
+ method: method.toUpperCase(),
34
+ path,
35
+ pattern,
36
+ paramNames,
37
+ match,
38
+ remaining: Infinity,
39
+ delayMs: 0,
40
+ hits: 0,
41
+ };
42
+ const add = (respond, extra = {}) => {
43
+ this.#routes.unshift({ ...route, ...extra, respond });
44
+ return this;
45
+ };
46
+ const builder = {
47
+ times: (n) => ((route.remaining = n), builder),
48
+ once: () => builder.times(1),
49
+ delay: (ms) => ((route.delayMs = ms), builder),
50
+ reply: (statusOrResponse, body, headers) => add(typeof statusOrResponse === "number" ? { status: statusOrResponse, body, headers } : statusOrResponse),
51
+ replySequence: (responses) => {
52
+ if (responses.length === 0)
53
+ throw new Error("slicetest: replySequence() needs at least one response");
54
+ let i = 0;
55
+ return add(() => responses[Math.min(i++, responses.length - 1)]);
56
+ },
57
+ networkError: () => add({}, { fault: "reset" }),
58
+ };
59
+ return builder;
60
+ }
61
+ /** Calls received so far, optionally filtered by method, path and conditions (same syntax as `on()`). */
62
+ calls(method, path, match = {}) {
63
+ const compiled = path === undefined ? undefined : compilePath(path);
64
+ return this.#calls.filter((c) => (!method || method === "*" || c.method === method.toUpperCase()) &&
65
+ (!compiled || matchPath(path, compiled.pattern, c.path) !== undefined) &&
66
+ matchConditions(match, c));
67
+ }
68
+ unmatched() {
69
+ return this.#calls.filter((c) => !c.matched);
70
+ }
71
+ /** Human-readable list of registered routes, for diagnostics. */
72
+ describeRoutes() {
73
+ return this.#routes.map((r) => {
74
+ const limit = Number.isFinite(r.remaining + r.hits) ? ` (${r.hits}/${r.remaining + r.hits} used)` : "";
75
+ const cond = Object.keys(r.match).length ? ` + ${Object.keys(r.match).join("/")} conditions` : "";
76
+ return `${r.method} ${r.path}${cond}${limit}`;
77
+ });
78
+ }
79
+ reset() {
80
+ this.#routes = [];
81
+ this.#calls = [];
82
+ }
83
+ async close() {
84
+ this.#server.closeAllConnections();
85
+ await new Promise((resolve) => this.#server.close(resolve));
86
+ }
87
+ async #handle(req, res) {
88
+ const chunks = [];
89
+ for await (const chunk of req)
90
+ chunks.push(chunk);
91
+ const body = Buffer.concat(chunks).toString("utf8");
92
+ const url = new URL(req.url ?? "/", this.url);
93
+ const call = {
94
+ method: req.method ?? "GET",
95
+ path: url.pathname,
96
+ query: url.searchParams,
97
+ headers: req.headers,
98
+ body,
99
+ json: parseJson(body),
100
+ params: {},
101
+ matched: false,
102
+ };
103
+ this.#calls.push(call);
104
+ let route;
105
+ for (const r of this.#routes) {
106
+ if (r.remaining <= 0)
107
+ continue;
108
+ if (r.method !== "*" && r.method !== call.method)
109
+ continue;
110
+ const params = matchPath(r.path, r.pattern, call.path, r.paramNames);
111
+ if (!params || !matchConditions(r.match, call))
112
+ continue;
113
+ call.params = params;
114
+ route = r;
115
+ break;
116
+ }
117
+ if (!route) {
118
+ res.writeHead(501, { "content-type": "text/plain" }).end(`slicetest: no stub for ${call.method} ${call.path}`);
119
+ return;
120
+ }
121
+ call.matched = true;
122
+ route.remaining--;
123
+ route.hits++;
124
+ if (route.delayMs > 0)
125
+ await new Promise((r) => setTimeout(r, route.delayMs));
126
+ if (route.fault === "reset") {
127
+ req.socket.destroy();
128
+ return;
129
+ }
130
+ try {
131
+ const out = typeof route.respond === "function" ? await route.respond(call) : route.respond;
132
+ const raw = out.body === undefined || typeof out.body === "string" || out.body instanceof Uint8Array;
133
+ const headers = { ...out.headers };
134
+ if (!raw && !Object.keys(headers).some((h) => h.toLowerCase() === "content-type")) {
135
+ headers["content-type"] = "application/json";
136
+ }
137
+ res.writeHead(out.status ?? 200, headers);
138
+ res.end(raw ? out.body : JSON.stringify(out.body));
139
+ }
140
+ catch (e) {
141
+ res.writeHead(500).end(`slicetest: stub handler threw: ${e}`);
142
+ }
143
+ }
144
+ }
145
+ function compilePath(path) {
146
+ if (typeof path !== "string" || !path.includes("/:"))
147
+ return { pattern: undefined, paramNames: [] };
148
+ const paramNames = [];
149
+ const source = path
150
+ .split("/")
151
+ .map((seg) => {
152
+ if (!seg.startsWith(":"))
153
+ return seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
154
+ paramNames.push(seg.slice(1));
155
+ return "([^/]+)";
156
+ })
157
+ .join("/");
158
+ return { pattern: new RegExp(`^${source}$`), paramNames };
159
+ }
160
+ /** Returns captured params on a match, undefined otherwise. */
161
+ function matchPath(path, pattern, actual, names = []) {
162
+ if (typeof path === "string" && !pattern)
163
+ return path === actual ? {} : undefined;
164
+ const re = pattern ?? path;
165
+ re.lastIndex = 0;
166
+ const m = re.exec(actual);
167
+ if (!m)
168
+ return undefined;
169
+ const params = { ...m.groups };
170
+ names.forEach((n, i) => (params[n] = decodeURIComponent(m[i + 1])));
171
+ return params;
172
+ }
173
+ function matchConditions(match, call) {
174
+ for (const [k, m] of Object.entries(match.query ?? {})) {
175
+ if (!test(m, call.query.get(k) ?? undefined))
176
+ return false;
177
+ }
178
+ for (const [k, m] of Object.entries(match.headers ?? {})) {
179
+ const v = call.headers[k.toLowerCase()];
180
+ if (!test(m, Array.isArray(v) ? v.join(", ") : v))
181
+ return false;
182
+ }
183
+ if (match.body !== undefined && !test(match.body, call.body))
184
+ return false;
185
+ if (match.json !== undefined && !subset(match.json, call.json))
186
+ return false;
187
+ return true;
188
+ }
189
+ function test(m, value) {
190
+ if (isAsymmetric(m))
191
+ return m.asymmetricMatch(value);
192
+ if (typeof m === "function")
193
+ return m(value);
194
+ if (value === undefined)
195
+ return false;
196
+ if (typeof m === "number" || typeof m === "boolean")
197
+ return String(m) === value;
198
+ return m instanceof RegExp ? m.test(value) : m === value;
199
+ }
200
+ /** `expected` is contained in `actual`: objects compare key by key, arrays element-wise. */
201
+ export function subset(expected, actual) {
202
+ if (isAsymmetric(expected))
203
+ return expected.asymmetricMatch(actual);
204
+ if (expected instanceof RegExp)
205
+ return typeof actual === "string" && expected.test(actual);
206
+ if (Array.isArray(expected)) {
207
+ return Array.isArray(actual) && actual.length === expected.length && expected.every((e, i) => subset(e, actual[i]));
208
+ }
209
+ if (expected && typeof expected === "object") {
210
+ if (!actual || typeof actual !== "object")
211
+ return false;
212
+ return Object.entries(expected).every(([k, v]) => subset(v, actual[k]));
213
+ }
214
+ return isDeepStrictEqual(expected, actual);
215
+ }
216
+ function isAsymmetric(m) {
217
+ return !!m && typeof m === "object" && typeof m.asymmetricMatch === "function";
218
+ }
219
+ function parseJson(body) {
220
+ if (!body)
221
+ return undefined;
222
+ try {
223
+ return JSON.parse(body);
224
+ }
225
+ catch {
226
+ return undefined;
227
+ }
228
+ }
@@ -0,0 +1,6 @@
1
+ import type { Plugin } from "vite";
2
+ import { type SlicetestOptions } from "./config.js";
3
+ /** YAML scenario files are picked up next to the regular test files. */
4
+ export declare const YAML_SCENARIOS = "**/*.scenario.{yaml,yml}";
5
+ export declare function slicetest(options: SlicetestOptions): Plugin;
6
+ export type { SlicetestOptions } from "./config.js";
package/dist/vitest.js ADDED
@@ -0,0 +1,46 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { configDefaults } from "vitest/config";
4
+ import { resolveOptions } from "./config.js";
5
+ import { parseScenarioFile } from "./yaml.js";
6
+ /** YAML scenario files are picked up next to the regular test files. */
7
+ export const YAML_SCENARIOS = "**/*.scenario.{yaml,yml}";
8
+ const YAML_ID = /\.scenario\.ya?ml$/;
9
+ const here = path.dirname(fileURLToPath(import.meta.url));
10
+ // Resolve sibling modules with this file's own extension, so it works from both src (.ts) and dist (.js).
11
+ const ext = path.extname(fileURLToPath(import.meta.url));
12
+ export function slicetest(options) {
13
+ let root;
14
+ return {
15
+ name: "slicetest",
16
+ config(config) {
17
+ const root = path.resolve(config.root ?? process.cwd());
18
+ // Mutated rather than returned: a returned list would replace Vitest's default include instead of extending it.
19
+ const test = (config.test ??= {});
20
+ test.include = [...(test.include ?? configDefaults.include), YAML_SCENARIOS];
21
+ return {
22
+ test: {
23
+ globalSetup: [path.join(here, `global-setup${ext}`)],
24
+ setupFiles: [path.join(here, `setup-file${ext}`)],
25
+ provide: { slicetestOptions: resolveOptions(options, root) },
26
+ hookTimeout: 120_000,
27
+ },
28
+ };
29
+ },
30
+ transform(code, id) {
31
+ const file = id.split("?")[0];
32
+ if (!YAML_ID.test(file))
33
+ return;
34
+ const doc = parseScenarioFile(code, path.relative(root ?? process.cwd(), file) || file);
35
+ const runtime = JSON.stringify(path.join(here, `yaml-runtime${ext}`));
36
+ return { code: `import { defineYamlScenarios } from ${runtime};\ndefineYamlScenarios(${JSON.stringify(doc)});\n`, map: null };
37
+ },
38
+ // `config.root` is often unset; the resolved root is the one relative paths should follow.
39
+ configResolved(resolved) {
40
+ root = resolved.root;
41
+ const provided = resolved.test?.provide;
42
+ if (provided?.slicetestOptions)
43
+ provided.slicetestOptions.root = resolved.root;
44
+ },
45
+ };
46
+ }
@@ -0,0 +1,18 @@
1
+ import "./matchers.js";
2
+ import type { YamlFile } from "./yaml.js";
3
+ type Vars = Record<string, unknown>;
4
+ /** Registers the scenarios of one parsed YAML file. Called from the module the plugin generates for it. */
5
+ export declare function defineYamlScenarios(doc: YamlFile): void;
6
+ /** `json.items[0].id` or `json.items.0.id` */
7
+ export declare function lookup(obj: unknown, path: string): unknown;
8
+ /**
9
+ * Replace `{{name}}` in every string. A string that is only `{{name}}` takes
10
+ * the variable's own type, so `id: "{{pollId}}"` stays a number.
11
+ */
12
+ export declare function interpolate(value: unknown, vars: Vars): unknown;
13
+ /**
14
+ * `{ $type: number }`, `{ $regex: "^ch_" }`, `{ $contains: "x" }` and
15
+ * `{ $any: true }` become Vitest asymmetric matchers.
16
+ */
17
+ export declare function toMatchers(value: unknown): unknown;
18
+ export {};
@@ -0,0 +1,255 @@
1
+ import { expect } from "vitest";
2
+ import "./matchers.js";
3
+ import { scenario } from "./scenario.js";
4
+ /** Registers the scenarios of one parsed YAML file. Called from the module the plugin generates for it. */
5
+ export function defineYamlScenarios(doc) {
6
+ for (const sc of doc.scenarios) {
7
+ const register = sc.only ? scenario.only : sc.skip ? scenario.skip : scenario;
8
+ const rows = sc.each ?? [undefined];
9
+ rows.forEach((row, i) => {
10
+ const title = row ? interpolateTitle(sc.name, row, i) : sc.name;
11
+ register(title, (ctx) => runSteps(doc, sc, [...doc.setup, ...sc.steps], ctx, { ...row }), sc.timeout);
12
+ });
13
+ }
14
+ }
15
+ async function runSteps(doc, sc, steps, ctx, vars) {
16
+ for (const [i, step] of steps.entries()) {
17
+ try {
18
+ await runStep(step, ctx, vars);
19
+ }
20
+ catch (e) {
21
+ const label = step.name ?? describeStep(step);
22
+ const where = `${doc.file}:${step.line} (${sc.name}, step ${i + 1}: ${label})`;
23
+ if (e instanceof Error) {
24
+ e.message = `${where}\n${e.message}`;
25
+ throw e;
26
+ }
27
+ throw new Error(`${where}\n${String(e)}`);
28
+ }
29
+ }
30
+ }
31
+ function describeStep(step) {
32
+ if ("request" in step)
33
+ return step.request;
34
+ if ("stub" in step)
35
+ return `stub ${step.stub} ${step.on}`;
36
+ if ("received" in step)
37
+ return `received ${step.received}${step.call ? ` ${step.call}` : ""}`;
38
+ if ("insert" in step)
39
+ return `insert ${step.insert}`;
40
+ if ("db" in step)
41
+ return `db ${step.db}`;
42
+ return "sql";
43
+ }
44
+ async function runStep(step, ctx, vars) {
45
+ if ("stub" in step) {
46
+ const [method, path] = splitCall(step.on);
47
+ let route = ctx.stub(step.stub).on(method, interpolate(path, vars), conditions(step.when, vars));
48
+ if (step.times !== undefined)
49
+ route = route.times(step.times);
50
+ if (step.delay !== undefined)
51
+ route = route.delay(step.delay);
52
+ // Replies are interpolated when a call arrives, so they can echo it: {{call.params.id}}, {{call.json.name}}.
53
+ const answer = (template) => (call) => interpolate(template, { ...vars, call: callVars(call) });
54
+ if (step.networkError)
55
+ route.networkError();
56
+ else if (step.sequence) {
57
+ let n = 0;
58
+ const seq = step.sequence;
59
+ route.reply((call) => answer(seq[Math.min(n++, seq.length - 1)])(call));
60
+ }
61
+ else
62
+ route.reply(answer(step.reply ?? {}));
63
+ return;
64
+ }
65
+ if ("request" in step) {
66
+ const [method, rawPath] = splitCall(step.request);
67
+ const path = interpolate(rawPath, vars);
68
+ const opts = {
69
+ headers: interpolate(step.headers, vars),
70
+ query: interpolate(step.query, vars),
71
+ follow: step.follow,
72
+ };
73
+ const body = step.json !== undefined
74
+ ? interpolate(step.json, vars)
75
+ : step.form !== undefined
76
+ ? ctx.http.form(interpolate(step.form, vars))
77
+ : interpolate(step.body, vars);
78
+ const res = await ctx.http.request(method, path, body, opts);
79
+ const e = step.expect;
80
+ if (e?.status !== undefined)
81
+ expect(res).toHaveStatus(interpolate(e.status, vars));
82
+ if (e?.headers !== undefined) {
83
+ const expected = Object.fromEntries(Object.entries(e.headers).map(([k, v]) => [k.toLowerCase(), v]));
84
+ check(Object.fromEntries(res.headers), expected, vars, "response headers");
85
+ }
86
+ if (e?.json !== undefined)
87
+ check(res.json, e.json, vars, "response JSON");
88
+ if (e?.text !== undefined)
89
+ check(res.text, e.text, vars, "response text");
90
+ capture(step.capture, { status: res.status, json: res.json, text: res.text, headers: Object.fromEntries(res.headers) }, vars);
91
+ return;
92
+ }
93
+ if ("insert" in step) {
94
+ const rows = await ctx.db.insert(step.insert, interpolate(step.rows, vars));
95
+ capture(step.capture, { rows, row: rows[0] }, vars);
96
+ return;
97
+ }
98
+ if ("sql" in step) {
99
+ const rows = await ctx.db.query(step.sql, interpolate(step.params, vars) ?? []);
100
+ expectRows(step.expect, rows, vars);
101
+ capture(step.capture, { rows, row: rows[0], count: rows.length }, vars);
102
+ return;
103
+ }
104
+ if ("db" in step) {
105
+ const rows = await ctx.db.rows(step.db, interpolate(step.where, vars) ?? {}, { orderBy: step.orderBy });
106
+ expectRows(step.expect, rows, vars, step.db);
107
+ capture(step.capture, { rows, row: rows[0], count: rows.length }, vars);
108
+ return;
109
+ }
110
+ // received
111
+ const [method, path] = step.call ? splitCall(step.call) : [undefined, undefined];
112
+ const stub = ctx.stub(step.received);
113
+ const match = conditions(step.when, vars);
114
+ const p = path && interpolate(path, vars);
115
+ if (step.times === undefined)
116
+ expect(stub).toHaveReceived(method ?? "*", p ?? /.*/, match);
117
+ else
118
+ expect(stub).toHaveReceivedTimes(step.times, method, p, match);
119
+ }
120
+ function expectRows(e, rows, vars, table) {
121
+ if (!e)
122
+ return;
123
+ if (e.count !== undefined && rows.length !== e.count) {
124
+ const shown = rows.slice(0, 5).map((r) => ` ${JSON.stringify(r)}`).join("\n");
125
+ throw new Error(`expected ${e.count} row(s)${table ? ` in ${table}` : ""}, found ${rows.length}${shown ? `:\n${shown}` : ""}`);
126
+ }
127
+ if (e.rows !== undefined)
128
+ check(rows, e.rows, vars, table ? `rows of ${table}` : "rows");
129
+ }
130
+ /** Subset comparison with Vitest's diff: objects may have extra keys, arrays must have the same length. */
131
+ function check(actual, expected, vars, what) {
132
+ const want = toMatchers(interpolate(expected, vars));
133
+ if (want && typeof want === "object" && !isAsymmetric(want))
134
+ expect(actual, what).toMatchObject(want);
135
+ else
136
+ expect(actual, what).toEqual(want);
137
+ }
138
+ function conditions(when, vars) {
139
+ if (!when)
140
+ return {};
141
+ return toMatchers(interpolate(when, vars));
142
+ }
143
+ function capture(spec, result, vars) {
144
+ for (const [name, path] of Object.entries(spec ?? {})) {
145
+ const value = lookup(result, path);
146
+ if (value === undefined) {
147
+ throw new Error(`capture "${name}": ${path} is undefined in ${JSON.stringify(result).slice(0, 300)}`);
148
+ }
149
+ vars[name] = value;
150
+ }
151
+ }
152
+ function callVars(call) {
153
+ return {
154
+ method: call.method,
155
+ path: call.path,
156
+ params: call.params,
157
+ query: Object.fromEntries(call.query),
158
+ headers: call.headers,
159
+ json: call.json,
160
+ body: call.body,
161
+ };
162
+ }
163
+ function splitCall(s) {
164
+ const i = s.search(/\s/);
165
+ return [s.slice(0, i).toUpperCase(), s.slice(i).trim()];
166
+ }
167
+ /** `json.items[0].id` or `json.items.0.id` */
168
+ export function lookup(obj, path) {
169
+ return path
170
+ .replace(/\[(\d+)\]/g, ".$1")
171
+ .split(".")
172
+ .filter(Boolean)
173
+ .reduce((cur, key) => (cur == null ? undefined : cur[key]), obj);
174
+ }
175
+ const WHOLE = /^\{\{\s*([^{}]+?)\s*\}\}$/;
176
+ const PART = /\{\{\s*([^{}]+?)\s*\}\}/g;
177
+ /**
178
+ * Replace `{{name}}` in every string. A string that is only `{{name}}` takes
179
+ * the variable's own type, so `id: "{{pollId}}"` stays a number.
180
+ */
181
+ export function interpolate(value, vars) {
182
+ if (typeof value === "string") {
183
+ const whole = WHOLE.exec(value);
184
+ if (whole)
185
+ return resolve(whole[1], vars);
186
+ return value.replace(PART, (_, name) => {
187
+ const v = resolve(name, vars);
188
+ return typeof v === "object" ? JSON.stringify(v) : String(v);
189
+ });
190
+ }
191
+ if (Array.isArray(value))
192
+ return value.map((v) => interpolate(v, vars));
193
+ if (value && typeof value === "object") {
194
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, interpolate(v, vars)]));
195
+ }
196
+ return value;
197
+ }
198
+ function resolve(name, vars) {
199
+ const v = lookup(vars, name);
200
+ if (v === undefined) {
201
+ const known = Object.keys(vars).filter((k) => k !== "call");
202
+ throw new Error(`unknown variable {{${name}}}. Defined so far: ${known.join(", ") || "(none)"}`);
203
+ }
204
+ return v;
205
+ }
206
+ const TYPES = {
207
+ string: expect.any(String),
208
+ number: expect.any(Number),
209
+ boolean: expect.any(Boolean),
210
+ array: expect.any(Array),
211
+ object: expect.any(Object),
212
+ };
213
+ /**
214
+ * `{ $type: number }`, `{ $regex: "^ch_" }`, `{ $contains: "x" }` and
215
+ * `{ $any: true }` become Vitest asymmetric matchers.
216
+ */
217
+ export function toMatchers(value) {
218
+ if (Array.isArray(value))
219
+ return value.map(toMatchers);
220
+ if (!value || typeof value !== "object" || isAsymmetric(value))
221
+ return value;
222
+ const keys = Object.keys(value);
223
+ if (keys.length === 1 && keys[0].startsWith("$")) {
224
+ const [key] = keys;
225
+ const arg = value[key];
226
+ switch (key) {
227
+ case "$type":
228
+ if (arg === "null")
229
+ return null;
230
+ if (typeof arg === "string" && arg in TYPES)
231
+ return TYPES[arg];
232
+ throw new Error(`$type must be one of ${Object.keys(TYPES).join(", ")}, null`);
233
+ case "$regex":
234
+ return expect.stringMatching(new RegExp(String(arg)));
235
+ case "$contains":
236
+ return expect.stringContaining(String(arg));
237
+ case "$any":
238
+ return expect.anything();
239
+ default:
240
+ throw new Error(`unknown matcher ${key} (expected $type, $regex, $contains, $any)`);
241
+ }
242
+ }
243
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, toMatchers(v)]));
244
+ }
245
+ function isAsymmetric(v) {
246
+ return !!v && typeof v === "object" && typeof v.asymmetricMatch === "function";
247
+ }
248
+ function interpolateTitle(name, row, index) {
249
+ return name.replace(PART, (m, key) => {
250
+ if (key.trim() === "#")
251
+ return String(index);
252
+ const v = lookup(row, key);
253
+ return v === undefined ? m : typeof v === "object" ? JSON.stringify(v) : String(v);
254
+ });
255
+ }
package/dist/yaml.d.ts ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * YAML scenarios: the same things a TypeScript scenario can do, written as
3
+ * data so teams that don't use JavaScript can write them. This module parses
4
+ * and validates a file (in the Vitest main process, where line numbers are
5
+ * known); yaml-runtime.ts runs the result inside a worker.
6
+ */
7
+ export interface YamlFile {
8
+ file: string;
9
+ /** Steps run at the start of every scenario in the file. */
10
+ setup: Step[];
11
+ scenarios: YamlScenario[];
12
+ }
13
+ export interface YamlScenario {
14
+ name: string;
15
+ line: number;
16
+ each?: Record<string, unknown>[];
17
+ skip?: boolean;
18
+ only?: boolean;
19
+ timeout?: number;
20
+ steps: Step[];
21
+ }
22
+ export type Step = (StubStep | RequestStep | InsertStep | SqlStep | DbStep | ReceivedStep) & {
23
+ line: number;
24
+ name?: string;
25
+ };
26
+ export interface StubStep {
27
+ stub: string;
28
+ on: string;
29
+ when?: Conditions;
30
+ reply?: {
31
+ status?: number;
32
+ headers?: Record<string, string>;
33
+ body?: unknown;
34
+ };
35
+ sequence?: {
36
+ status?: number;
37
+ headers?: Record<string, string>;
38
+ body?: unknown;
39
+ }[];
40
+ networkError?: boolean;
41
+ times?: number;
42
+ delay?: number;
43
+ }
44
+ export interface Conditions {
45
+ query?: Record<string, unknown>;
46
+ headers?: Record<string, unknown>;
47
+ json?: unknown;
48
+ body?: unknown;
49
+ }
50
+ export interface RequestStep {
51
+ request: string;
52
+ headers?: Record<string, string>;
53
+ query?: Record<string, unknown>;
54
+ json?: unknown;
55
+ form?: Record<string, unknown>;
56
+ body?: string;
57
+ follow?: boolean;
58
+ expect?: {
59
+ status?: number;
60
+ headers?: Record<string, unknown>;
61
+ json?: unknown;
62
+ text?: unknown;
63
+ };
64
+ capture?: Record<string, string>;
65
+ }
66
+ export interface InsertStep {
67
+ insert: string;
68
+ rows: Record<string, unknown> | Record<string, unknown>[];
69
+ capture?: Record<string, string>;
70
+ }
71
+ export interface SqlStep {
72
+ sql: string;
73
+ params?: unknown[];
74
+ expect?: {
75
+ rows?: unknown[];
76
+ count?: number;
77
+ };
78
+ capture?: Record<string, string>;
79
+ }
80
+ export interface DbStep {
81
+ db: string;
82
+ where?: Record<string, unknown>;
83
+ orderBy?: string | string[];
84
+ expect?: {
85
+ rows?: unknown[];
86
+ count?: number;
87
+ };
88
+ capture?: Record<string, string>;
89
+ }
90
+ export interface ReceivedStep {
91
+ received: string;
92
+ call?: string;
93
+ when?: Conditions;
94
+ /** Exact number of matching calls. Default: at least one. */
95
+ times?: number;
96
+ }
97
+ export declare class YamlScenarioError extends Error {
98
+ }
99
+ export declare function parseScenarioFile(text: string, file: string): YamlFile;