slicetest 0.1.0 → 0.2.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.d.ts CHANGED
@@ -11,6 +11,14 @@ export interface RecordedCall {
11
11
  params: Record<string, string>;
12
12
  /** Whether a registered route answered this call. */
13
13
  matched: boolean;
14
+ /** What the stub answered, once it has. */
15
+ response?: {
16
+ status: number;
17
+ headers: Record<string, string>;
18
+ body: string;
19
+ };
20
+ /** Answered by the fallback (e.g. an example from the provider's OpenAPI spec), not a registered route. */
21
+ fallback?: boolean;
14
22
  }
15
23
  export interface StubResponse {
16
24
  status?: number;
@@ -68,6 +76,12 @@ export declare class Stub {
68
76
  /** Human-readable list of registered routes, for diagnostics. */
69
77
  describeRoutes(): string[];
70
78
  reset(): void;
79
+ /**
80
+ * Answer calls that no registered route matches, instead of failing with 501.
81
+ * Return undefined to leave a call unanswered (it then fails the scenario as usual).
82
+ * Kept across scenarios, unlike routes.
83
+ */
84
+ fallback(respond: ((call: RecordedCall) => StubResponse | undefined) | undefined): this;
71
85
  close(): Promise<void>;
72
86
  }
73
87
  /** `expected` is contained in `actual`: objects compare key by key, arrays element-wise. */
package/dist/stub.js CHANGED
@@ -9,6 +9,7 @@ export class Stub {
9
9
  #server;
10
10
  #routes = [];
11
11
  #calls = [];
12
+ #fallback;
12
13
  url = "";
13
14
  constructor(name) {
14
15
  this.name = name;
@@ -80,6 +81,15 @@ export class Stub {
80
81
  this.#routes = [];
81
82
  this.#calls = [];
82
83
  }
84
+ /**
85
+ * Answer calls that no registered route matches, instead of failing with 501.
86
+ * Return undefined to leave a call unanswered (it then fails the scenario as usual).
87
+ * Kept across scenarios, unlike routes.
88
+ */
89
+ fallback(respond) {
90
+ this.#fallback = respond;
91
+ return this;
92
+ }
83
93
  async close() {
84
94
  this.#server.closeAllConnections();
85
95
  await new Promise((resolve) => this.#server.close(resolve));
@@ -115,7 +125,14 @@ export class Stub {
115
125
  break;
116
126
  }
117
127
  if (!route) {
118
- res.writeHead(501, { "content-type": "text/plain" }).end(`slicetest: no stub for ${call.method} ${call.path}`);
128
+ const out = this.#fallback?.(call);
129
+ if (!out) {
130
+ res.writeHead(501, { "content-type": "text/plain" }).end(`slicetest: no stub for ${call.method} ${call.path}`);
131
+ return;
132
+ }
133
+ call.matched = true;
134
+ call.fallback = true;
135
+ this.#send(call, res, out);
119
136
  return;
120
137
  }
121
138
  call.matched = true;
@@ -128,19 +145,27 @@ export class Stub {
128
145
  return;
129
146
  }
130
147
  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));
148
+ this.#send(call, res, typeof route.respond === "function" ? await route.respond(call) : route.respond);
139
149
  }
140
150
  catch (e) {
141
151
  res.writeHead(500).end(`slicetest: stub handler threw: ${e}`);
142
152
  }
143
153
  }
154
+ #send(call, res, out) {
155
+ const raw = out.body === undefined || typeof out.body === "string" || out.body instanceof Uint8Array;
156
+ const headers = { ...out.headers };
157
+ if (!raw && !Object.keys(headers).some((h) => h.toLowerCase() === "content-type")) {
158
+ headers["content-type"] = "application/json";
159
+ }
160
+ const payload = raw ? out.body : JSON.stringify(out.body);
161
+ call.response = {
162
+ status: out.status ?? 200,
163
+ headers: Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v])),
164
+ body: payload === undefined ? "" : typeof payload === "string" ? payload : Buffer.from(payload).toString("utf8"),
165
+ };
166
+ res.writeHead(out.status ?? 200, headers);
167
+ res.end(payload);
168
+ }
144
169
  }
