slicetest 0.1.0 → 0.3.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.d.ts CHANGED
@@ -19,7 +19,7 @@ export interface YamlScenario {
19
19
  timeout?: number;
20
20
  steps: Step[];
21
21
  }
22
- export type Step = (StubStep | RequestStep | InsertStep | SqlStep | DbStep | ReceivedStep) & {
22
+ export type Step = (StubStep | RequestStep | InsertStep | SqlStep | DbStep | ReceivedStep | ChangesStep | CheckpointStep | LogStep | SnapshotStep) & {
23
23
  line: number;
24
24
  name?: string;
25
25
  };
@@ -76,6 +76,7 @@ export interface SqlStep {
76
76
  count?: number;
77
77
  };
78
78
  capture?: Record<string, string>;
79
+ within?: number;
79
80
  }
80
81
  export interface DbStep {
81
82
  db: string;
@@ -86,6 +87,8 @@ export interface DbStep {
86
87
  count?: number;
87
88
  };
88
89
  capture?: Record<string, string>;
90
+ /** Retry for up to this many milliseconds, for effects the app applies asynchronously. */
91
+ within?: number;
89
92
  }
90
93
  export interface ReceivedStep {
91
94
  received: string;
@@ -93,6 +96,38 @@ export interface ReceivedStep {
93
96
  when?: Conditions;
94
97
  /** Exact number of matching calls. Default: at least one. */
95
98
  times?: number;
99
+ within?: number;
100
+ }
101
+ /** Expected rows per table: a count, or a list of subset rows. */
102
+ export type ChangeSpec = {
103
+ inserted?: number | unknown[];
104
+ updated?: number | unknown[];
105
+ deleted?: number | unknown[];
106
+ };
107
+ /**
108
+ * The database changed exactly in these tables since the scenario started (or
109
+ * the last checkpoint). Tables that aren't listed must be unchanged.
110
+ */
111
+ export interface ChangesStep {
112
+ changes: Record<string, ChangeSpec>;
113
+ within?: number;
114
+ }
115
+ /** Wait until the app (or `from:` a service) prints a line matching `log` (a regex) during the scenario. */
116
+ export interface LogStep {
117
+ log: string;
118
+ from?: string;
119
+ /** Milliseconds to wait. Default 5000. */
120
+ within?: number;
121
+ }
122
+ /** Later `changes` steps only see what happens after this step. */
123
+ export interface CheckpointStep {
124
+ checkpoint: true;
125
+ }
126
+ /** Compare the scenario's trace (requests, stub calls, database changes) with its stored snapshot. */
127
+ export interface SnapshotStep {
128
+ snapshot: true;
129
+ /** Keys whose values are masked in addition to dates and UUIDs. */
130
+ mask?: string[];
96
131
  }
97
132
  export declare class YamlScenarioError extends Error {
98
133
  }
package/dist/yaml.js CHANGED
@@ -3,9 +3,13 @@ const KINDS = {
3
3
  stub: ["on", "when", "reply", "sequence", "networkError", "times", "delay"],
4
4
  request: ["headers", "query", "json", "form", "body", "follow", "expect", "capture"],
5
5
  insert: ["rows", "capture"],
6
- sql: ["params", "expect", "capture"],
7
- db: ["where", "orderBy", "expect", "capture"],
8
- received: ["call", "when", "times"],
6
+ sql: ["params", "expect", "capture", "within"],
7
+ db: ["where", "orderBy", "expect", "capture", "within"],
8
+ received: ["call", "when", "times", "within"],
9
+ changes: ["within"],
10
+ log: ["from", "within"],
11
+ checkpoint: [],
12
+ snapshot: ["mask"],
9
13
  };
10
14
  const EXPECT_KEYS = {
11
15
  request: ["status", "headers", "json", "text"],
@@ -13,6 +17,7 @@ const EXPECT_KEYS = {
13
17
  db: ["rows", "count"],
14
18
  };
15
19
  const CONDITION_KEYS = ["query", "headers", "json", "body"];
20
+ const CHANGE_KEYS = ["inserted", "updated", "deleted"];
16
21
  const RESPONSE_KEYS = ["status", "headers", "body"];
17
22
  const SCENARIO_KEYS = ["name", "steps", "each", "skip", "only", "timeout"];
18
23
  const CALL = /^([A-Za-z]+|\*)\s+(\/\S*)$/;
@@ -88,7 +93,32 @@ function parseStep(node, fail, lineOf) {
88
93
  if (!allowed.has(key))
89
94
  fail(at(key), `unknown key "${key}" in a ${kind} step (allowed: ${[...allowed].join(", ")})`);
90
95
  }
91
- if (typeof raw[kind] !== "string" || !raw[kind])
96
+ if (kind === "changes") {
97
+ const tables = raw.changes;
98
+ if (!tables || typeof tables !== "object" || Array.isArray(tables))
99
+ fail(at(kind), "`changes:` must map table names to { inserted, updated, deleted }");
100
+ for (const [table, spec] of Object.entries(tables)) {
101
+ if (!spec || typeof spec !== "object" || Array.isArray(spec))
102
+ fail(at(kind), `changes of "${table}" must be a mapping such as { inserted: 1 }`);
103
+ for (const [k, v] of Object.entries(spec)) {
104
+ if (!CHANGE_KEYS.includes(k))
105
+ fail(at(kind), `unknown key "${k}" in changes of "${table}" (allowed: ${CHANGE_KEYS.join(", ")})`);
106
+ if (!Array.isArray(v) && !(typeof v === "number" && v >= 0))
107
+ fail(at(kind), `${table}.${k} must be a count or a list of rows`);
108
+ }
109
+ }
110
+ }
111
+ else if (kind === "checkpoint") {
112
+ if (raw.checkpoint !== true)
113
+ fail(at(kind), "use `checkpoint: true`");
114
+ }
115
+ else if (kind === "snapshot") {
116
+ if (raw.snapshot !== true)
117
+ fail(at(kind), "use `snapshot: true`");
118
+ if (raw.mask !== undefined && !(Array.isArray(raw.mask) && raw.mask.every((k) => typeof k === "string")))
119
+ fail(at("mask"), "`mask:` must be a list of keys, e.g. [token]");
120
+ }
121
+ else if (typeof raw[kind] !== "string" || !raw[kind])
92
122
  fail(at(kind), `\`${kind}:\` must be a non-empty string`);
93
123
  const keysOf = (key, allowedKeys) => {
94
124
  const v = raw[key];
@@ -105,6 +135,8 @@ function parseStep(node, fail, lineOf) {
105
135
  if (raw[key] !== undefined && !CALL.test(String(raw[key])))
106
136
  fail(at(key), `\`${key}\` must look like "POST /path", got "${raw[key]}"`);
107
137
  };
138
+ if (raw.within !== undefined && (typeof raw.within !== "number" || raw.within <= 0))
139
+ fail(at("within"), "`within` must be a positive number of milliseconds");
108
140
  const number = (key) => {
109
141
  if (raw[key] !== undefined && (typeof raw[key] !== "number" || raw[key] < 0))
110
142
  fail(at(key), `\`${key}\` must be a non-negative number`);
@@ -142,6 +174,16 @@ function parseStep(node, fail, lineOf) {
142
174
  if (!raw.rows || typeof raw.rows !== "object")
143
175
  fail(at("rows"), "an insert step needs `rows` (a mapping or a list of mappings)");
144
176
  break;
177
+ case "log":
178
+ try {
179
+ new RegExp(raw.log);
180
+ }
181
+ catch (e) {
182
+ fail(at("log"), `\`log\` must be a regular expression: ${e.message}`);
183
+ }
184
+ if (raw.from !== undefined && typeof raw.from !== "string")
185
+ fail(at("from"), "`from` must be a service name");
186
+ break;
145
187
  case "received":
146
188
  call("call");
147
189
  number("times");
package/package.json CHANGED
@@ -1,20 +1,23 @@
1
1
  {
2
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.",
3
+ "version": "0.3.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.",
5
5
  "keywords": [
6
6
  "testing",
7
7
  "integration-testing",
8
8
  "slice-test",
9
9
  "api-testing",
10
10
  "postgres",
11
+ "mysql",
11
12
  "stub",
12
13
  "mock-server",
13
14
  "vitest",
14
15
  "vitest-plugin",
15
16
  "yaml",
16
17
  "testcontainers",
17
- "language-agnostic"
18
+ "language-agnostic",
19
+ "openapi",
20
+ "contract-testing"
18
21
  ],
19
22
  "author": "pon-bok",
20
23
  "repository": {
@@ -52,25 +55,39 @@
52
55
  "build": "tsc -p tsconfig.build.json",
53
56
  "typecheck": "tsc --noEmit",
54
57
  "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",
58
+ "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",
56
59
  "prepublishOnly": "npm run typecheck && npm run build"
57
60
  },
58
61
  "peerDependencies": {
59
- "vitest": ">=5"
62
+ "vitest": ">=5",
63
+ "mysql2": ">=3",
64
+ "@testcontainers/mysql": ">=11"
60
65
  },
61
66
  "dependencies": {
62
67
  "@testcontainers/postgresql": "^12.2.0",
68
+ "ajv": "^8.20.0",
69
+ "ajv-formats": "^3.0.1",
63
70
  "pg": "^8.23.0",
64
71
  "testcontainers": "^12.2.0",
65
72
  "yaml": "^2.9.1"
66
73
  },
67
74
  "devDependencies": {
75
+ "@testcontainers/mysql": "^12.2.0",
68
76
  "@types/node": "^26.6.3",
69
77
  "@types/pg": "^8.23.1",
78
+ "mysql2": "^3.24.5",
70
79
  "typescript": "^7.0.2",
71
80
  "vitest": "^5.0.2"
72
81
  },
73
82
  "bin": {
74
83
  "slicetest": "dist/cli.js"
84
+ },
85
+ "peerDependenciesMeta": {
86
+ "mysql2": {
87
+ "optional": true
88
+ },
89
+ "@testcontainers/mysql": {
90
+ "optional": true
91
+ }
75
92
  }
76
93
  }
@@ -277,6 +277,11 @@
277
277
  "additionalProperties": {
278
278
  "type": "string"
279
279
  }
280
+ },
281
+ "within": {
282
+ "type": "integer",
283
+ "minimum": 1,
284
+ "description": "Retry this step for up to this many milliseconds, for effects the app applies asynchronously"
280
285
  }
281
286
  }
282
287
  },
@@ -325,6 +330,11 @@
325
330
  "additionalProperties": {
326
331
  "type": "string"
327
332
  }
333
+ },
334
+ "within": {
335
+ "type": "integer",
336
+ "minimum": 1,
337
+ "description": "Retry this step for up to this many milliseconds, for effects the app applies asynchronously"
328
338
  }
329
339
  }
