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.
@@ -0,0 +1,305 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { Ajv } from "ajv";
3
+ import { Ajv2020 } from "ajv/dist/2020.js";
4
+ import addFormatsModule from "ajv-formats";
5
+ import { parse } from "yaml";
6
+ const addFormats = (addFormatsModule.default ?? addFormatsModule);
7
+ const METHODS = ["get", "put", "post", "delete", "options", "head", "patch", "trace"];
8
+ /**
9
+ * An OpenAPI 3.0 / 3.1 document, used to check that real traffic matches it:
10
+ * the app's responses against the app's own spec, and the app's calls to a
11
+ * stubbed service (and the stub's canned replies) against that service's spec.
12
+ */
13
+ export class OpenApiSpec {
14
+ file;
15
+ doc;
16
+ #ajv;
17
+ #validators = new Map();
18
+ #ops = [];
19
+ #basePaths;
20
+ #documentOrder = [];
21
+ constructor(file, doc) {
22
+ this.file = file;
23
+ this.doc = doc;
24
+ const v31 = String(doc.openapi ?? "").startsWith("3.1");
25
+ this.#ajv = v31 ? new Ajv2020({ strict: false, allErrors: true }) : new Ajv({ strict: false, allErrors: true });
26
+ addFormats(this.#ajv);
27
+ for (const f of ["int32", "int64", "float", "double", "byte", "binary", "password"])
28
+ this.#ajv.addFormat(f, true);
29
+ this.#ajv.addSchema(v31 ? doc : nullableToType(structuredClone(doc)), "spec");
30
+ for (const [template, item] of Object.entries(doc.paths ?? {})) {
31
+ for (const method of METHODS)
32
+ if (item[method])
33
+ this.#documentOrder.push({ template, method, op: item[method] });
34
+ }
35
+ // Concrete segments before templated ones, so /polls/new wins over /polls/{id}.
36
+ const templates = Object.keys(doc.paths ?? {}).sort((a, b) => a.split("{").length - b.split("{").length);
37
+ for (const template of templates) {
38
+ const re = new RegExp(`^${template.split(/\{[^}]+\}/).map(escape).join("[^/]+")}/?$`);
39
+ for (const method of METHODS) {
40
+ const op = doc.paths[template][method];
41
+ if (op)
42
+ this.#ops.push({ re, op: { template, method, op } });
43
+ }
44
+ }
45
+ // Paths in the spec are relative to the server URL, e.g. /v1 for https://api.example.com/v1.
46
+ this.#basePaths = [
47
+ ...new Set((doc.servers ?? [])
48
+ .map((s) => new URL(s.url ?? "/", "http://x").pathname.replace(/\/$/, ""))
49
+ .filter(Boolean)),
50
+ ];
51
+ }
52
+ /** `file` is read; `label` (default: `file`) is how messages refer to it. */
53
+ static async load(file, label = file) {
54
+ let doc;
55
+ try {
56
+ doc = parse(await readFile(file, "utf8"));
57
+ }
58
+ catch (e) {
59
+ throw new Error(`slicetest: can't read OpenAPI spec ${label}: ${e.message}`);
60
+ }
61
+ if (!doc || typeof doc !== "object" || !("openapi" in doc)) {
62
+ throw new Error(`slicetest: ${label} is not an OpenAPI 3 document (no "openapi" field)`);
63
+ }
64
+ return new OpenApiSpec(label, doc);
65
+ }
66
+ find(method, path) {
67
+ const m = method.toLowerCase();
68
+ for (const candidate of [path, ...this.#basePaths.filter((b) => path.startsWith(b)).map((b) => path.slice(b.length) || "/")]) {
69
+ const hit = this.#ops.find((o) => o.op.method === m && o.re.test(candidate));
70
+ if (hit)
71
+ return hit.op;
72
+ }
73
+ return undefined;
74
+ }
75
+ /** Problems with a response to `method path`; empty when it matches the spec. */
76
+ /** The documented response (`200`, `4XX`, `default`) that `status` falls under, for coverage. */
77
+ responseKey(method, path, status) {
78
+ const found = this.find(method, path);
79
+ if (!found)
80
+ return undefined;
81
+ const key = pickResponse(found.op.responses ?? {}, status);
82
+ return key && `${method.toUpperCase()} ${found.template} ${key}`;
83
+ }
84
+ /** Every documented response as `METHOD /template key`, in document order. */
85
+ responseKeys() {
86
+ return this.#documentOrder.flatMap(({ template, method, op }) => Object.keys(op.responses ?? {}).map((key) => `${method.toUpperCase()} ${template} ${key}`));
87
+ }
88
+ checkResponse(method, path, res) {
89
+ const found = this.find(method, path);
90
+ if (!found)
91
+ return [`${method} ${path} is not in ${this.file}`];
92
+ const { template, op } = found;
93
+ const status = String(res.status);
94
+ const responses = op.responses ?? {};
95
+ const key = pickResponse(responses, res.status ?? 0);
96
+ if (!key)
97
+ return [`${method} ${template} responded ${status}, which ${this.file} doesn't document (documented: ${Object.keys(responses).join(", ") || "none"})`];
98
+ const [response, at] = this.#resolve(responses[key], ["paths", template, found.method, "responses", key]);
99
+ return this.#checkContent(response?.content, res, [...at, "content"], `${method} ${template} → ${status}`);
100
+ }
101
+ /**
102
+ * A response the real service could send to `method path`: the lowest
103
+ * documented 2xx, with its example if the spec has one, else a value built
104
+ * from its schema. Undefined when the operation isn't in the spec.
105
+ */
106
+ exampleResponse(method, path) {
107
+ const found = this.find(method, path);
108
+ if (!found)
109
+ return undefined;
110
+ const responses = found.op.responses ?? {};
111
+ const key = Object.keys(responses)
112
+ .filter((k) => /^2\d\d$/.test(k))
113
+ .sort()[0] ?? Object.keys(responses).find((k) => /^2xx$/i.test(k));
114
+ if (!key)
115
+ return undefined;
116
+ const status = key.length === 3 && /^\d+$/.test(key) ? Number(key) : 200;
117
+ const response = this.#deref(responses[key]);
118
+ const content = response?.content ?? {};
119
+ const media = Object.keys(content).find(isJson) ?? Object.keys(content)[0];
120
+ if (!media)
121
+ return { status };
122
+ const m = content[media] ?? {};
123
+ const named = m.examples && Object.values(m.examples)[0];
124
+ const body = m.example !== undefined
125
+ ? m.example
126
+ : named !== undefined
127
+ ? (this.#deref(named)?.value ?? null)
128
+ : this.#sample(m.schema);
129
+ return { status, headers: { "content-type": media === "*/*" ? "application/json" : media }, body: isJson(media) ? body : String(body ?? "") };
130
+ }
131
+ /** A value that satisfies `schema` (as far as a simple walk can): examples, defaults, enums, then types. */
132
+ #sample(schema, depth = 0) {
133
+ const s = this.#deref(schema);
134
+ if (!s || typeof s !== "object" || depth > 8)
135
+ return null;
136
+ if (s.example !== undefined)
137
+ return s.example;
138
+ if (Array.isArray(s.examples) && s.examples.length)
139
+ return s.examples[0];
140
+ if (s.default !== undefined)
141
+ return s.default;
142
+ if (s.const !== undefined)
143
+ return s.const;
144
+ if (Array.isArray(s.enum) && s.enum.length)
145
+ return s.enum[0];
146
+ if (Array.isArray(s.allOf)) {
147
+ return Object.assign({}, ...s.allOf.map((part) => this.#sample(part, depth + 1)).filter((v) => v && typeof v === "object"));
148
+ }
149
+ const first = s.oneOf?.[0] ?? s.anyOf?.[0];
150
+ if (first)
151
+ return this.#sample(first, depth + 1);
152
+ const type = Array.isArray(s.type) ? s.type.find((t) => t !== "null") : s.type;
153
+ switch (type ?? (s.properties ? "object" : s.items ? "array" : undefined)) {
154
+ case "object":
155
+ return Object.fromEntries(Object.entries(s.properties ?? {}).map(([k, v]) => [k, this.#sample(v, depth + 1)]));
156
+ case "array":
157
+ return Array.from({ length: Math.max(1, s.minItems ?? 1) }, () => this.#sample(s.items, depth + 1));
158
+ case "integer":
159
+ case "number":
160
+ return typeof s.minimum === "number" ? s.minimum : typeof s.exclusiveMinimum === "number" ? s.exclusiveMinimum + 1 : 0;
161
+ case "boolean":
162
+ return true;
163
+ case "string":
164
+ return sampleString(s);
165
+ default:
166
+ return null;
167
+ }
168
+ }
169
+ /** Problems with a request the app sent to `method path`. */
170
+ checkRequest(method, path, req) {
171
+ const found = this.find(method, path);
172
+ if (!found)
173
+ return [`${method} ${path} is not in ${this.file}`];
174
+ const { template, op } = found;
175
+ const errors = [];
176
+ const params = [...(this.doc.paths[template].parameters ?? []), ...(op.parameters ?? [])].map((p) => this.#deref(p));
177
+ for (const p of params) {
178
+ if (p?.in === "query" && p.required && !req.query?.has(p.name))
179
+ errors.push(`${method} ${template}: required query parameter "${p.name}" is missing`);
180
+ }
181
+ const [body, at] = this.#resolve(op.requestBody, ["paths", template, found.method, "requestBody"]);
182
+ if (!body)
183
+ return errors;
184
+ if (req.body === "" || req.body === undefined) {
185
+ if (body.required)
186
+ errors.push(`${method} ${template}: the request body is required`);
187
+ return errors;
188
+ }
189
+ return [...errors, ...this.#checkContent(body.content, req, [...at, "content"], `${method} ${template} request`)];
190
+ }
191
+ #checkContent(content, msg, pointer, label) {
192
+ if (!content || Object.keys(content).length === 0)
193
+ return [];
194
+ const type = (msg.contentType ?? "").split(";")[0].trim().toLowerCase();
195
+ // Without a content-type (e.g. a stub replying with a bare string) there's nothing to hold the body to.
196
+ if (!type)
197
+ return [];
198
+ const media = Object.keys(content).find((k) => k.toLowerCase() === type) ??
199
+ Object.keys(content).find((k) => k.endsWith("/*") && type.startsWith(k.slice(0, -1))) ??
200
+ Object.keys(content).find((k) => k === "*/*");
201
+ if (!media)
202
+ return [`${label}: content-type "${type || "(none)"}" is not one of ${Object.keys(content).join(", ")}`];
203
+ if (!content[media]?.schema || !isJson(media))
204
+ return [];
205
+ if (typeof msg.body === "string")
206
+ return [`${label}: body is not valid JSON`];
207
+ const validate = this.#validator([...pointer, media, "schema"]);
208
+ if (validate(msg.body))
209
+ return [];
210
+ return (validate.errors ?? []).slice(0, 5).map((e) => `${label}: ${e.instancePath || "body"} ${e.message}${e.params && "additionalProperty" in e.params ? ` "${e.params.additionalProperty}"` : ""}`);
211
+ }
212
+ #validator(pointer) {
213
+ const ref = `spec#/${pointer.map((p) => p.replace(/~/g, "~0").replace(/\//g, "~1")).join("/")}`;
214
+ let v = this.#validators.get(ref);
215
+ if (!v) {
216
+ v = this.#ajv.compile({ $ref: ref });
217
+ this.#validators.set(ref, v);
218
+ }
219
+ return v;
220
+ }
221
+ /** Follow a local `$ref` such as `#/components/responses/NotFound`. */
222
+ #deref(node) {
223
+ return this.#resolve(node, [])[0];
224
+ }
225
+ /** The node behind any `$ref`s, and its location in the document (for compiling schemas under it). */
226
+ #resolve(node, at, depth = 0) {
227
+ if (!node || typeof node.$ref !== "string" || !node.$ref.startsWith("#/") || depth > 10)
228
+ return [node, at];
229
+ const pointer = node.$ref
230
+ .slice(2)
231
+ .split("/")
232
+ .map((p) => p.replace(/~1/g, "/").replace(/~0/g, "~"));
233
+ const target = pointer.reduce((cur, key) => cur?.[key], this.doc);
234
+ return this.#resolve(target, pointer, depth + 1);
235
+ }
236
+ }
237
+ const FORMATS = {
238
+ "date-time": "2026-01-01T00:00:00Z",
239
+ date: "2026-01-01",
240
+ time: "00:00:00Z",
241
+ email: "user@example.com",
242
+ uri: "https://example.com",
243
+ url: "https://example.com",
244
+ uuid: "00000000-0000-4000-8000-000000000000",
245
+ hostname: "example.com",
246
+ ipv4: "192.0.2.1",
247
+ ipv6: "2001:db8::1",
248
+ };
249
+ function sampleString(s) {
250
+ const base = (s.format && FORMATS[s.format]) ?? "string";
251
+ return base.length >= (s.minLength ?? 0) ? base : base.padEnd(s.minLength, "x");
252
+ }
253
+ function pickResponse(responses, status) {
254
+ const s = String(status);
255
+ return [s, `${s[0]}XX`, `${s[0]}xx`, "default"].find((k) => k in responses);
256
+ }
257
+ /**
258
+ * Coverage report: which documented responses the scenarios produced.
259
+ * `hits` are `responseKey()` values collected from every worker.
260
+ */
261
+ export function formatCoverage(spec, hits) {
262
+ const keys = spec.responseKeys();
263
+ const byOp = new Map();
264
+ for (const k of keys) {
265
+ const i = k.lastIndexOf(" ");
266
+ const op = k.slice(0, i);
267
+ byOp.set(op, [...(byOp.get(op) ?? []), k.slice(i + 1)]);
268
+ }
269
+ const width = Math.max(0, ...[...byOp.keys()].map((op) => op.indexOf(" ") > -1 ? op.length - op.indexOf(" ") - 1 : 0));
270
+ const lines = [...byOp].map(([op, statuses]) => {
271
+ const i = op.indexOf(" ");
272
+ const marks = statuses.map((s) => `${s} ${hits.has(`${op} ${s}`) ? "✓" : "✗"}`).join(" ");
273
+ return ` ${op.slice(0, i).padEnd(6)} ${op.slice(i + 1).padEnd(width)} ${marks}`;
274
+ });
275
+ const covered = keys.filter((k) => hits.has(k)).length;
276
+ const percent = keys.length ? Math.round((covered / keys.length) * 100) : 100;
277
+ return { covered, total: keys.length, percent, text: `slicetest: OpenAPI coverage (${spec.file}): ${covered}/${keys.length} documented responses (${percent}%)\n${lines.join("\n")}` };
278
+ }
279
+ function isJson(media) {
280
+ return /[/+]json$/i.test(media) || media === "*/*";
281
+ }
282
+ function escape(s) {
283
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
284
+ }
285
+ /** OpenAPI 3.0's `nullable: true` → JSON Schema's `type: [T, "null"]`. */
286
+ function nullableToType(node) {
287
+ if (Array.isArray(node))
288
+ return node.map(nullableToType);
289
+ if (!node || typeof node !== "object")
290
+ return node;
291
+ for (const [k, v] of Object.entries(node))
292
+ node[k] = nullableToType(v);
293
+ if (node.nullable === true) {
294
+ if (node.enum && !node.enum.includes(null))
295
+ node.enum = [...node.enum, null];
296
+ if (typeof node.type === "string")
297
+ node.type = [node.type, "null"];
298
+ else if (!node.type) {
299
+ const inner = { ...node };
300
+ delete inner.nullable;
301
+ return { anyOf: [inner, { type: "null" }] };
302
+ }
303
+ }
304
+ return node;
305
+ }
@@ -6,6 +6,7 @@ declare module "vitest" {
6
6
  adminUrl: string;
7
7
  template: string;
8
8
  prefix: string;
9
+ coverageDir?: string;
9
10
  };
10
11
  }
11
12
  }
package/dist/runtime.d.ts CHANGED
@@ -8,27 +8,33 @@ export interface ScenarioContext {
8
8
  db: Db;
9
9
  stub: (name: string) => Stub;
10
10
  app: App;
11
+ /** A process from the `services` option: its `url`, `logs()` and `waitForLog()`. */
12
+ service: (name: string) => App;
11
13
  }
12
14
  /** Everything one test file needs: its own database, stub servers and app process. */
13
15
  export declare class Runtime {
14
16
  #private;
15
17
  app: App;
18
+ readonly services: Map<string, App>;
16
19
  readonly db: Db;
17
20
  readonly stubs: Map<string, Stub>;
18
21
  private readonly opts;
19
22
  private readonly vars;
23
+ private readonly specs;
24
+ private readonly coverageDir?;
20
25
  private constructor();
21
26
  get http(): HttpClient;
22
27
  static start(opts: ResolvedOptions, shared: {
23
28
  adminUrl: string;
24
29
  template: string;
25
30
  prefix: string;
31
+ coverageDir?: string;
26
32
  }): Promise<Runtime>;
27
33
  context(): ScenarioContext;
28
34
  beforeScenario(): Promise<void>;
29
35
  /** Failures that the scenario body can't see on its own. */
30
36
  afterScenario(): Promise<void>;
31
37
  /** What happened during the current scenario, printed when it fails. */
32
- diagnostics(): string;
38
+ diagnostics(): Promise<string>;
33
39
  stop(): Promise<void>;
34
40
  }
package/dist/runtime.js CHANGED
@@ -1,37 +1,81 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { writeFile } from "node:fs/promises";
1
3
  import path from "node:path";
2
- import pg from "pg";
3
4
  import { App } from "./app.js";
4
- import { Db, withDatabase } from "./db.js";
5
+ import { Db, formatChanges } from "./db.js";
6
+ import { engineFor } from "./drivers/index.js";
5
7
  import { formatHistory, HttpClient } from "./http.js";
8
+ import { OpenApiSpec } from "./openapi.js";
6
9
  import { Stub } from "./stub.js";
7
10
  /** Everything one test file needs: its own database, stub servers and app process. */
8
11
  export class Runtime {
9
12
  app;
13
+ services;
10
14
  db;
11
15
  stubs;
12
16
  opts;
13
17
  vars;
14
- #logMark = 0;
18
+ specs;
19
+ coverageDir;
15
20
  #http;
16
- constructor(app, db, stubs, opts, vars) {
21
+ /** Responses from the app that don't match its OpenAPI spec, this scenario. */
22
+ #contract = [];
23
+ /** Documented responses seen in this file, for the run's coverage report. */
24
+ #covered = new Set();
25
+ constructor(app, services, db, stubs, opts, vars, specs, coverageDir) {
17
26
  this.app = app;
27
+ this.services = services;
18
28
  this.db = db;
19
29
  this.stubs = stubs;
20
30
  this.opts = opts;
21
31
  this.vars = vars;
22
- this.#http = new HttpClient(app.url, opts.http);
32
+ this.specs = specs;
33
+ this.coverageDir = coverageDir;
34
+ this.#http = this.#client();
35
+ }
36
+ #client() {
37
+ const http = new HttpClient(this.app.url, this.opts.http);
38
+ const spec = this.specs.app;
39
+ if (spec) {
40
+ http.onResponse((res) => {
41
+ try {
42
+ const path = res.url.split("?")[0];
43
+ const key = spec.responseKey(res.method, path, res.status);
44
+ if (key)
45
+ this.#covered.add(key);
46
+ this.#contract.push(...spec.checkResponse(res.method, path, message(res)));
47
+ }
48
+ catch (e) {
49
+ this.#contract.push(`${res.method} ${res.url}: couldn't check against ${spec.file}: ${e.message}`);
50
+ }
51
+ });
52
+ }
53
+ return http;
23
54
  }
24
55
  get http() {
25
56
  return this.#http;
26
57
  }
27
58
  static async start(opts, shared) {
28
- const url = await ensureWorkerDatabase(shared.adminUrl, shared.template, shared.prefix);
59
+ const engine = engineFor(opts);
60
+ const url = await ensureWorkerDatabase(engine, shared.adminUrl, shared.template, shared.prefix);
29
61
  const stubs = new Map();
62
+ const services = new Map();
30
63
  let db;
31
64
  try {
65
+ // Loaded first: a broken spec should fail before anything is started.
66
+ const specs = {
67
+ app: opts.openapi.app ? await OpenApiSpec.load(path.resolve(opts.root, opts.openapi.app), opts.openapi.app) : undefined,
68
+ stubs: new Map(),
69
+ };
70
+ for (const [name, file] of Object.entries(opts.openapi.stubs))
71
+ specs.stubs.set(name, await OpenApiSpec.load(path.resolve(opts.root, file), file));
32
72
  for (const name of opts.stubs)
33
73
  stubs.set(name, await Stub.start(name));
34
- db = await Db.connect(url, {
74
+ for (const name of opts.openapi.autoReply) {
75
+ const spec = specs.stubs.get(name);
76
+ stubs.get(name).fallback((call) => spec.exampleResponse(call.method, call.path));
77
+ }
78
+ db = await Db.connect(await engine.driver(url), url, {
35
79
  schemas: opts.db.schemas,
36
80
  keep: opts.db.keep,
37
81
  seedFile: opts.db.seed && path.resolve(opts.root, opts.db.seed),
@@ -39,10 +83,17 @@ export class Runtime {
39
83
  const vars = { "db.url": url };
40
84
  for (const [name, stub] of stubs)
41
85
  vars[`stub.${name}`] = stub.url;
86
+ for (const [name, service] of Object.entries(opts.services)) {
87
+ const started = await App.start(service, opts.root, vars, `service.${name}`);
88
+ services.set(name, started);
89
+ vars[`service.${name}`] = started.url;
90
+ vars[`service.${name}.port`] = String(started.port);
91
+ }
42
92
  const app = await App.start(opts.app, opts.root, vars);
43
- return new Runtime(app, db, stubs, opts, vars);
93
+ return new Runtime(app, services, db, stubs, opts, vars, specs, shared.coverageDir);
44
94
  }
45
95
  catch (e) {
96
+ await Promise.all([...services.values()].map((s) => s.stop()));
46
97
  await db?.close();
47
98
  await Promise.all([...stubs.values()].map((s) => s.close()));
48
99
  throw e;
@@ -60,29 +111,80 @@ export class Runtime {
60
111
  }
61
112
  return stub;
62
113
  },
114
+ service: (name) => {
115
+ const service = this.services.get(name);
116
+ if (!service) {
117
+ throw new Error(`slicetest: unknown service "${name}". Declared services: ${[...this.services.keys()].join(", ") || "(none)"}`);
118
+ }
119
+ return service;
120
+ },
63
121
  };
64
122
  }
123
+ /** The app and every service, for checks that apply to all of them. */
124
+ #processes() {
125
+ return [this.app, ...this.services.values()];
126
+ }
65
127
  async beforeScenario() {
66
- // A crash already failed the scenario that caused it; give the next one a fresh app.
128
+ // A crash already failed the scenario that caused it; give the next one a fresh process.
129
+ for (const [name, service] of this.services) {
130
+ if (!service.exited)
131
+ continue;
132
+ await service.stop();
133
+ this.services.set(name, await App.start(this.opts.services[name], this.opts.root, this.vars, `service.${name}`, service.port));
134
+ }
67
135
  if (this.app.exited) {
68
136
  await this.app.stop();
69
137
  this.app = await App.start(this.opts.app, this.opts.root, this.vars);
70
- this.#http = new HttpClient(this.app.url, this.opts.http);
138
+ this.#http = this.#client();
71
139
  }
140
+ this.#contract = [];
72
141
  await this.db.reset();
73
142
  for (const stub of this.stubs.values())
74
143
  stub.reset();
75
144
  this.http.reset();
76
- this.#logMark = this.app.mark();
145
+ for (const p of this.#processes())
146
+ p.beginScenario();
77
147
  }
78
148
  /** Failures that the scenario body can't see on its own. */
79
149
  async afterScenario() {
80
- await this.app.settle();
150
+ await Promise.all(this.#processes().map((p) => p.settle()));
81
151
  this.#assertAlive();
82
152
  const unmatched = this.#unmatched();
83
153
  if (unmatched.length > 0) {
84
154
  throw new Error(`slicetest: the app called stubbed services with no matching route:\n${unmatched.join("\n")}`);
85
155
  }
156
+ const contract = this.#contractViolations();
157
+ if (contract.length > 0) {
158
+ throw new Error(`slicetest: traffic doesn't match the OpenAPI spec:\n${contract.map((c) => ` ${c}`).join("\n")}`);
159
+ }
160
+ }
161
+ /** The app's responses against its spec, and its calls to stubs (and the stubs' replies) against theirs. */
162
+ #contractViolations() {
163
+ const out = this.#contract.map((c) => `app: ${c}`);
164
+ for (const [name, spec] of this.specs.stubs) {
165
+ for (const call of this.stubs.get(name).calls()) {
166
+ try {
167
+ out.push(...this.#checkStubCall(name, spec, call));
168
+ }
169
+ catch (e) {
170
+ out.push(`app → ${name}: ${call.method} ${call.path}: couldn't check against ${spec.file}: ${e.message}`);
171
+ }
172
+ }
173
+ }
174
+ return [...new Set(out)];
175
+ }
176
+ #checkStubCall(name, spec, call) {
177
+ if (!call.matched)
178
+ return []; // already reported as an unmatched call
179
+ const req = { contentType: header(call.headers["content-type"]), body: call.json ?? call.body, query: call.query };
180
+ const out = spec.checkRequest(call.method, call.path, req).map((c) => `app → ${name}: ${c}`);
181
+ const res = call.response;
182
+ if (res) {
183
+ const body = res.body ? (parseJson(res.body) ?? res.body) : "";
184
+ const problems = spec.checkResponse(call.method, call.path, { status: res.status, contentType: res.headers["content-type"], body });
185
+ out.push(...problems.map((c) => `stub ${name} reply (the real service wouldn't answer this way): ${c}`));
186
+ }
187
+ return out;
86
188
  }
87
189
  #unmatched() {
88
190
  return [...this.stubs.values()].flatMap((s) => {
@@ -97,23 +199,43 @@ export class Runtime {
97
199
  });
98
200
  }
99
201
  /** What happened during the current scenario, printed when it fails. */
100
- diagnostics() {
202
+ async diagnostics() {
101
203
  const sections = [];
102
- const exit = this.app.exited;
103
- if (exit)
104
- sections.push(`the app exited (code ${exit.code}, signal ${exit.signal}); it will be restarted for the next scenario`);
204
+ for (const p of this.#processes()) {
205
+ const exit = p.exited;
206
+ if (exit)
207
+ sections.push(`the ${p.label} exited (code ${exit.code}, signal ${exit.signal}); it will be restarted for the next scenario`);
208
+ }
105
209
  const unmatched = this.#unmatched();
106
210
  if (unmatched.length > 0)
107
211
  sections.push(`stub calls with no matching route:\n${unmatched.join("\n")}`);
212
+ const contract = this.#contractViolations();
213
+ if (contract.length > 0)
214
+ sections.push(`OpenAPI mismatches:\n${contract.map((c) => ` ${c}`).join("\n")}`);
108
215
  if (this.http.history.length > 0)
109
216
  sections.push(`requests to the app:\n${formatHistory(this.http.history)}`);
110
- const logs = this.app.logs(this.#logMark);
217
+ try {
218
+ const changes = formatChanges(await this.db.changesSinceStart());
219
+ sections.push(changes ? `database changes during this scenario:\n${changes}` : "database changes during this scenario: (none)");
220
+ }
221
+ catch (e) {
222
+ sections.push(`database changes during this scenario: unavailable (${e.message})`);
223
+ }
224
+ const logs = this.app.scenarioLogs();
111
225
  sections.push(logs ? `app output during this scenario:\n${logs}` : "app output during this scenario: (none)");
226
+ for (const [name, service] of this.services) {
227
+ const out = service.scenarioLogs();
228
+ sections.push(out ? `service ${name} output during this scenario:\n${out}` : `service ${name} output during this scenario: (none)`);
229
+ }
112
230
  return sections.join("\n\n");
113
231
  }
114
232
  async stop() {
233
+ if (this.coverageDir && this.#covered.size > 0) {
234
+ await writeFile(path.join(this.coverageDir, `${process.pid}-${randomUUID()}.json`), JSON.stringify([...this.#covered])).catch(() => { });
235
+ }
115
236
  const results = await Promise.allSettled([
116
237
  this.app.stop(),
238
+ ...[...this.services.values()].map((s) => s.stop()),
117
239
  this.db.close(),
118
240
  ...[...this.stubs.values()].map((s) => s.close()),
119
241
  ]);
@@ -122,24 +244,37 @@ export class Runtime {
122
244
  throw failed.reason;
123
245
  }
124
246
  #assertAlive() {
125
- const exit = this.app.exited;
126
- if (exit) {
127
- throw new Error(`slicetest: app process exited (code ${exit.code}, signal ${exit.signal})\n${this.app.logs()}`);
247
+ for (const p of this.#processes()) {
248
+ const exit = p.exited;
249
+ if (exit)
250
+ throw new Error(`slicetest: ${p.label} process exited (code ${exit.code}, signal ${exit.signal})\n${p.logs()}`);
128
251
  }
129
252
  }
130
253
  }
131
254
  /** One database per vitest worker, cloned from the migrated template and reused across its test files. */
132
- async function ensureWorkerDatabase(adminUrl, template, prefix) {
255
+ async function ensureWorkerDatabase(engine, adminUrl, template, prefix) {
133
256
  const name = `${prefix}_w${process.env.VITEST_POOL_ID ?? process.pid}`;
134
- const admin = new pg.Client({ connectionString: adminUrl });
135
- await admin.connect();
257
+ const admin = await engine.admin(adminUrl);
136
258
  try {
137
- const { rowCount } = await admin.query("SELECT 1 FROM pg_database WHERE datname = $1", [name]);
138
- if (!rowCount)
139
- await admin.query(`CREATE DATABASE "${name}" TEMPLATE "${template}"`);
259
+ if (!(await admin.databases(name)).includes(name))
260
+ await admin.clone(template, name);
261
+ return admin.urlFor(name);
140
262
  }
141
263
  finally {
142
- await admin.end();
264
+ await admin.close();
265
+ }
266
+ }
267
+ function message(res) {
268
+ return { status: res.status, contentType: res.headers.get("content-type") ?? undefined, body: res.json ?? res.text };
269
+ }
270
+ function header(v) {
271
+ return Array.isArray(v) ? v[0] : v;
272
+ }
273
+ function parseJson(text) {
274
+ try {
275
+ return JSON.parse(text);
276
+ }
277
+ catch {
278
+ return undefined;
143
279
  }
144
- return withDatabase(adminUrl, name);
145
280
  }
package/dist/scenario.js CHANGED
@@ -15,8 +15,8 @@ function define(register) {
15
15
  if (task.concurrent) {
16
16
  throw new Error("slicetest: scenarios share one app and database per file, so they can't run concurrently. Remove .concurrent / sequence.concurrent.");
17
17
  }
18
- onTestFailed(() => {
19
- console.error(`--- slicetest ---\n${runtime.diagnostics()}\n-----------------`);
18
+ onTestFailed(async () => {
19
+ console.error(`--- slicetest ---\n${await runtime.diagnostics()}\n-----------------`);
20
20
  });
21
21
  await runtime.beforeScenario();
22
22
  await body(runtime.context());