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/runtime.js CHANGED
@@ -1,50 +1,128 @@
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 { Dependency } from "./containers.js";
6
+ import { Db, formatChanges } from "./db.js";
7
+ import { engineFor } from "./drivers/index.js";
5
8
  import { formatHistory, HttpClient } from "./http.js";
9
+ import { OpenApiSpec } from "./openapi.js";
10
+ import { Recorder } from "./recording.js";
6
11
  import { Stub } from "./stub.js";
12
+ import { buildTrace, mask } from "./trace.js";
7
13
  /** Everything one test file needs: its own database, stub servers and app process. */
8
14
  export class Runtime {
9
15
  app;
16
+ services;
10
17
  db;
11
18
  stubs;
12
19
  opts;
13
20
  vars;
14
- #logMark = 0;
21
+ specs;
22
+ coverageDir;
23
+ recorders;
24
+ recordDir;
25
+ containers;
15
26
  #http;
16
- constructor(app, db, stubs, opts, vars) {
27
+ /** Responses from the app that don't match its OpenAPI spec, this scenario. */
28
+ #contract = [];
29
+ /** Documented responses seen in this file, for the run's coverage report. */
30
+ #covered = new Set();
31
+ constructor(app, services, db, stubs, opts, vars, specs, coverageDir, recorders = new Map(), recordDir, containers = new Map()) {
17
32
  this.app = app;
33
+ this.services = services;
18
34
  this.db = db;
19
35
  this.stubs = stubs;
20
36
  this.opts = opts;
21
37
  this.vars = vars;
22
- this.#http = new HttpClient(app.url, opts.http);
38
+ this.specs = specs;
39
+ this.coverageDir = coverageDir;
40
+ this.recorders = recorders;
41
+ this.recordDir = recordDir;
42
+ this.containers = containers;
43
+ this.#http = this.#client();
44
+ }
45
+ #client() {
46
+ const http = new HttpClient(this.app.url, this.opts.http);
47
+ const spec = this.specs.app;
48
+ if (spec) {
49
+ http.onResponse((res) => {
50
+ try {
51
+ const path = res.url.split("?")[0];
52
+ const key = spec.responseKey(res.method, path, res.status);
53
+ if (key)
54
+ this.#covered.add(key);
55
+ this.#contract.push(...spec.checkResponse(res.method, path, message(res)));
56
+ }
57
+ catch (e) {
58
+ this.#contract.push(`${res.method} ${res.url}: couldn't check against ${spec.file}: ${e.message}`);
59
+ }
60
+ });
61
+ }
62
+ return http;
23
63
  }
24
64
  get http() {
25
65
  return this.#http;
26
66
  }
27
67
  static async start(opts, shared) {
28
- const url = await ensureWorkerDatabase(shared.adminUrl, shared.template, shared.prefix);
68
+ const engine = await engineFor(opts);
69
+ const url = await ensureWorkerDatabase(engine, shared.adminUrl, shared.template, shared.prefix);
29
70
  const stubs = new Map();
71
+ const recorders = new Map();
72
+ const containers = new Map();
73
+ const services = new Map();
30
74
  let db;
31
75
  try {
76
+ // Loaded first: a broken spec should fail before anything is started.
77
+ const specs = {
78
+ app: opts.openapi.app ? await OpenApiSpec.load(path.resolve(opts.root, opts.openapi.app), opts.openapi.app) : undefined,
79
+ stubs: new Map(),
80
+ };
81
+ for (const [name, file] of Object.entries(opts.openapi.stubs))
82
+ specs.stubs.set(name, await OpenApiSpec.load(path.resolve(opts.root, file), file));
32
83
  for (const name of opts.stubs)
33
84
  stubs.set(name, await Stub.start(name));
34
- db = await Db.connect(url, {
85
+ for (const [name, r] of Object.entries(opts.recordings))
86
+ recorders.set(name, await Recorder.load(name, path.resolve(opts.root, r.file), r.upstream, r.record));
87
+ // A registered route wins, then a recording of the real service, then an answer made up from its spec.
88
+ for (const [name, stub] of stubs) {
89
+ const recorder = recorders.get(name);
90
+ const spec = opts.openapi.autoReply.includes(name) ? specs.stubs.get(name) : undefined;
91
+ if (!recorder && !spec)
92
+ continue;
93
+ stub.fallback(async (call) => (await recorder?.answer(call)) ?? spec?.exampleResponse(call.method, call.path), recorder?.hint());
94
+ }
95
+ db = await Db.connect(await engine.driver(url), url, {
35
96
  schemas: opts.db.schemas,
36
97
  keep: opts.db.keep,
37
98
  seedFile: opts.db.seed && path.resolve(opts.root, opts.db.seed),
38
99
  });
100
+ const started = await Promise.allSettled(Object.entries(opts.containers).map(async ([name, c]) => containers.set(name, await Dependency.start(name, c))));
101
+ const failed = started.find((r) => r.status === "rejected");
102
+ if (failed)
103
+ throw failed.reason;
39
104
  const vars = { "db.url": url };
105
+ for (const [name, c] of containers) {
106
+ vars[`container.${name}`] = c.address;
107
+ vars[`container.${name}.host`] = c.host;
108
+ vars[`container.${name}.port`] = String(c.port);
109
+ }
40
110
  for (const [name, stub] of stubs)
41
111
  vars[`stub.${name}`] = stub.url;
112
+ for (const [name, service] of Object.entries(opts.services)) {
113
+ const started = await App.start(service, opts.root, vars, `service.${name}`);
114
+ services.set(name, started);
115
+ vars[`service.${name}`] = started.url;
116
+ vars[`service.${name}.port`] = String(started.port);
117
+ }
42
118
  const app = await App.start(opts.app, opts.root, vars);
43
- return new Runtime(app, db, stubs, opts, vars);
119
+ return new Runtime(app, services, db, stubs, opts, vars, specs, shared.coverageDir, recorders, shared.recordDir, containers);
44
120
  }
45
121
  catch (e) {
122
+ await Promise.all([...services.values()].map((s) => s.stop()));
46
123
  await db?.close();
47
124
  await Promise.all([...stubs.values()].map((s) => s.close()));
125
+ await Promise.allSettled([...containers.values()].map((c) => c.stop()));
48
126
  throw e;
49
127
  }
50
128
  }
@@ -60,29 +138,90 @@ export class Runtime {
60
138
  }
61
139
  return stub;
62
140
  },
141
+ service: (name) => {
142
+ const service = this.services.get(name);
143
+ if (!service) {
144
+ throw new Error(`slicetest: unknown service "${name}". Declared services: ${[...this.services.keys()].join(", ") || "(none)"}`);
145
+ }
146
+ return service;
147
+ },
148
+ container: (name) => {
149
+ const c = this.containers.get(name);
150
+ if (!c)
151
+ throw new Error(`slicetest: unknown container "${name}". Declared containers: ${[...this.containers.keys()].join(", ") || "(none)"}`);
152
+ return c;
153
+ },
154
+ trace: async (opts) => mask(buildTrace(this.http.history, this.stubs.values(), await this.db.changesSinceStart()), opts),
63
155
  };
64
156
  }