330
340
  },
@@ -368,6 +378,151 @@
368
378
  "type": "integer",
369
379
  "minimum": 0,
370
380
  "description": "Exact count; default: at least once"
381
+ },
382
+ "within": {
383
+ "type": "integer",
384
+ "minimum": 1,
385
+ "description": "Retry this step for up to this many milliseconds, for effects the app applies asynchronously"
386
+ }
387
+ }
388
+ },
389
+ {
390
+ "type": "object",
391
+ "required": [
392
+ "changes"
393
+ ],
394
+ "additionalProperties": false,
395
+ "properties": {
396
+ "changes": {
397
+ "type": "object",
398
+ "description": "The database changed exactly in these tables since the scenario started (or the last checkpoint). Unlisted tables must be unchanged.",
399
+ "additionalProperties": {
400
+ "type": "object",
401
+ "additionalProperties": false,
402
+ "properties": {
403
+ "inserted": {
404
+ "oneOf": [
405
+ {
406
+ "type": "integer",
407
+ "minimum": 0
408
+ },
409
+ {
410
+ "type": "array",
411
+ "items": {
412
+ "type": "object"
413
+ }
414
+ }
415
+ ],
416
+ "description": "Count, or subset rows"
417
+ },
418
+ "updated": {
419
+ "oneOf": [
420
+ {
421
+ "type": "integer",
422
+ "minimum": 0
423
+ },
424
+ {
425
+ "type": "array",
426
+ "items": {
427
+ "type": "object"
428
+ }
429
+ }
430
+ ],
431
+ "description": "Count, or subset rows (after the update)"
432
+ },
433
+ "deleted": {
434
+ "oneOf": [
435
+ {
436
+ "type": "integer",
437
+ "minimum": 0
438
+ },
439
+ {
440
+ "type": "array",
441
+ "items": {
442
+ "type": "object"
443
+ }
444
+ }
445
+ ],
446
+ "description": "Count, or subset rows"
447
+ }
448
+ }
449
+ }
450
+ },
451
+ "name": {
452
+ "type": "string",
453
+ "description": "Label shown when this step fails"
454
+ },
455
+ "within": {
456
+ "type": "integer",
457
+ "minimum": 1,
458
+ "description": "Retry this step for up to this many milliseconds, for effects the app applies asynchronously"
459
+ }
460
+ }
461
+ },
462
+ {
463
+ "type": "object",
464
+ "required": [
465
+ "checkpoint"
466
+ ],
467
+ "additionalProperties": false,
468
+ "properties": {
469
+ "checkpoint": {
470
+ "const": true,
471
+ "description": "Later `changes` steps only see what happens after this step"
472
+ },
473
+ "name": {
474
+ "type": "string",
475
+ "description": "Label shown when this step fails"
476
+ }
477
+ }
478
+ },
479
+ {
480
+ "type": "object",
481
+ "required": [
482
+ "log"
483
+ ],
484
+ "additionalProperties": false,
485
+ "properties": {
486
+ "log": {
487
+ "type": "string",
488
+ "description": "Regular expression; waits until the app (or `from:` a service) prints a matching line during this scenario"
489
+ },
490
+ "from": {
491
+ "type": "string",
492
+ "description": "Service name (from the `services` option). Default: the app"
493
+ },
494
+ "within": {
495
+ "type": "integer",
496
+ "minimum": 1,
497
+ "description": "Milliseconds to wait. Default 5000"
498
+ },
499
+ "name": {
500
+ "type": "string",
501
+ "description": "Label shown when this step fails"
502
+ }
503
+ }
504
+ },
505
+ {
506
+ "type": "object",
507
+ "required": [
508
+ "snapshot"
509
+ ],
510
+ "additionalProperties": false,
511
+ "properties": {
512
+ "snapshot": {
513
+ "const": true,
514
+ "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"
515
+ },
516
+ "mask": {
517
+ "type": "array",
518
+ "items": {
519
+ "type": "string"
520
+ },
521
+ "description": "Keys whose values are masked too, e.g. [token]"
522
+ },
523
+ "name": {
524
+ "type": "string",
525
+ "description": "Label shown when this step fails"
371
526
  }
372
527
  }
373
528
  }
@@ -402,4 +557,4 @@
402
557
  "additionalProperties": true
403
558
  }
404
559
  }
405
- }
560
+ }