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/yaml.js ADDED
@@ -0,0 +1,151 @@
1
+ import { isMap, isSeq, LineCounter, parseDocument } from "yaml";
2
+ const KINDS = {
3
+ stub: ["on", "when", "reply", "sequence", "networkError", "times", "delay"],
4
+ request: ["headers", "query", "json", "form", "body", "follow", "expect", "capture"],
5
+ insert: ["rows", "capture"],
6
+ sql: ["params", "expect", "capture"],
7
+ db: ["where", "orderBy", "expect", "capture"],
8
+ received: ["call", "when", "times"],
9
+ };
10
+ const EXPECT_KEYS = {
11
+ request: ["status", "headers", "json", "text"],
12
+ sql: ["rows", "count"],
13
+ db: ["rows", "count"],
14
+ };
15
+ const CONDITION_KEYS = ["query", "headers", "json", "body"];
16
+ const RESPONSE_KEYS = ["status", "headers", "body"];
17
+ const SCENARIO_KEYS = ["name", "steps", "each", "skip", "only", "timeout"];
18
+ const CALL = /^([A-Za-z]+|\*)\s+(\/\S*)$/;
19
+ export class YamlScenarioError extends Error {
20
+ }
21
+ export function parseScenarioFile(text, file) {
22
+ const lines = new LineCounter();
23
+ const doc = parseDocument(text, { lineCounter: lines, prettyErrors: true });
24
+ const [syntax] = doc.errors;
25
+ if (syntax)
26
+ throw new YamlScenarioError(`${file}:${syntax.linePos?.[0]?.line ?? "?"}: ${syntax.message}`);
27
+ const lineOf = (node) => (node && typeof node === "object" && "range" in node && node.range ? lines.linePos(node.range[0]).line : 0);
28
+ const fail = (node, msg) => {
29
+ throw new YamlScenarioError(`${file}:${lineOf(node)}: ${msg}`);
30
+ };
31
+ const root = doc.contents;
32
+ if (!isMap(root))
33
+ return fail(root, "expected a mapping with a `scenarios:` list");
34
+ const top = doc.toJS();
35
+ for (const key of Object.keys(top))
36
+ if (key !== "scenarios" && key !== "setup")
37
+ fail(root, `unknown top-level key "${key}" (expected scenarios, setup)`);
38
+ const stepsOf = (seqNode, what) => {
39
+ if (!isSeq(seqNode))
40
+ return fail(seqNode, `${what} must be a list of steps`);
41
+ return seqNode.items.map((item) => parseStep(item, fail, lineOf));
42
+ };
43
+ const scenariosNode = root.get("scenarios", true);
44
+ if (!isSeq(scenariosNode) || scenariosNode.items.length === 0)
45
+ return fail(scenariosNode ?? root, "`scenarios:` must be a non-empty list");
46
+ const setupNode = root.get("setup", true);
47
+ return {
48
+ file,
49
+ setup: setupNode ? stepsOf(setupNode, "setup") : [],
50
+ scenarios: scenariosNode.items.map((node) => {
51
+ if (!isMap(node))
52
+ return fail(node, "each scenario must be a mapping with `name` and `steps`");
53
+ const raw = node.toJSON();
54
+ for (const key of Object.keys(raw))
55
+ if (!SCENARIO_KEYS.includes(key))
56
+ fail(node, `unknown scenario key "${key}" (expected ${SCENARIO_KEYS.join(", ")})`);
57
+ if (typeof raw.name !== "string" || !raw.name)
58
+ fail(node, "scenario needs a `name`");
59
+ if (raw.each !== undefined && (!Array.isArray(raw.each) || raw.each.some((r) => !r || typeof r !== "object"))) {
60
+ fail(node.get("each", true), "`each` must be a list of mappings");
61
+ }
62
+ if (raw.timeout !== undefined && typeof raw.timeout !== "number")
63
+ fail(node, "`timeout` must be a number of milliseconds");
64
+ return {
65
+ name: raw.name,
66
+ line: lineOf(node),
67
+ each: raw.each,
68
+ skip: raw.skip === true,
69
+ only: raw.only === true,
70
+ timeout: raw.timeout,
71
+ steps: stepsOf(node.get("steps", true), `scenario "${raw.name}": steps`),
72
+ };
73
+ }),
74
+ };
75
+ }
76
+ function parseStep(node, fail, lineOf) {
77
+ if (!isMap(node))
78
+ return fail(node, "a step must be a mapping, e.g. `- request: GET /health`");
79
+ const raw = node.toJSON();
80
+ const kinds = Object.keys(KINDS).filter((k) => k in raw);
81
+ if (kinds.length !== 1) {
82
+ fail(node, kinds.length === 0 ? `a step needs one of: ${Object.keys(KINDS).join(", ")}` : `a step can only be one of ${kinds.join(" / ")}`);
83
+ }
84
+ const kind = kinds[0];
85
+ const at = (key) => node.get(key, true) ?? node;
86
+ const allowed = new Set([kind, "name", ...KINDS[kind]]);
87
+ for (const key of Object.keys(raw)) {
88
+ if (!allowed.has(key))
89
+ fail(at(key), `unknown key "${key}" in a ${kind} step (allowed: ${[...allowed].join(", ")})`);
90
+ }
91
+ if (typeof raw[kind] !== "string" || !raw[kind])
92
+ fail(at(kind), `\`${kind}:\` must be a non-empty string`);
93
+ const keysOf = (key, allowedKeys) => {
94
+ const v = raw[key];
95
+ if (v === undefined)
96
+ return;
97
+ if (!v || typeof v !== "object" || Array.isArray(v))
98
+ fail(at(key), `\`${key}\` must be a mapping`);
99
+ for (const k of Object.keys(v)) {
100
+ if (!allowedKeys.includes(k))
101
+ fail(at(key), `unknown key "${k}" in ${key} (allowed: ${allowedKeys.join(", ")})`);
102
+ }
103
+ };
104
+ const call = (key) => {
105
+ if (raw[key] !== undefined && !CALL.test(String(raw[key])))
106
+ fail(at(key), `\`${key}\` must look like "POST /path", got "${raw[key]}"`);
107
+ };
108
+ const number = (key) => {
109
+ if (raw[key] !== undefined && (typeof raw[key] !== "number" || raw[key] < 0))
110
+ fail(at(key), `\`${key}\` must be a non-negative number`);
111
+ };
112
+ if (raw.capture !== undefined) {
113
+ keysOf("capture", Object.keys(raw.capture));
114
+ for (const [k, v] of Object.entries(raw.capture))
115
+ if (typeof v !== "string")
116
+ fail(at("capture"), `capture "${k}" must be a path such as json.id`);
117
+ }
118
+ if (kind in EXPECT_KEYS)
119
+ keysOf("expect", EXPECT_KEYS[kind]);
120
+ keysOf("when", CONDITION_KEYS);
121
+ switch (kind) {
122
+ case "stub": {
123
+ if (raw.on === undefined)
124
+ fail(node, "a stub step needs `on`, e.g. `on: POST /hook`");
125
+ call("on");
126
+ const answers = ["reply", "sequence", "networkError"].filter((k) => raw[k] !== undefined);
127
+ if (answers.length !== 1)
128
+ fail(node, "a stub step needs exactly one of reply / sequence / networkError");
129
+ keysOf("reply", RESPONSE_KEYS);
130
+ if (raw.sequence !== undefined && (!Array.isArray(raw.sequence) || raw.sequence.length === 0))
131
+ fail(at("sequence"), "`sequence` must be a non-empty list of responses");
132
+ number("times");
133
+ number("delay");
134
+ break;
135
+ }
136
+ case "request":
137
+ call("request");
138
+ if (["json", "form", "body"].filter((k) => raw[k] !== undefined).length > 1)
139
+ fail(node, "use only one of json / form / body");
140
+ break;
141
+ case "insert":
142
+ if (!raw.rows || typeof raw.rows !== "object")
143
+ fail(at("rows"), "an insert step needs `rows` (a mapping or a list of mappings)");
144
+ break;
145
+ case "received":
146
+ call("call");
147
+ number("times");
148
+ break;
149
+ }
150
+ return { ...raw, line: lineOf(node) };
151
+ }
package/package.json ADDED
@@ -0,0 +1,76 @@
1
+ {
2
+ "name": "slicetest",
3
+ "version": "0.1.0",
4
+ "description": "Framework-agnostic slice tests: drive a real app process over HTTP with a real Postgres and stubbed outbound APIs.",
5
+ "keywords": [
6
+ "testing",
7
+ "integration-testing",
8
+ "slice-test",
9
+ "api-testing",
10
+ "postgres",
11
+ "stub",
12
+ "mock-server",
13
+ "vitest",
14
+ "vitest-plugin",
15
+ "yaml",
16
+ "testcontainers",
17
+ "language-agnostic"
18
+ ],
19
+ "author": "pon-bok",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/revo1290/slicetest.git"
23
+ },
24
+ "homepage": "https://github.com/revo1290/slicetest#readme",
25
+ "bugs": {
26
+ "url": "https://github.com/revo1290/slicetest/issues"
27
+ },
28
+ "type": "module",
29
+ "license": "MIT",
30
+ "exports": {
31
+ ".": {
32
+ "types": "./dist/index.d.ts",
33
+ "default": "./dist/index.js"
34
+ },
35
+ "./vitest": {
36
+ "types": "./dist/vitest.d.ts",
37
+ "default": "./dist/vitest.js"
38
+ },
39
+ "./yaml": {
40
+ "types": "./dist/yaml.d.ts",
41
+ "default": "./dist/yaml.js"
42
+ }
43
+ },
44
+ "files": [
45
+ "dist",
46
+ "schema"
47
+ ],
48
+ "engines": {
49
+ "node": ">=20"
50
+ },
51
+ "scripts": {
52
+ "build": "tsc -p tsconfig.build.json",
53
+ "typecheck": "tsc --noEmit",
54
+ "test": "vitest run",
55
+ "test:dist": "npm run build && vitest run --config examples/vitest.dist.config.ts && node dist/cli.js --config examples/slicetest.config.yaml",
56
+ "prepublishOnly": "npm run typecheck && npm run build"
57
+ },
58
+ "peerDependencies": {
59
+ "vitest": ">=5"
60
+ },
61
+ "dependencies": {
62
+ "@testcontainers/postgresql": "^12.2.0",
63
+ "pg": "^8.23.0",
64
+ "testcontainers": "^12.2.0",
65
+ "yaml": "^2.9.1"
66
+ },
67
+ "devDependencies": {
68
+ "@types/node": "^26.6.3",
69
+ "@types/pg": "^8.23.1",
70
+ "typescript": "^7.0.2",
71
+ "vitest": "^5.0.2"
72
+ },
73
+ "bin": {
74
+ "slicetest": "dist/cli.js"
75
+ }
76
+ }
@@ -0,0 +1,405 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://unpkg.com/slicetest/schema/scenario.schema.json",
4
+ "title": "slicetest YAML scenarios",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": [
8
+ "scenarios"
9
+ ],
10
+ "properties": {
11
+ "setup": {
12
+ "type": "array",
13
+ "description": "Steps run at the start of every scenario",
14
+ "items": {
15
+ "$ref": "#/$defs/step"
16
+ }
17
+ },
18
+ "scenarios": {
19
+ "type": "array",
20
+ "minItems": 1,
21
+ "items": {
22
+ "type": "object",
23
+ "additionalProperties": false,
24
+ "required": [
25
+ "name",
26
+ "steps"
27
+ ],
28
+ "properties": {
29
+ "name": {
30
+ "type": "string",
31
+ "description": "May use {{field}} from `each` rows"
32
+ },
33
+ "each": {
34
+ "type": "array",
35
+ "items": {
36
+ "type": "object"
37
+ },
38
+ "description": "Run once per row; row fields become variables"
39
+ },
40
+ "skip": {
41
+ "type": "boolean"
42
+ },
43
+ "only": {
44
+ "type": "boolean"
45
+ },
46
+ "timeout": {
47
+ "type": "integer"
48
+ },
49
+ "steps": {
50
+ "type": "array",
51
+ "items": {
52
+ "$ref": "#/$defs/step"
53
+ }
54
+ }
55
+ }
56
+ }
57
+ }
58
+ },
59
+ "$defs": {
60
+ "step": {
61
+ "oneOf": [
62
+ {
63
+ "type": "object",
64
+ "required": [
65
+ "stub",
66
+ "on"
67
+ ],
68
+ "additionalProperties": false,
69
+ "properties": {
70
+ "stub": {
71
+ "type": "string",
72
+ "description": "Stub name (declared in `stubs`). Registers a route on it."
73
+ },
74
+ "name": {
75
+ "type": "string",
76
+ "description": "Label shown when this step fails"
77
+ },
78
+ "on": {
79
+ "type": "string",
80
+ "pattern": "^([A-Za-z]+|\\*)\\s+/\\S*$",
81
+ "description": "METHOD /path, e.g. POST /polls"
82
+ },
83
+ "when": {
84
+ "type": "object",
85
+ "additionalProperties": false,
86
+ "properties": {
87
+ "query": {
88
+ "type": "object"
89
+ },
90
+ "headers": {
91
+ "type": "object"
92
+ },
93
+ "json": {
94
+ "description": "Subset of the request JSON"
95
+ },
96
+ "body": {}
97
+ }
98
+ },
99
+ "reply": {
100
+ "type": "object",
101
+ "additionalProperties": false,
102
+ "properties": {
103
+ "status": {
104
+ "type": "integer"
105
+ },
106
+ "headers": {
107
+ "type": "object",
108
+ "additionalProperties": {
109
+ "type": "string"
110
+ }
111
+ },
112
+ "body": {
113
+ "description": "String, or anything else to send as JSON. May use {{call.params.x}}, {{call.json.x}}, {{call.query.x}}"
114
+ }
115
+ }
116
+ },
117
+ "sequence": {
118
+ "type": "array",
119
+ "items": {
120
+ "type": "object",
121
+ "additionalProperties": false,
122
+ "properties": {
123
+ "status": {
124
+ "type": "integer"
125
+ },
126
+ "headers": {
127
+ "type": "object",
128
+ "additionalProperties": {
129
+ "type": "string"
130
+ }
131
+ },
132
+ "body": {
133
+ "description": "String, or anything else to send as JSON. May use {{call.params.x}}, {{call.json.x}}, {{call.query.x}}"
134
+ }
135
+ }
136
+ },
137
+ "minItems": 1
138
+ },
139
+ "networkError": {
140
+ "const": true
141
+ },
142
+ "times": {
143
+ "type": "integer",
144
+ "minimum": 0
145
+ },
146
+ "delay": {
147
+ "type": "integer",
148
+ "minimum": 0
149
+ }
150
+ }
151
+ },
152
+ {
153
+ "type": "object",
154
+ "required": [
155
+ "request"
156
+ ],
157
+ "additionalProperties": false,
158
+ "properties": {
159
+ "request": {
160
+ "type": "string",
161
+ "description": "Request to the app: METHOD /path"
162
+ },
163
+ "name": {
164
+ "type": "string",
165
+ "description": "Label shown when this step fails"
166
+ },
167
+ "headers": {
168
+ "type": "object"
169
+ },
170
+ "query": {
171
+ "type": "object"
172
+ },
173
+ "json": {},
174
+ "form": {
175
+ "type": "object"
176
+ },
177
+ "body": {
178
+ "type": "string"
179
+ },
180
+ "follow": {
181
+ "type": "boolean"
182
+ },
183
+ "expect": {
184
+ "type": "object",
185
+ "additionalProperties": false,
186
+ "properties": {
187
+ "status": {
188
+ "type": [
189
+ "integer",
190
+ "string"
191
+ ]
192
+ },
193
+ "headers": {
194
+ "type": "object"
195
+ },
196
+ "json": {
197
+ "description": "Subset of the response JSON"
198
+ },
199
+ "text": {}
200
+ }
201
+ },
202
+ "capture": {
203
+ "type": "object",
204
+ "description": "Save values as variables for later steps, e.g. { pollId: json.id }",
205
+ "additionalProperties": {
206
+ "type": "string"
207
+ }
208
+ }
209
+ }
210
+ },
211
+ {
212
+ "type": "object",
213
+ "required": [
214
+ "insert",
215
+ "rows"
216
+ ],
217
+ "additionalProperties": false,
218
+ "properties": {
219
+ "insert": {
220
+ "type": "string",
221
+ "description": "Table to insert into"
222
+ },
223
+ "name": {
224
+ "type": "string",
225
+ "description": "Label shown when this step fails"
226
+ },
227
+ "rows": {
228
+ "type": [
229
+ "object",
230
+ "array"
231
+ ]
232
+ },
233
+ "capture": {
234
+ "type": "object",
235
+ "description": "Save values as variables for later steps, e.g. { pollId: json.id }",
236
+ "additionalProperties": {
237
+ "type": "string"
238
+ }
239
+ }
240
+ }
241
+ },
242
+ {
243
+ "type": "object",
244
+ "required": [
245
+ "sql"
246
+ ],
247
+ "additionalProperties": false,
248
+ "properties": {
249
+ "sql": {
250
+ "type": "string",
251
+ "description": "SQL to run ($1, $2 … for params)"
252
+ },
253
+ "name": {
254
+ "type": "string",
255
+ "description": "Label shown when this step fails"
256
+ },
257
+ "params": {
258
+ "type": "array"
259
+ },
260
+ "expect": {
261
+ "type": "object",
262
+ "additionalProperties": false,
263
+ "properties": {
264
+ "rows": {
265
+ "type": "array",
266
+ "description": "Rows in order; each row may omit columns"
267
+ },
268
+ "count": {
269
+ "type": "integer",
270
+ "minimum": 0
271
+ }
272
+ }
273
+ },
274
+ "capture": {
275
+ "type": "object",
276
+ "description": "Save values as variables for later steps, e.g. { pollId: json.id }",
277
+ "additionalProperties": {
278
+ "type": "string"
279
+ }
280
+ }
281
+ }
282
+ },
283
+ {
284
+ "type": "object",
285
+ "required": [
286
+ "db"
287
+ ],
288
+ "additionalProperties": false,
289
+ "properties": {
290
+ "db": {
291
+ "type": "string",
292
+ "description": "Table to read"
293
+ },
294
+ "name": {
295
+ "type": "string",
296
+ "description": "Label shown when this step fails"
297
+ },
298
+ "where": {
299
+ "type": "object",
300
+ "description": "null = IS NULL, list = IN"
301
+ },
302
+ "orderBy": {
303
+ "type": [
304
+ "string",
305
+ "array"
306
+ ]
307
+ },
308
+ "expect": {
309
+ "type": "object",
310
+ "additionalProperties": false,
311
+ "properties": {
312
+ "rows": {
313
+ "type": "array",
314
+ "description": "Rows in order; each row may omit columns"
315
+ },
316
+ "count": {
317
+ "type": "integer",
318
+ "minimum": 0
319
+ }
320
+ }
321
+ },
322
+ "capture": {
323
+ "type": "object",
324
+ "description": "Save values as variables for later steps, e.g. { pollId: json.id }",
325
+ "additionalProperties": {
326
+ "type": "string"
327
+ }
328
+ }
329
+ }
330
+ },
331
+ {
332
+ "type": "object",
333
+ "required": [
334
+ "received"
335
+ ],
336
+ "additionalProperties": false,
337
+ "properties": {
338
+ "received": {
339
+ "type": "string",
340
+ "description": "Stub name. Asserts the app called it."
341
+ },
342
+ "name": {
343
+ "type": "string",
344
+ "description": "Label shown when this step fails"
345
+ },
346
+ "call": {
347
+ "type": "string",
348
+ "pattern": "^([A-Za-z]+|\\*)\\s+/\\S*$",
349
+ "description": "METHOD /path, e.g. POST /polls"
350
+ },
351
+ "when": {
352
+ "type": "object",
353
+ "additionalProperties": false,
354
+ "properties": {
355
+ "query": {
356
+ "type": "object"
357
+ },
358
+ "headers": {
359
+ "type": "object"
360
+ },
361
+ "json": {
362
+ "description": "Subset of the request JSON"
363
+ },
364
+ "body": {}
365
+ }
366
+ },
367
+ "times": {
368
+ "type": "integer",
369
+ "minimum": 0,
370
+ "description": "Exact count; default: at least once"
371
+ }
372
+ }
373
+ }
374
+ ]
375
+ },
376
+ "matcher": {
377
+ "type": "object",
378
+ "description": "Value matcher: {$type}, {$regex}, {$contains} or {$any}",
379
+ "minProperties": 1,
380
+ "maxProperties": 1,
381
+ "properties": {
382
+ "$type": {
383
+ "enum": [
384
+ "string",
385
+ "number",
386
+ "boolean",
387
+ "array",
388
+ "object",
389
+ "null"
390
+ ]
391
+ },
392
+ "$regex": {
393
+ "type": "string"
394
+ },
395
+ "$contains": {
396
+ "type": "string"
397
+ },
398
+ "$any": {
399
+ "const": true
400
+ }
401
+ },
402
+ "additionalProperties": true
403
+ }
404
+ }
405
+ }