157
+ /** The app and every service, for checks that apply to all of them. */
158
+ #processes() {
159
+ return [this.app, ...this.services.values()];
160
+ }
65
161
  async beforeScenario() {
66
- // A crash already failed the scenario that caused it; give the next one a fresh app.
162
+ // A crash already failed the scenario that caused it; give the next one a fresh process.
163
+ for (const [name, service] of this.services) {
164
+ if (!service.exited)
165
+ continue;
166
+ await service.stop();
167
+ this.services.set(name, await App.start(this.opts.services[name], this.opts.root, this.vars, `service.${name}`, service.port));
168
+ }
67
169
  if (this.app.exited) {
68
170
  await this.app.stop();
69
171
  this.app = await App.start(this.opts.app, this.opts.root, this.vars);
70
- this.#http = new HttpClient(this.app.url, this.opts.http);
172
+ this.#http = this.#client();
71
173
  }
174
+ this.#contract = [];
72
175
  await this.db.reset();
73
176
  for (const stub of this.stubs.values())
74
177
  stub.reset();
178
+ for (const recorder of this.recorders.values())
179
+ recorder.reset();
180
+ await Promise.all([...this.containers.values()].map((c) => c.reset()));
75
181
  this.http.reset();
76
- this.#logMark = this.app.mark();
182
+ for (const p of this.#processes())
183
+ p.beginScenario();
77
184
  }
78
185
  /** Failures that the scenario body can't see on its own. */
79
186
  async afterScenario() {
80
- await this.app.settle();
187
+ await Promise.all(this.#processes().map((p) => p.settle()));
81
188
  this.#assertAlive();
82
189
  const unmatched = this.#unmatched();
83
190
  if (unmatched.length > 0) {
84
191
  throw new Error(`slicetest: the app called stubbed services with no matching route:\n${unmatched.join("\n")}`);
85
192
  }
193
+ const contract = this.#contractViolations();
194
+ if (contract.length > 0) {
195
+ throw new Error(`slicetest: traffic doesn't match the OpenAPI spec:\n${contract.map((c) => ` ${c}`).join("\n")}`);
196
+ }
197
+ }
198
+ /** The app's responses against its spec, and its calls to stubs (and the stubs' replies) against theirs. */
199
+ #contractViolations() {
200
+ const out = this.#contract.map((c) => `app: ${c}`);
201
+ for (const [name, spec] of this.specs.stubs) {
202
+ for (const call of this.stubs.get(name).calls()) {
203
+ try {
204
+ out.push(...this.#checkStubCall(name, spec, call));
205
+ }
206
+ catch (e) {
207
+ out.push(`app → ${name}: ${call.method} ${call.path}: couldn't check against ${spec.file}: ${e.message}`);
208
+ }
209
+ }
210
+ }
211
+ return [...new Set(out)];
212
+ }
213
+ #checkStubCall(name, spec, call) {
214
+ if (!call.matched)
215
+ return []; // already reported as an unmatched call
216
+ const req = { contentType: header(call.headers["content-type"]), body: call.json ?? call.body, query: call.query };
217
+ const out = spec.checkRequest(call.method, call.path, req).map((c) => `app → ${name}: ${c}`);
218
+ const res = call.response;
219
+ if (res) {
220
+ const body = res.body ? (parseJson(res.body) ?? res.body) : "";
221
+ const problems = spec.checkResponse(call.method, call.path, { status: res.status, contentType: res.headers["content-type"], body });
222
+ out.push(...problems.map((c) => `stub ${name} reply (the real service wouldn't answer this way): ${c}`));
223
+ }
224
+ return out;
86
225
  }
87
226
  #unmatched() {
88
227
  return [...this.stubs.values()].flatMap((s) => {
@@ -93,53 +232,93 @@ export class Runtime {
93
232
  return [
94
233
  ...calls.map((c) => ` ${s.name}: ${c.method} ${c.path}${c.query.size ? `?${c.query}` : ""}`),
95
234
  ` registered on ${s.name}: ${routes.length ? routes.join(", ") : "(none)"}`,
235
+ ...(this.recorders.has(s.name) ? [` ${this.recorders.get(s.name).hint()}`] : []),
96
236
  ];
97
237
  });
98
238
  }
99
239
  /** What happened during the current scenario, printed when it fails. */
100
- diagnostics() {
240
+ async diagnostics() {
101
241
  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`);
242
+ for (const p of this.#processes()) {
243
+ const exit = p.exited;
244
+ if (exit)
245
+ sections.push(`the ${p.label} exited (code ${exit.code}, signal ${exit.signal}); it will be restarted for the next scenario`);
246
+ }
105
247
  const unmatched = this.#unmatched();
106
248
  if (unmatched.length > 0)
107
249
  sections.push(`stub calls with no matching route:\n${unmatched.join("\n")}`);
250
+ const contract = this.#contractViolations();
251
+ if (contract.length > 0)
252
+ sections.push(`OpenAPI mismatches:\n${contract.map((c) => ` ${c}`).join("\n")}`);
108
253
  if (this.http.history.length > 0)
109
254
  sections.push(`requests to the app:\n${formatHistory(this.http.history)}`);
110
- const logs = this.app.logs(this.#logMark);
255
+ try {
256
+ const changes = formatChanges(await this.db.changesSinceStart());
257
+ sections.push(changes ? `database changes during this scenario:\n${changes}` : "database changes during this scenario: (none)");
258
+ }
259
+ catch (e) {
260
+ sections.push(`database changes during this scenario: unavailable (${e.message})`);
261
+ }
262
+ const logs = this.app.scenarioLogs();
111
263
  sections.push(logs ? `app output during this scenario:\n${logs}` : "app output during this scenario: (none)");
264
+ for (const [name, service] of this.services) {
265
+ const out = service.scenarioLogs();
266
+ sections.push(out ? `service ${name} output during this scenario:\n${out}` : `service ${name} output during this scenario: (none)`);
267
+ }
112
268
  return sections.join("\n\n");
113
269
  }
114
270
  async stop() {
271
+ if (this.coverageDir && this.#covered.size > 0) {
272
+ await writeFile(path.join(this.coverageDir, `${process.pid}-${randomUUID()}.json`), JSON.stringify([...this.#covered])).catch(() => { });
273
+ }
274
+ for (const [name, recorder] of this.recorders) {
275
+ if (this.recordDir && recorder.added().length > 0) {
276
+ await writeFile(path.join(this.recordDir, `${name}.${process.pid}-${randomUUID()}.json`), JSON.stringify(recorder.added())).catch(() => { });
277
+ }
278
+ }
115
279
  const results = await Promise.allSettled([
116
280
  this.app.stop(),
281
+ ...[...this.services.values()].map((s) => s.stop()),
117
282
  this.db.close(),
118
283
  ...[...this.stubs.values()].map((s) => s.close()),
284
+ ...[...this.containers.values()].map((c) => c.stop()),
119
285
  ]);
120
286
  const failed = results.find((r) => r.status === "rejected");
121
287
  if (failed)
122
288
  throw failed.reason;
123
289
  }
124
290
  #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()}`);
291
+ for (const p of this.#processes()) {
292
+ const exit = p.exited;
293
+ if (exit)
294
+ throw new Error(`slicetest: ${p.label} process exited (code ${exit.code}, signal ${exit.signal})\n${p.logs()}`);
128
295
  }
129
296
  }
130
297
  }
