slicetest 0.2.0 → 0.4.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 +188 -9
- package/dist/ci.d.ts +29 -0
- package/dist/ci.js +57 -0
- package/dist/cli.js +52 -3
- package/dist/config.d.ts +61 -8
- package/dist/config.js +46 -2
- package/dist/containers.d.ts +22 -0
- package/dist/containers.js +59 -0
- package/dist/doctor.d.ts +22 -0
- package/dist/doctor.js +190 -0
- package/dist/drivers/driver.d.ts +2 -0
- package/dist/drivers/index.d.ts +2 -2
- package/dist/drivers/index.js +13 -2
- package/dist/drivers/mysql.d.ts +29 -0
- package/dist/drivers/mysql.js +270 -0
- package/dist/drivers/sqlite.d.ts +23 -0
- package/dist/drivers/sqlite.js +203 -0
- package/dist/gen.d.ts +24 -0
- package/dist/gen.js +116 -0
- package/dist/global-setup.js +53 -5
- package/dist/http.d.ts +6 -0
- package/dist/http.js +14 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +1 -0
- package/dist/init.d.ts +2 -0
- package/dist/init.js +180 -3
- package/dist/mail.d.ts +58 -0
- package/dist/mail.js +299 -0
- package/dist/matchers.d.ts +4 -0
- package/dist/matchers.js +33 -0
- package/dist/openapi.d.ts +19 -0
- package/dist/openapi.js +43 -1
- package/dist/provided.d.ts +2 -0
- package/dist/record-cli.d.ts +6 -0
- package/dist/record-cli.js +93 -0
- package/dist/record-session.d.ts +1 -0
- package/dist/record-session.js +10 -0
- package/dist/record.d.ts +46 -0
- package/dist/record.js +201 -0
- package/dist/recording.d.ts +43 -0
- package/dist/recording.js +148 -0
- package/dist/runtime.d.ts +17 -0
- package/dist/runtime.js +71 -6
- package/dist/stub.d.ts +1 -1
- package/dist/stub.js +14 -3
- package/dist/trace.d.ts +43 -0
- package/dist/trace.js +59 -0
- package/dist/vitest.js +5 -2
- package/dist/yaml-runtime.d.ts +1 -0
- package/dist/yaml-runtime.js +58 -13
- package/dist/yaml.d.ts +30 -1
- package/dist/yaml.js +41 -5
- package/package.json +18 -4
- package/schema/scenario.schema.json +173 -1
package/dist/trace.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Changes } from "./db.js";
|
|
2
|
+
import type { HttpResponse } from "./http.js";
|
|
3
|
+
import type { Mailbox } from "./mail.js";
|
|
4
|
+
import type { Stub } from "./stub.js";
|
|
5
|
+
/**
|
|
6
|
+
* Everything one scenario did at the three boundaries: the requests it made to
|
|
7
|
+
* the app, the calls the app made to stubs, and the rows it changed. Meant for
|
|
8
|
+
* `expect(await trace()).toMatchSnapshot()`: resets make ids deterministic,
|
|
9
|
+
* and `mask` replaces the values that aren't (timestamps, UUIDs).
|
|
10
|
+
*/
|
|
11
|
+
export interface Trace {
|
|
12
|
+
http: {
|
|
13
|
+
request: string;
|
|
14
|
+
status: number;
|
|
15
|
+
body?: unknown;
|
|
16
|
+
}[];
|
|
17
|
+
stubs: Record<string, {
|
|
18
|
+
request: string;
|
|
19
|
+
body?: unknown;
|
|
20
|
+
status?: number;
|
|
21
|
+
}[]>;
|
|
22
|
+
db: Changes;
|
|
23
|
+
/** Mail the app sent, when the `mail` option is on. */
|
|
24
|
+
mail?: {
|
|
25
|
+
from: string;
|
|
26
|
+
to: string[];
|
|
27
|
+
subject: string;
|
|
28
|
+
text: string;
|
|
29
|
+
}[];
|
|
30
|
+
}
|
|
31
|
+
export interface MaskOptions {
|
|
32
|
+
/** Keys whose values are always replaced, e.g. `["token", "password_hash"]`. Matched at any depth. */
|
|
33
|
+
keys?: string[];
|
|
34
|
+
/** Also mask strings matching these patterns, e.g. `[/^tok_\w+$/]`. */
|
|
35
|
+
patterns?: RegExp[];
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* A copy of `value` with the parts that change from run to run replaced by
|
|
39
|
+
* placeholders: dates (`Date` objects and ISO strings) by `[date]`, UUIDs by
|
|
40
|
+
* `[uuid]`, and the values of `keys` / strings matching `patterns` by `[masked]`.
|
|
41
|
+
*/
|
|
42
|
+
export declare function mask<T>(value: T, opts?: MaskOptions): T;
|
|
43
|
+
export declare function buildTrace(history: readonly HttpResponse[], stubs: Iterable<Stub>, db: Changes, mailbox?: Mailbox): Trace;
|
package/dist/trace.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const ISO_DATE = /^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/;
|
|
2
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
3
|
+
/**
|
|
4
|
+
* A copy of `value` with the parts that change from run to run replaced by
|
|
5
|
+
* placeholders: dates (`Date` objects and ISO strings) by `[date]`, UUIDs by
|
|
6
|
+
* `[uuid]`, and the values of `keys` / strings matching `patterns` by `[masked]`.
|
|
7
|
+
*/
|
|
8
|
+
export function mask(value, opts = {}) {
|
|
9
|
+
const keys = new Set(opts.keys ?? []);
|
|
10
|
+
const walk = (v) => {
|
|
11
|
+
if (v instanceof Date)
|
|
12
|
+
return "[date]";
|
|
13
|
+
if (typeof v === "bigint")
|
|
14
|
+
return v.toString();
|
|
15
|
+
if (typeof v === "string") {
|
|
16
|
+
if (UUID.test(v))
|
|
17
|
+
return "[uuid]";
|
|
18
|
+
if (ISO_DATE.test(v))
|
|
19
|
+
return "[date]";
|
|
20
|
+
if (opts.patterns?.some((p) => ((p.lastIndex = 0), p.test(v))))
|
|
21
|
+
return "[masked]";
|
|
22
|
+
return v;
|
|
23
|
+
}
|
|
24
|
+
if (Array.isArray(v))
|
|
25
|
+
return v.map(walk);
|
|
26
|
+
if (v && typeof v === "object") {
|
|
27
|
+
return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, keys.has(k) && x != null ? "[masked]" : walk(x)]));
|
|
28
|
+
}
|
|
29
|
+
return v;
|
|
30
|
+
};
|
|
31
|
+
return walk(value);
|
|
32
|
+
}
|
|
33
|
+
export function buildTrace(history, stubs, db, mailbox) {
|
|
34
|
+
const trace = {
|
|
35
|
+
http: history.map((r) => {
|
|
36
|
+
const url = new URL(r.url, "http://app");
|
|
37
|
+
const body = r.json !== undefined ? r.json : r.text || undefined;
|
|
38
|
+
return { request: `${r.method} ${url.pathname}${url.search}`, status: r.status, ...(body !== undefined ? { body } : {}) };
|
|
39
|
+
}),
|
|
40
|
+
stubs: {},
|
|
41
|
+
db,
|
|
42
|
+
};
|
|
43
|
+
for (const stub of stubs) {
|
|
44
|
+
const calls = stub.calls();
|
|
45
|
+
if (calls.length === 0)
|
|
46
|
+
continue;
|
|
47
|
+
trace.stubs[stub.name] = calls.map((c) => {
|
|
48
|
+
const body = c.json !== undefined ? c.json : c.body || undefined;
|
|
49
|
+
return {
|
|
50
|
+
request: `${c.method} ${c.path}${c.query.size ? `?${c.query}` : ""}`,
|
|
51
|
+
...(body !== undefined ? { body } : {}),
|
|
52
|
+
...(c.response ? { status: c.response.status } : {}),
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
if (mailbox)
|
|
57
|
+
trace.mail = mailbox.messages().map((m) => ({ from: m.from, to: m.to, subject: m.subject, text: m.text }));
|
|
58
|
+
return trace;
|
|
59
|
+
}
|
package/dist/vitest.js
CHANGED
|
@@ -2,6 +2,7 @@ import path from "node:path";
|
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
3
|
import { configDefaults } from "vitest/config";
|
|
4
4
|
import { resolveOptions } from "./config.js";
|
|
5
|
+
import { SESSION_ENV } from "./record.js";
|
|
5
6
|
import { parseScenarioFile } from "./yaml.js";
|
|
6
7
|
/** YAML scenario files are picked up next to the regular test files. */
|
|
7
8
|
export const YAML_SCENARIOS = "**/*.scenario.{yaml,yml}";
|
|
@@ -17,7 +18,9 @@ export function slicetest(options) {
|
|
|
17
18
|
const root = path.resolve(config.root ?? process.cwd());
|
|
18
19
|
// Mutated rather than returned: a returned list would replace Vitest's default include instead of extending it.
|
|
19
20
|
const test = (config.test ??= {});
|
|
20
|
-
|
|
21
|
+
// `slicetest record` runs only its session file.
|
|
22
|
+
if (!process.env[SESSION_ENV])
|
|
23
|
+
test.include = [...(test.include ?? configDefaults.include), YAML_SCENARIOS];
|
|
21
24
|
return {
|
|
22
25
|
test: {
|
|
23
26
|
globalSetup: [path.join(here, `global-setup${ext}`)],
|
|
@@ -31,7 +34,7 @@ export function slicetest(options) {
|
|
|
31
34
|
const file = id.split("?")[0];
|
|
32
35
|
if (!YAML_ID.test(file))
|
|
33
36
|
return;
|
|
34
|
-
const doc = parseScenarioFile(code, path.relative(root ?? process.cwd(), file) || file);
|
|
37
|
+
const doc = { ...parseScenarioFile(code, path.relative(root ?? process.cwd(), file) || file), path: file };
|
|
35
38
|
const runtime = JSON.stringify(path.join(here, `yaml-runtime${ext}`));
|
|
36
39
|
return { code: `import { defineYamlScenarios } from ${runtime};\ndefineYamlScenarios(${JSON.stringify(doc)});\n`, map: null };
|
|
37
40
|
},
|
package/dist/yaml-runtime.d.ts
CHANGED
package/dist/yaml-runtime.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { expect } from "vitest";
|
|
1
|
+
import { expect, inject } from "vitest";
|
|
2
|
+
import { recordYamlFailure } from "./ci.js";
|
|
3
|
+
import "./provided.js";
|
|
2
4
|
import { formatChanges } from "./db.js";
|
|
3
5
|
import "./matchers.js";
|
|
4
6
|
import { scenario } from "./scenario.js";
|
|
@@ -17,11 +19,17 @@ async function runSteps(doc, sc, steps, ctx, vars) {
|
|
|
17
19
|
for (const [i, step] of steps.entries()) {
|
|
18
20
|
try {
|
|
19
21
|
// A log step waits by itself; `within` is its timeout.
|
|
20
|
-
|
|
22
|
+
// Log and mail steps wait by themselves; `within` is their timeout.
|
|
23
|
+
await retry("within" in step && !("log" in step) && !("mail" in step) ? step.within : undefined, () => runStep(step, ctx, vars));
|
|
21
24
|
}
|
|
22
25
|
catch (e) {
|
|
23
26
|
const label = step.name ?? describeStep(step);
|
|
24
27
|
const where = `${doc.file}:${step.line} (${sc.name}, step ${i + 1}: ${label})`;
|
|
28
|
+
const ciDir = inject("slicetestDb")?.ciDir;
|
|
29
|
+
if (ciDir && doc.path) {
|
|
30
|
+
const message = (e instanceof Error ? e.message : String(e)).replace(/\x1b\[[0-9;]*m/g, "");
|
|
31
|
+
await recordYamlFailure(ciDir, { file: doc.path, line: step.line, scenario: sc.name, step: `step ${i + 1}: ${label}`, message }).catch(() => { });
|
|
32
|
+
}
|
|
25
33
|
if (e instanceof Error) {
|
|
26
34
|
e.message = `${where}\n${e.message}`;
|
|
27
35
|
throw e;
|
|
@@ -32,7 +40,7 @@ async function runSteps(doc, sc, steps, ctx, vars) {
|
|
|
32
40
|
}
|
|
33
41
|
function describeStep(step) {
|
|
34
42
|
if ("request" in step)
|
|
35
|
-
return step.request;
|
|
43
|
+
return step.concurrency ? `${step.request} ×${step.concurrency}` : step.request;
|
|
36
44
|
if ("stub" in step)
|
|
37
45
|
return `stub ${step.stub} ${step.on}`;
|
|
38
46
|
if ("received" in step)
|
|
@@ -45,6 +53,10 @@ function describeStep(step) {
|
|
|
45
53
|
return "changes";
|
|
46
54
|
if ("checkpoint" in step)
|
|
47
55
|
return "checkpoint";
|
|
56
|
+
if ("snapshot" in step)
|
|
57
|
+
return "snapshot";
|
|
58
|
+
if ("mail" in step)
|
|
59
|
+
return `mail${Object.entries(step.mail).map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`).join(",")}`;
|
|
48
60
|
if ("log" in step)
|
|
49
61
|
return `log ${step.from ? `from ${step.from} ` : ""}/${step.log}/`;
|
|
50
62
|
return "sql";
|
|
@@ -73,11 +85,33 @@ async function runStep(step, ctx, vars) {
|
|
|
73
85
|
await ctx.db.checkpoint();
|
|
74
86
|
return;
|
|
75
87
|
}
|
|
88
|
+
if ("snapshot" in step) {
|
|
89
|
+
expect(await ctx.trace({ keys: step.mask })).toMatchSnapshot();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
76
92
|
if ("log" in step) {
|
|
77
93
|
const target = step.from ? ctx.service(step.from) : ctx.app;
|
|
78
94
|
await target.waitForLog(new RegExp(interpolate(step.log, vars)), step.within ?? 5000);
|
|
79
95
|
return;
|
|
80
96
|
}
|
|
97
|
+
if ("mail" in step) {
|
|
98
|
+
const filter = Object.fromEntries(Object.entries(interpolate(step.mail, vars)).map(([k, v]) => {
|
|
99
|
+
const re = v && typeof v === "object" && "$regex" in v ? new RegExp(String(v.$regex)) : undefined;
|
|
100
|
+
return [k, re ?? String(v)];
|
|
101
|
+
}));
|
|
102
|
+
const box = ctx.mail;
|
|
103
|
+
await retry(step.within ?? 5000, async () => {
|
|
104
|
+
const found = box.messages(filter);
|
|
105
|
+
if (step.times === undefined ? found.length === 0 : found.length !== step.times) {
|
|
106
|
+
const want = step.times === undefined ? "at least one message" : `${step.times} message(s)`;
|
|
107
|
+
throw new Error(`expected ${want} matching ${JSON.stringify(step.mail)}, got ${found.length}\n${box.describe()}`);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
const last = box.last(filter);
|
|
111
|
+
if (last)
|
|
112
|
+
capture(step.capture, last, vars);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
81
115
|
if ("changes" in step) {
|
|
82
116
|
const actual = await ctx.db.changes();
|
|
83
117
|
const expected = interpolate(step.changes, vars);
|
|
@@ -135,18 +169,29 @@ async function runStep(step, ctx, vars) {
|
|
|
135
169
|
: step.form !== undefined
|
|
136
170
|
? ctx.http.form(interpolate(step.form, vars))
|
|
137
171
|
: interpolate(step.body, vars);
|
|
138
|
-
const res = await ctx.http.request(method, path, body, opts);
|
|
139
172
|
const e = step.expect;
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
+
};
|
|
185
|
+
if (step.concurrency !== undefined) {
|
|
186
|
+
// Each request gets its own body: a URLSearchParams body can only be read once.
|
|
187
|
+
const all = await ctx.http.concurrently(step.concurrency, () => ctx.http.request(method, path, body instanceof URLSearchParams ? new URLSearchParams(body) : body, opts));
|
|
188
|
+
if (e?.statuses !== undefined)
|
|
189
|
+
expect(all).toHaveStatuses(interpolate(e.statuses, vars));
|
|
190
|
+
all.forEach(verify);
|
|
191
|
+
return;
|
|
145
192
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
if (e?.text !== undefined)
|
|
149
|
-
check(res.text, e.text, vars, "response text");
|
|
193
|
+
const res = await ctx.http.request(method, path, body, opts);
|
|
194
|
+
verify(res);
|
|
150
195
|
capture(step.capture, { status: res.status, json: res.json, text: res.text, headers: Object.fromEntries(res.headers) }, vars);
|
|
151
196
|
return;
|
|
152
197
|
}
|
package/dist/yaml.d.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
export interface YamlFile {
|
|
8
8
|
file: string;
|
|
9
|
+
/** Absolute path, for CI annotations. */
|
|
10
|
+
path?: string;
|
|
9
11
|
/** Steps run at the start of every scenario in the file. */
|
|
10
12
|
setup: Step[];
|
|
11
13
|
scenarios: YamlScenario[];
|
|
@@ -19,7 +21,7 @@ export interface YamlScenario {
|
|
|
19
21
|
timeout?: number;
|
|
20
22
|
steps: Step[];
|
|
21
23
|
}
|
|
22
|
-
export type Step = (StubStep | RequestStep | InsertStep | SqlStep | DbStep | ReceivedStep | ChangesStep | CheckpointStep | LogStep) & {
|
|
24
|
+
export type Step = (StubStep | RequestStep | InsertStep | SqlStep | DbStep | ReceivedStep | ChangesStep | CheckpointStep | LogStep | SnapshotStep | MailStep) & {
|
|
23
25
|
line: number;
|
|
24
26
|
name?: string;
|
|
25
27
|
};
|
|
@@ -55,8 +57,11 @@ export interface RequestStep {
|
|
|
55
57
|
form?: Record<string, unknown>;
|
|
56
58
|
body?: string;
|
|
57
59
|
follow?: boolean;
|
|
60
|
+
/** Send the request this many times at once. `expect` applies to every response; `statuses` counts them. */
|
|
61
|
+
concurrency?: number;
|
|
58
62
|
expect?: {
|
|
59
63
|
status?: number;
|
|
64
|
+
statuses?: Record<string, number>;
|
|
60
65
|
headers?: Record<string, unknown>;
|
|
61
66
|
json?: unknown;
|
|
62
67
|
text?: unknown;
|
|
@@ -123,6 +128,30 @@ export interface LogStep {
|
|
|
123
128
|
export interface CheckpointStep {
|
|
124
129
|
checkpoint: true;
|
|
125
130
|
}
|
|
131
|
+
/** Compare the scenario's trace (requests, stub calls, database changes) with its stored snapshot. */
|
|
132
|
+
export interface SnapshotStep {
|
|
133
|
+
snapshot: true;
|
|
134
|
+
/** Keys whose values are masked in addition to dates and UUIDs. */
|
|
135
|
+
mask?: string[];
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* The app sent mail matching `mail:` (strings are exact addresses for to/from and substrings
|
|
139
|
+
* otherwise; `{ $regex }` also works). Waits up to `within` ms (default 5000). `capture` reads
|
|
140
|
+
* the last match: `subject`, `text`, `links.0`.
|
|
141
|
+
*/
|
|
142
|
+
export interface MailStep {
|
|
143
|
+
mail: {
|
|
144
|
+
to?: unknown;
|
|
145
|
+
from?: unknown;
|
|
146
|
+
subject?: unknown;
|
|
147
|
+
text?: unknown;
|
|
148
|
+
html?: unknown;
|
|
149
|
+
};
|
|
150
|
+
/** Exact number of matching messages. Default: at least one. */
|
|
151
|
+
times?: number;
|
|
152
|
+
within?: number;
|
|
153
|
+
capture?: Record<string, string>;
|
|
154
|
+
}
|
|
126
155
|
export declare class YamlScenarioError extends Error {
|
|
127
156
|
}
|
|
128
157
|
export declare function parseScenarioFile(text: string, file: string): YamlFile;
|
package/dist/yaml.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { isMap, isSeq, LineCounter, parseDocument } from "yaml";
|
|
2
2
|
const KINDS = {
|
|
3
3
|
stub: ["on", "when", "reply", "sequence", "networkError", "times", "delay"],
|
|
4
|
-
request: ["headers", "query", "json", "form", "body", "follow", "expect", "capture"],
|
|
4
|
+
request: ["headers", "query", "json", "form", "body", "follow", "concurrency", "expect", "capture"],
|
|
5
5
|
insert: ["rows", "capture"],
|
|
6
6
|
sql: ["params", "expect", "capture", "within"],
|
|
7
7
|
db: ["where", "orderBy", "expect", "capture", "within"],
|
|
@@ -9,17 +9,22 @@ const KINDS = {
|
|
|
9
9
|
changes: ["within"],
|
|
10
10
|
log: ["from", "within"],
|
|
11
11
|
checkpoint: [],
|
|
12
|
+
snapshot: ["mask"],
|
|
13
|
+
mail: ["times", "within", "capture"],
|
|
12
14
|
};
|
|
13
15
|
const EXPECT_KEYS = {
|
|
14
|
-
request: ["status", "headers", "json", "text"],
|
|
16
|
+
request: ["status", "statuses", "headers", "json", "text"],
|
|
15
17
|
sql: ["rows", "count"],
|
|
16
18
|
db: ["rows", "count"],
|
|
17
19
|
};
|
|
18
20
|
const CONDITION_KEYS = ["query", "headers", "json", "body"];
|
|
21
|
+
const MAIL_KEYS = ["to", "from", "subject", "text", "html"];
|
|
19
22
|
const CHANGE_KEYS = ["inserted", "updated", "deleted"];
|
|
20
23
|
const RESPONSE_KEYS = ["status", "headers", "body"];
|
|
21
24
|
const SCENARIO_KEYS = ["name", "steps", "each", "skip", "only", "timeout"];
|
|
22
25
|
const CALL = /^([A-Za-z]+|\*)\s+(\/\S*)$/;
|
|
26
|
+
/** A request may also go to a captured URL of the app, e.g. a link from a mail: `GET {{link}}`. */
|
|
27
|
+
const REQUEST = /^[A-Za-z]+\s+(\/\S*|\{\{[^}]+\}\}\S*)$/;
|
|
23
28
|
export class YamlScenarioError extends Error {
|
|
24
29
|
}
|
|
25
30
|
export function parseScenarioFile(text, file) {
|
|
@@ -107,10 +112,24 @@ function parseStep(node, fail, lineOf) {
|
|
|
107
112
|
}
|
|
108
113
|
}
|
|
109
114
|
}
|
|
115
|
+
else if (kind === "mail") {
|
|
116
|
+
const filter = raw.mail;
|
|
117
|
+
if (!filter || typeof filter !== "object" || Array.isArray(filter))
|
|
118
|
+
fail(at(kind), "`mail:` must be a mapping such as { to: alice@example.com, subject: Welcome } ({} for any)");
|
|
119
|
+
for (const k of Object.keys(filter))
|
|
120
|
+
if (!MAIL_KEYS.includes(k))
|
|
121
|
+
fail(at(kind), `unknown key "${k}" in mail (allowed: ${MAIL_KEYS.join(", ")})`);
|
|
122
|
+
}
|
|
110
123
|
else if (kind === "checkpoint") {
|
|
111
124
|
if (raw.checkpoint !== true)
|
|
112
125
|
fail(at(kind), "use `checkpoint: true`");
|
|
113
126
|
}
|
|
127
|
+
else if (kind === "snapshot") {
|
|
128
|
+
if (raw.snapshot !== true)
|
|
129
|
+
fail(at(kind), "use `snapshot: true`");
|
|
130
|
+
if (raw.mask !== undefined && !(Array.isArray(raw.mask) && raw.mask.every((k) => typeof k === "string")))
|
|
131
|
+
fail(at("mask"), "`mask:` must be a list of keys, e.g. [token]");
|
|
132
|
+
}
|
|
114
133
|
else if (typeof raw[kind] !== "string" || !raw[kind])
|
|
115
134
|
fail(at(kind), `\`${kind}:\` must be a non-empty string`);
|
|
116
135
|
const keysOf = (key, allowedKeys) => {
|
|
@@ -124,8 +143,8 @@ function parseStep(node, fail, lineOf) {
|
|
|
124
143
|
fail(at(key), `unknown key "${k}" in ${key} (allowed: ${allowedKeys.join(", ")})`);
|
|
125
144
|
}
|
|
126
145
|
};
|
|
127
|
-
const call = (key) => {
|
|
128
|
-
if (raw[key] !== undefined && !
|
|
146
|
+
const call = (key, pattern = CALL) => {
|
|
147
|
+
if (raw[key] !== undefined && !pattern.test(String(raw[key])))
|
|
129
148
|
fail(at(key), `\`${key}\` must look like "POST /path", got "${raw[key]}"`);
|
|
130
149
|
};
|
|
131
150
|
if (raw.within !== undefined && (typeof raw.within !== "number" || raw.within <= 0))
|
|
@@ -159,9 +178,23 @@ function parseStep(node, fail, lineOf) {
|
|
|
159
178
|
break;
|
|
160
179
|
}
|
|
161
180
|
case "request":
|
|
162
|
-
call("request");
|
|
181
|
+
call("request", REQUEST);
|
|
163
182
|
if (["json", "form", "body"].filter((k) => raw[k] !== undefined).length > 1)
|
|
164
183
|
fail(node, "use only one of json / form / body");
|
|
184
|
+
if (raw.concurrency !== undefined && !(Number.isInteger(raw.concurrency) && raw.concurrency >= 1))
|
|
185
|
+
fail(at("concurrency"), "`concurrency` must be a positive whole number");
|
|
186
|
+
if (raw.concurrency !== undefined && raw.capture !== undefined)
|
|
187
|
+
fail(at("capture"), "`capture` can't be used with `concurrency`: there is more than one response");
|
|
188
|
+
{
|
|
189
|
+
const statuses = raw.expect?.statuses;
|
|
190
|
+
if (statuses !== undefined) {
|
|
191
|
+
if (raw.concurrency === undefined)
|
|
192
|
+
fail(at("expect"), "`expect.statuses` needs `concurrency`");
|
|
193
|
+
if (!statuses || typeof statuses !== "object" || Object.entries(statuses).some(([k, v]) => !/^\d{3}$/.test(k) || !Number.isInteger(v))) {
|
|
194
|
+
fail(at("expect"), "`expect.statuses` maps status codes to counts, e.g. { 201: 1, 409: 9 }");
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
165
198
|
break;
|
|
166
199
|
case "insert":
|
|
167
200
|
if (!raw.rows || typeof raw.rows !== "object")
|
|
@@ -181,6 +214,9 @@ function parseStep(node, fail, lineOf) {
|
|
|
181
214
|
call("call");
|
|
182
215
|
number("times");
|
|
183
216
|
break;
|
|
217
|
+
case "mail":
|
|
218
|
+
number("times");
|
|
219
|
+
break;
|
|
184
220
|
}
|
|
185
221
|
return { ...raw, line: lineOf(node) };
|
|
186
222
|
}
|
package/package.json
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "slicetest",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Framework-agnostic slice tests: drive a real app process over HTTP with a real Postgres and stubbed outbound APIs, checked against OpenAPI contracts.",
|
|
3
|
+
"version": "0.4.0",
|
|
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",
|
|
7
7
|
"integration-testing",
|
|
8
8
|
"slice-test",
|
|
9
9
|
"api-testing",
|
|
10
10
|
"postgres",
|
|
11
|
+
"mysql",
|
|
12
|
+
"sqlite",
|
|
11
13
|
"stub",
|
|
12
14
|
"mock-server",
|
|
13
15
|
"vitest",
|
|
@@ -54,11 +56,13 @@
|
|
|
54
56
|
"build": "tsc -p tsconfig.build.json",
|
|
55
57
|
"typecheck": "tsc --noEmit",
|
|
56
58
|
"test": "vitest run",
|
|
57
|
-
"test:dist": "npm run build && tsc -p examples/tsconfig.dist.json && vitest run --config examples/vitest.dist.config.ts && node dist/cli.js --config examples/slicetest.config.yaml",
|
|
59
|
+
"test:dist": "npm run build && tsc -p examples/tsconfig.dist.json && vitest run --config examples/vitest.dist.config.ts && node dist/cli.js --config examples/slicetest.config.yaml && node test/record-dist.mjs",
|
|
58
60
|
"prepublishOnly": "npm run typecheck && npm run build"
|
|
59
61
|
},
|
|
60
62
|
"peerDependencies": {
|
|
61
|
-
"vitest": ">=5"
|
|
63
|
+
"vitest": ">=5",
|
|
64
|
+
"mysql2": ">=3",
|
|
65
|
+
"@testcontainers/mysql": ">=11"
|
|
62
66
|
},
|
|
63
67
|
"dependencies": {
|
|
64
68
|
"@testcontainers/postgresql": "^12.2.0",
|
|
@@ -69,12 +73,22 @@
|
|
|
69
73
|
"yaml": "^2.9.1"
|
|
70
74
|
},
|
|
71
75
|
"devDependencies": {
|
|
76
|
+
"@testcontainers/mysql": "^12.2.0",
|
|
72
77
|
"@types/node": "^26.6.3",
|
|
73
78
|
"@types/pg": "^8.23.1",
|
|
79
|
+
"mysql2": "^3.24.5",
|
|
74
80
|
"typescript": "^7.0.2",
|
|
75
81
|
"vitest": "^5.0.2"
|
|
76
82
|
},
|
|
77
83
|
"bin": {
|
|
78
84
|
"slicetest": "dist/cli.js"
|
|
85
|
+
},
|
|
86
|
+
"peerDependenciesMeta": {
|
|
87
|
+
"mysql2": {
|
|
88
|
+
"optional": true
|
|
89
|
+
},
|
|
90
|
+
"@testcontainers/mysql": {
|
|
91
|
+
"optional": true
|
|
92
|
+
}
|
|
79
93
|
}
|
|
80
94
|
}
|
|
@@ -180,6 +180,11 @@
|
|
|
180
180
|
"follow": {
|
|
181
181
|
"type": "boolean"
|
|
182
182
|
},
|
|
183
|
+
"concurrency": {
|
|
184
|
+
"type": "integer",
|
|
185
|
+
"minimum": 1,
|
|
186
|
+
"description": "Send the request this many times at once, to provoke races. `expect` applies to every response"
|
|
187
|
+
},
|
|
183
188
|
"expect": {
|
|
184
189
|
"type": "object",
|
|
185
190
|
"additionalProperties": false,
|
|
@@ -196,7 +201,18 @@
|
|
|
196
201
|
"json": {
|
|
197
202
|
"description": "Subset of the response JSON"
|
|
198
203
|
},
|
|
199
|
-
"text": {}
|
|
204
|
+
"text": {},
|
|
205
|
+
"statuses": {
|
|
206
|
+
"type": "object",
|
|
207
|
+
"propertyNames": {
|
|
208
|
+
"pattern": "^\\d{3}$"
|
|
209
|
+
},
|
|
210
|
+
"additionalProperties": {
|
|
211
|
+
"type": "integer",
|
|
212
|
+
"minimum": 0
|
|
213
|
+
},
|
|
214
|
+
"description": "With `concurrency`: how many responses have each status, e.g. { 201: 1, 409: 9 }"
|
|
215
|
+
}
|
|
200
216
|
}
|
|
201
217
|
},
|
|
202
218
|
"capture": {
|
|
@@ -501,6 +517,162 @@
|
|
|
501
517
|
"description": "Label shown when this step fails"
|
|
502
518
|
}
|
|
503
519
|
}
|
|
520
|
+
},
|
|
521
|
+
{
|
|
522
|
+
"type": "object",
|
|
523
|
+
"required": [
|
|
524
|
+
"snapshot"
|
|
525
|
+
],
|
|
526
|
+
"additionalProperties": false,
|
|
527
|
+
"properties": {
|
|
528
|
+
"snapshot": {
|
|
529
|
+
"const": true,
|
|
530
|
+
"description": "Compare this scenario's trace so far (requests to the app, calls to stubs, database changes; dates and UUIDs masked) with its stored snapshot. Update with vitest -u"
|
|
531
|
+
},
|
|
532
|
+
"mask": {
|
|
533
|
+
"type": "array",
|
|
534
|
+
"items": {
|
|
535
|
+
"type": "string"
|
|
536
|
+
},
|
|
537
|
+
"description": "Keys whose values are masked too, e.g. [token]"
|
|
538
|
+
},
|
|
539
|
+
"name": {
|
|
540
|
+
"type": "string",
|
|
541
|
+
"description": "Label shown when this step fails"
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
},
|
|
545
|
+
{
|
|
546
|
+
"type": "object",
|
|
547
|
+
"required": [
|
|
548
|
+
"mail"
|
|
549
|
+
],
|
|
550
|
+
"additionalProperties": false,
|
|
551
|
+
"properties": {
|
|
552
|
+
"mail": {
|
|
553
|
+
"type": "object",
|
|
554
|
+
"additionalProperties": false,
|
|
555
|
+
"description": "The app sent mail matching these fields (needs `mail: true` in the config). to/from are exact addresses; subject/text/html are substrings. {} matches any message",
|
|
556
|
+
"properties": {
|
|
557
|
+
"to": {
|
|
558
|
+
"oneOf": [
|
|
559
|
+
{
|
|
560
|
+
"type": "string"
|
|
561
|
+
},
|
|
562
|
+
{
|
|
563
|
+
"type": "object",
|
|
564
|
+
"required": [
|
|
565
|
+
"$regex"
|
|
566
|
+
],
|
|
567
|
+
"additionalProperties": false,
|
|
568
|
+
"properties": {
|
|
569
|
+
"$regex": {
|
|
570
|
+
"type": "string"
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
]
|
|
575
|
+
},
|
|
576
|
+
"from": {
|
|
577
|
+
"oneOf": [
|
|
578
|
+
{
|
|
579
|
+
"type": "string"
|
|
580
|
+
},
|
|
581
|
+
{
|
|
582
|
+
"type": "object",
|
|
583
|
+
"required": [
|
|
584
|
+
"$regex"
|
|
585
|
+
],
|
|
586
|
+
"additionalProperties": false,
|
|
587
|
+
"properties": {
|
|
588
|
+
"$regex": {
|
|
589
|
+
"type": "string"
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
]
|
|
594
|
+
},
|
|
595
|
+
"subject": {
|
|
596
|
+
"oneOf": [
|
|
597
|
+
{
|
|
598
|
+
"type": "string"
|
|
599
|
+
},
|
|
600
|
+
{
|
|
601
|
+
"type": "object",
|
|
602
|
+
"required": [
|
|
603
|
+
"$regex"
|
|
604
|
+
],
|
|
605
|
+
"additionalProperties": false,
|
|
606
|
+
"properties": {
|
|
607
|
+
"$regex": {
|
|
608
|
+
"type": "string"
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
]
|
|
613
|
+
},
|
|
614
|
+
"text": {
|
|
615
|
+
"oneOf": [
|
|
616
|
+
{
|
|
617
|
+
"type": "string"
|
|
618
|
+
},
|
|
619
|
+
{
|
|
620
|
+
"type": "object",
|
|
621
|
+
"required": [
|
|
622
|
+
"$regex"
|
|
623
|
+
],
|
|
624
|
+
"additionalProperties": false,
|
|
625
|
+
"properties": {
|
|
626
|
+
"$regex": {
|
|
627
|
+
"type": "string"
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
]
|
|
632
|
+
},
|
|
633
|
+
"html": {
|
|
634
|
+
"oneOf": [
|
|
635
|
+
{
|
|
636
|
+
"type": "string"
|
|
637
|
+
},
|
|
638
|
+
{
|
|
639
|
+
"type": "object",
|
|
640
|
+
"required": [
|
|
641
|
+
"$regex"
|
|
642
|
+
],
|
|
643
|
+
"additionalProperties": false,
|
|
644
|
+
"properties": {
|
|
645
|
+
"$regex": {
|
|
646
|
+
"type": "string"
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
]
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
},
|
|
654
|
+
"times": {
|
|
655
|
+
"type": "integer",
|
|
656
|
+
"minimum": 0,
|
|
657
|
+
"description": "Exact number of matching messages. Default: at least one"
|
|
658
|
+
},
|
|
659
|
+
"within": {
|
|
660
|
+
"type": "integer",
|
|
661
|
+
"minimum": 1,
|
|
662
|
+
"description": "Milliseconds to wait. Default 5000"
|
|
663
|
+
},
|
|
664
|
+
"capture": {
|
|
665
|
+
"type": "object",
|
|
666
|
+
"additionalProperties": {
|
|
667
|
+
"type": "string"
|
|
668
|
+
},
|
|
669
|
+
"description": "Values of the last matching message: subject, text, html, from, to.0, links.0"
|
|
670
|
+
},
|
|
671
|
+
"name": {
|
|
672
|
+
"type": "string",
|
|
673
|
+
"description": "Label shown when this step fails"
|
|
674
|
+
}
|
|
675
|
+
}
|
|
504
676
|
}
|
|
505
677
|
]
|
|
506
678
|
},
|