145
170
  function compilePath(path) {
146
171
  if (typeof path !== "string" || !path.includes("/:"))
@@ -1,4 +1,5 @@
1
1
  import { expect } from "vitest";
2
+ import { formatChanges } from "./db.js";
2
3
  import "./matchers.js";
3
4
  import { scenario } from "./scenario.js";
4
5
  /** Registers the scenarios of one parsed YAML file. Called from the module the plugin generates for it. */
@@ -15,7 +16,8 @@ export function defineYamlScenarios(doc) {
15
16
  async function runSteps(doc, sc, steps, ctx, vars) {
16
17
  for (const [i, step] of steps.entries()) {
17
18
  try {
18
- await runStep(step, ctx, vars);
19
+ // A log step waits by itself; `within` is its timeout.
20
+ await retry("within" in step && !("log" in step) ? step.within : undefined, () => runStep(step, ctx, vars));
19
21
  }
20
22
  catch (e) {
21
23
  const label = step.name ?? describeStep(step);
@@ -39,9 +41,67 @@ function describeStep(step) {
39
41
  return `insert ${step.insert}`;
40
42
  if ("db" in step)
41
43
  return `db ${step.db}`;
44
+ if ("changes" in step)
45
+ return "changes";
46
+ if ("checkpoint" in step)
47
+ return "checkpoint";
48
+ if ("log" in step)
49
+ return `log ${step.from ? `from ${step.from} ` : ""}/${step.log}/`;
42
50
  return "sql";
43
51
  }
52
+ /** Run `fn` until it passes or `within` ms have passed, then rethrow its last error. */
53
+ async function retry(within, fn) {
54
+ if (!within)
55
+ return fn();
56
+ const deadline = Date.now() + within;
57
+ for (;;) {
58
+ try {
59
+ return await fn();
60
+ }
61
+ catch (e) {
62
+ if (Date.now() >= deadline) {
63
+ if (e instanceof Error)
64
+ e.message = `${e.message}\n(still failing after retrying for ${within}ms)`;
65
+ throw e;
66
+ }
67
+ await new Promise((r) => setTimeout(r, 50));
68
+ }
69
+ }
70
+ }
44
71
  async function runStep(step, ctx, vars) {
72
+ if ("checkpoint" in step) {
73
+ await ctx.db.checkpoint();
74
+ return;
75
+ }
76
+ if ("log" in step) {
77
+ const target = step.from ? ctx.service(step.from) : ctx.app;
78
+ await target.waitForLog(new RegExp(interpolate(step.log, vars)), step.within ?? 5000);
79
+ return;
80
+ }
81
+ if ("changes" in step) {
82
+ const actual = await ctx.db.changes();
83
+ const expected = interpolate(step.changes, vars);
84
+ const unexpected = Object.keys(actual).filter((t) => !(t in expected));
85
+ if (unexpected.length) {
86
+ throw new Error(`unexpected changes in ${unexpected.join(", ")}:\n${formatChanges(Object.fromEntries(unexpected.map((t) => [t, actual[t]])))}`);
87
+ }
88
+ for (const [table, spec] of Object.entries(expected)) {
89
+ const got = actual[table] ?? { inserted: [], updated: [], deleted: [] };
90
+ for (const kind of ["inserted", "updated", "deleted"]) {
91
+ const want = spec[kind];
92
+ if (want === undefined)
93
+ continue;
94
+ const rows = kind === "updated" ? got.updated.map((u) => u.after) : got[kind];
95
+ if (typeof want === "number") {
96
+ if (rows.length !== want)
97
+ throw new Error(`expected ${want} row(s) ${kind} in ${table}, got ${rows.length}\n${formatChanges({ [table]: got }) || " (no changes)"}`);
98
+ }
99
+ else
100
+ check(rows, want, vars, `${kind} rows of ${table}`);
101
+ }
102
+ }
103
+ return;
104
+ }
45
105
  if ("stub" in step) {
46
106
  const [method, path] = splitCall(step.on);
47
107
  let route = ctx.stub(step.stub).on(method, interpolate(path, vars), conditions(step.when, vars));
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) & {
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,32 @@ 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;
96
125
  }
97
126
  export declare class YamlScenarioError extends Error {
98
127
  }
package/dist/yaml.js CHANGED
@@ -3,9 +3,12 @@ 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: [],
9
12
  };
10
13
  const EXPECT_KEYS = {
11
14
  request: ["status", "headers", "json", "text"],
@@ -13,6 +16,7 @@ const EXPECT_KEYS = {
13
16
  db: ["rows", "count"],
14
17
  };
15
18
  const CONDITION_KEYS = ["query", "headers", "json", "body"];
19
+ const CHANGE_KEYS = ["inserted", "updated", "deleted"];
16
20
  const RESPONSE_KEYS = ["status", "headers", "body"];
17
21
  const SCENARIO_KEYS = ["name", "steps", "each", "skip", "only", "timeout"];
18
22
  const CALL = /^([A-Za-z]+|\*)\s+(\/\S*)$/;
@@ -88,7 +92,26 @@ function parseStep(node, fail, lineOf) {
88
92
  if (!allowed.has(key))
89
93
  fail(at(key), `unknown key "${key}" in a ${kind} step (allowed: ${[...allowed].join(", ")})`);
90
94
  }
91
- if (typeof raw[kind] !== "string" || !raw[kind])
95
+ if (kind === "changes") {
96
+ const tables = raw.changes;
97
+ if (!tables || typeof tables !== "object" || Array.isArray(tables))
98
+ fail(at(kind), "`changes:` must map table names to { inserted, updated, deleted }");
99
+ for (const [table, spec] of Object.entries(tables)) {
100
+ if (!spec || typeof spec !== "object" || Array.isArray(spec))
101
+ fail(at(kind), `changes of "${table}" must be a mapping such as { inserted: 1 }`);
102
+ for (const [k, v] of Object.entries(spec)) {
103
+ if (!CHANGE_KEYS.includes(k))
104
+ fail(at(kind), `unknown key "${k}" in changes of "${table}" (allowed: ${CHANGE_KEYS.join(", ")})`);
105
+ if (!Array.isArray(v) && !(typeof v === "number" && v >= 0))
106
+ fail(at(kind), `${table}.${k} must be a count or a list of rows`);
107
+ }
108
+ }
109
+ }
110
+ else if (kind === "checkpoint") {
111
+ if (raw.checkpoint !== true)
112
+ fail(at(kind), "use `checkpoint: true`");
113
+ }
114
+ else if (typeof raw[kind] !== "string" || !raw[kind])
92
115
  fail(at(kind), `\`${kind}:\` must be a non-empty string`);
93
116
  const keysOf = (key, allowedKeys) => {
94
117
  const v = raw[key];
@@ -105,6 +128,8 @@ function parseStep(node, fail, lineOf) {
105
128
  if (raw[key] !== undefined && !CALL.test(String(raw[key])))
106
129
  fail(at(key), `\`${key}\` must look like "POST /path", got "${raw[key]}"`);
107
130
  };
131
+ if (raw.within !== undefined && (typeof raw.within !== "number" || raw.within <= 0))
132
+ fail(at("within"), "`within` must be a positive number of milliseconds");
108
133
  const number = (key) => {
109
134
  if (raw[key] !== undefined && (typeof raw[key] !== "number" || raw[key] < 0))
110
135
  fail(at(key), `\`${key}\` must be a non-negative number`);
@@ -142,6 +167,16 @@ function parseStep(node, fail, lineOf) {
142
167
  if (!raw.rows || typeof raw.rows !== "object")
143
168
  fail(at("rows"), "an insert step needs `rows` (a mapping or a list of mappings)");
144
169
  break;
170
+ case "log":
171
+ try {
172
+ new RegExp(raw.log);
173
+ }
174
+ catch (e) {
175
+ fail(at("log"), `\`log\` must be a regular expression: ${e.message}`);
176
+ }
177
+ if (raw.from !== undefined && typeof raw.from !== "string")
178
+ fail(at("from"), "`from` must be a service name");
179
+ break;
145
180
  case "received":
146
181
  call("call");
147
182
  number("times");
package/package.json CHANGED
@@ -1,7 +1,7 @@
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.2.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",
@@ -14,7 +14,9 @@
14
14
  "vitest-plugin",
15
15
  "yaml",
16
16
  "testcontainers",
17
- "language-agnostic"
17
+ "language-agnostic",
18
+ "openapi",
19
+ "contract-testing"
18
20
  ],
19
21
  "author": "pon-bok",
20
22
  "repository": {
@@ -52,7 +54,7 @@
52
54
  "build": "tsc -p tsconfig.build.json",
53
55
  "typecheck": "tsc --noEmit",
54
56
  "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",
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",
56
58
  "prepublishOnly": "npm run typecheck && npm run build"
57
59
  },
58
60
  "peerDependencies": {
@@ -60,6 +62,8 @@
60
62
  },
61
63
  "dependencies": {
62
64
  "@testcontainers/postgresql": "^12.2.0",
65
+ "ajv": "^8.20.0",
66
+ "ajv-formats": "^3.0.1",
63
67
  "pg": "^8.23.0",
64
68
  "testcontainers": "^12.2.0",
65
69
  "yaml": "^2.9.1"
@@ -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,127 @@
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"
371
502
  }
372
503
  }
373
504
  }
@@ -402,4 +533,4 @@
402
533
  "additionalProperties": true
403
534
  }
404
535
  }
405
- }
536
+ }