131
298
  /** One database per vitest worker, cloned from the migrated template and reused across its test files. */
132
- async function ensureWorkerDatabase(adminUrl, template, prefix) {
299
+ async function ensureWorkerDatabase(engine, adminUrl, template, prefix) {
133
300
  const name = `${prefix}_w${process.env.VITEST_POOL_ID ?? process.pid}`;
134
- const admin = new pg.Client({ connectionString: adminUrl });
135
- await admin.connect();
301
+ const admin = await engine.admin(adminUrl);
136
302
  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}"`);
303
+ if (!(await admin.databases(name)).includes(name))
304
+ await admin.clone(template, name);
305
+ return admin.urlFor(name);
140
306
  }
141
307
  finally {
142
- await admin.end();
308
+ await admin.close();
309
+ }
310
+ }
311
+ function message(res) {
312
+ return { status: res.status, contentType: res.headers.get("content-type") ?? undefined, body: res.json ?? res.text };
313
+ }
314
+ function header(v) {
315
+ return Array.isArray(v) ? v[0] : v;
316
+ }
317
+ function parseJson(text) {
318
+ try {
319
+ return JSON.parse(text);
320
+ }
321
+ catch {
322
+ return undefined;
143
323
  }
144
- return withDatabase(adminUrl, name);
145
324
  }
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());
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 | Promise<StubResponse | undefined>) | undefined, hint?: string): 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,9 @@ export class Stub {
9
9
  #server;
10
10
  #routes = [];
11
11
  #calls = [];
12
+ #fallback;
13
+ /** Appended to the 501 answer for a call nothing could answer. */
14
+ #hint;
12
15
  url = "";
13
16
  constructor(name) {
14
17
  this.name = name;
@@ -80,6 +83,16 @@ export class Stub {
80
83
  this.#routes = [];
81
84
  this.#calls = [];
82
85
  }
86
+ /**
87
+ * Answer calls that no registered route matches, instead of failing with 501.
88
+ * Return undefined to leave a call unanswered (it then fails the scenario as usual).
89
+ * Kept across scenarios, unlike routes.
90
+ */
91
+ fallback(respond, hint) {
92
+ this.#fallback = respond;
93
+ this.#hint = hint;
94
+ return this;
95
+ }
83
96
  async close() {
84
97
  this.#server.closeAllConnections();
85
98
  await new Promise((resolve) => this.#server.close(resolve));
@@ -115,7 +128,22 @@ export class Stub {
115
128
  break;
116
129
  }
117
130
  if (!route) {
118
- res.writeHead(501, { "content-type": "text/plain" }).end(`slicetest: no stub for ${call.method} ${call.path}`);
131
+ let out;
132
+ try {
133
+ out = await this.#fallback?.(call);
134
+ }
135
+ catch (e) {
136
+ res.writeHead(502, { "content-type": "text/plain" }).end(e.message);
137
+ return;
138
+ }
139
+ if (!out) {
140
+ const hint = this.#hint ? ` (${this.#hint})` : "";
141
+ res.writeHead(501, { "content-type": "text/plain" }).end(`slicetest: no stub for ${call.method} ${call.path}${hint}`);
142
+ return;
143
+ }
144
+ call.matched = true;
145
+ call.fallback = true;
146
+ this.#send(call, res, out);
119
147
  return;
120
148
  }
121
149
  call.matched = true;
@@ -128,19 +156,27 @@ export class Stub {
128
156
  return;
129
157
  }
130
158
  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));
159
+ this.#send(call, res, typeof route.respond === "function" ? await route.respond(call) : route.respond);
139
160
  }
140
161
  catch (e) {
141
162
  res.writeHead(500).end(`slicetest: stub handler threw: ${e}`);
142
163
  }
143
164
  }
165
+ #send(call, res, out) {
166
+ const raw = out.body === undefined || typeof out.body === "string" || out.body instanceof Uint8Array;
167
+ const headers = { ...out.headers };
168
+ if (!raw && !Object.keys(headers).some((h) => h.toLowerCase() === "content-type")) {
169
+ headers["content-type"] = "application/json";
170
+ }
171
+ const payload = raw ? out.body : JSON.stringify(out.body);
172
+ call.response = {
173
+ status: out.status ?? 200,
174
+ headers: Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v])),
175
+ body: payload === undefined ? "" : typeof payload === "string" ? payload : Buffer.from(payload).toString("utf8"),
176
+ };
177
+ res.writeHead(out.status ?? 200, headers);
178
+ res.end(payload);
179
+ }
144
180
  }
145
181
  function compilePath(path) {
146
182
  if (typeof path !== "string" || !path.includes("/:"))
@@ -0,0 +1,35 @@
1
+ import type { Changes } from "./db.js";
2
+ import type { HttpResponse } from "./http.js";
3
+ import type { Stub } from "./stub.js";
4
+ /**
5
+ * Everything one scenario did at the three boundaries: the requests it made to
6
+ * the app, the calls the app made to stubs, and the rows it changed. Meant for
7
+ * `expect(await trace()).toMatchSnapshot()`: resets make ids deterministic,
8
+ * and `mask` replaces the values that aren't (timestamps, UUIDs).
9
+ */
10
+ export interface Trace {
11
+ http: {
12
+ request: string;
13
+ status: number;
14
+ body?: unknown;
15
+ }[];
16
+ stubs: Record<string, {
17
+ request: string;
18
+ body?: unknown;
19
+ status?: number;
20
+ }[]>;
21
+ db: Changes;
22
+ }
23
+ export interface MaskOptions {
24
+ /** Keys whose values are always replaced, e.g. `["token", "password_hash"]`. Matched at any depth. */
25
+ keys?: string[];
26
+ /** Also mask strings matching these patterns, e.g. `[/^tok_\w+$/]`. */
27
+ patterns?: RegExp[];
28
+ }
29
+ /**
30
+ * A copy of `value` with the parts that change from run to run replaced by
31
+ * placeholders: dates (`Date` objects and ISO strings) by `[date]`, UUIDs by
32
+ * `[uuid]`, and the values of `keys` / strings matching `patterns` by `[masked]`.
33
+ */
34
+ export declare function mask<T>(value: T, opts?: MaskOptions): T;
35
+ export declare function buildTrace(history: readonly HttpResponse[], stubs: Iterable<Stub>, db: Changes): Trace;
package/dist/trace.js ADDED
@@ -0,0 +1,57 @@
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) {
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
+ return trace;
57
+ }
@@ -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,73 @@ 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 ("snapshot" in step)
49
+ return "snapshot";
50
+ if ("log" in step)
51
+ return `log ${step.from ? `from ${step.from} ` : ""}/${step.log}/`;
42
52
  return "sql";
43
53
  }
54
+ /** Run `fn` until it passes or `within` ms have passed, then rethrow its last error. */
55
+ async function retry(within, fn) {
56
+ if (!within)
57
+ return fn();
58
+ const deadline = Date.now() + within;
59
+ for (;;) {
60
+ try {
61
+ return await fn();
62
+ }
63
+ catch (e) {
64
+ if (Date.now() >= deadline) {
65
+ if (e instanceof Error)
66
+ e.message = `${e.message}\n(still failing after retrying for ${within}ms)`;
67
+ throw e;
68
+ }
69
+ await new Promise((r) => setTimeout(r, 50));
70
+ }
71
+ }
72
+ }
44
73
  async function runStep(step, ctx, vars) {
74
+ if ("checkpoint" in step) {
75
+ await ctx.db.checkpoint();
76
+ return;
77
+ }
78
+ if ("snapshot" in step) {
79
+ expect(await ctx.trace({ keys: step.mask })).toMatchSnapshot();
80
+ return;
81
+ }
82
+ if ("log" in step) {
83
+ const target = step.from ? ctx.service(step.from) : ctx.app;
84
+ await target.waitForLog(new RegExp(interpolate(step.log, vars)), step.within ?? 5000);
85
+ return;
86
+ }
87
+ if ("changes" in step) {
88
+ const actual = await ctx.db.changes();
89
+ const expected = interpolate(step.changes, vars);
90
+ const unexpected = Object.keys(actual).filter((t) => !(t in expected));
91
+ if (unexpected.length) {
92
+ throw new Error(`unexpected changes in ${unexpected.join(", ")}:\n${formatChanges(Object.fromEntries(unexpected.map((t) => [t, actual[t]])))}`);
93
+ }
94
+ for (const [table, spec] of Object.entries(expected)) {
95
+ const got = actual[table] ?? { inserted: [], updated: [], deleted: [] };
96
+ for (const kind of ["inserted", "updated", "deleted"]) {
97
+ const want = spec[kind];
98
+ if (want === undefined)
99
+ continue;
100
+ const rows = kind === "updated" ? got.updated.map((u) => u.after) : got[kind];
101
+ if (typeof want === "number") {
102
+ if (rows.length !== want)
103
+ throw new Error(`expected ${want} row(s) ${kind} in ${table}, got ${rows.length}\n${formatChanges({ [table]: got }) || " (no changes)"}`);
104
+ }
105
+ else
106
+ check(rows, want, vars, `${kind} rows of ${table}`);
107
+ }
108
+ }
109
+ return;
110
+ }
45
111
  if ("stub" in step) {
46
112
  const [method, path] = splitCall(step.on);
47
113
  let route = ctx.stub(step.stub).on(method, interpolate(path, vars), conditions(step.when, vars));