slicetest 0.3.0 → 0.4.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/record.js ADDED
@@ -0,0 +1,201 @@
1
+ import { existsSync } from "node:fs";
2
+ import { writeFile } from "node:fs/promises";
3
+ import http from "node:http";
4
+ import path from "node:path";
5
+ import { stringify } from "yaml";
6
+ import { mask } from "./trace.js";
7
+ export const SESSION_ENV = "SLICETEST_RECORD_SESSION";
8
+ /** Requests a browser makes on its own, not part of what the scenario is about. */
9
+ const ASSET = /\.(ico|png|jpe?g|gif|webp|avif|svg|css|js|mjs|map|woff2?|ttf|eot)(\?|$)/i;
10
+ /** Hop-by-hop headers, and the ones fetch rewrites. */
11
+ const DROP_REQUEST = new Set(["host", "connection", "keep-alive", "transfer-encoding", "upgrade", "proxy-connection", "content-length", "accept-encoding"]);
12
+ const DROP_RESPONSE = new Set(["connection", "keep-alive", "transfer-encoding", "content-encoding", "content-length"]);
13
+ export async function runSession(ctx, session) {
14
+ const exchanges = [];
15
+ const target = ctx.app.url;
16
+ const proxy = http.createServer(async (req, res) => {
17
+ const chunks = [];
18
+ for await (const c of req)
19
+ chunks.push(c);
20
+ const body = Buffer.concat(chunks);
21
+ const headers = new Headers();
22
+ for (const [k, v] of Object.entries(req.headers)) {
23
+ if (v === undefined || DROP_REQUEST.has(k))
24
+ continue;
25
+ for (const value of Array.isArray(v) ? v : [v])
26
+ headers.append(k, value);
27
+ }
28
+ try {
29
+ const upstream = await fetch(new URL(req.url ?? "/", target), {
30
+ method: req.method,
31
+ headers,
32
+ body: body.length && req.method !== "GET" && req.method !== "HEAD" ? body : undefined,
33
+ redirect: "manual",
34
+ });
35
+ const payload = Buffer.from(await upstream.arrayBuffer());
36
+ const out = {};
37
+ upstream.headers.forEach((v, k) => {
38
+ if (!DROP_RESPONSE.has(k) && k !== "set-cookie")
39
+ out[k] = v;
40
+ });
41
+ const cookies = upstream.headers.getSetCookie();
42
+ if (cookies.length)
43
+ out["set-cookie"] = cookies;
44
+ res.writeHead(upstream.status, out).end(payload);
45
+ exchanges.push({
46
+ method: req.method ?? "GET",
47
+ path: req.url ?? "/",
48
+ contentType: req.headers["content-type"],
49
+ body: body.toString("utf8"),
50
+ status: upstream.status,
51
+ responseType: upstream.headers.get("content-type") ?? undefined,
52
+ response: payload.toString("utf8"),
53
+ });
54
+ }
55
+ catch (e) {
56
+ res.writeHead(502, { "content-type": "text/plain" }).end(`slicetest record: the app didn't answer: ${e.message}\n`);
57
+ }
58
+ });
59
+ await new Promise((resolve, reject) => {
60
+ proxy.once("error", reject);
61
+ proxy.listen(session.port, "127.0.0.1", resolve);
62
+ });
63
+ const url = `http://127.0.0.1:${proxy.address().port}`;
64
+ await writeFile(session.stateFile, JSON.stringify({ url, app: target }));
65
+ while (!existsSync(session.stopFile))
66
+ await new Promise((r) => setTimeout(r, 200));
67
+ await new Promise((resolve) => proxy.close(() => resolve()));
68
+ proxy.closeAllConnections();
69
+ const calls = Object.fromEntries(session.stubs.map((name) => [name, ctx.stub(name).calls()]));
70
+ const changes = await ctx.db.changes();
71
+ const { yaml, summary } = buildScenario(exchanges, calls, changes, { name: path.basename(session.out).replace(/\.scenario\.ya?ml$/, "") });
72
+ await writeFile(session.out, yaml);
73
+ await writeFile(session.stateFile, JSON.stringify({ url, done: true, summary }));
74
+ }
75
+ /** The recorded session as a YAML scenario, with a comment header of what to review. */
76
+ export function buildScenario(exchanges, calls, changes, { name = "recorded session", now = new Date() } = {}) {
77
+ const kept = exchanges.filter((x) => !ASSET.test(x.path));
78
+ const steps = [];
79
+ const notes = [];
80
+ // Stubs first: the answers the services gave, so the replay doesn't need them.
81
+ const unanswered = [];
82
+ let stubRoutes = 0;
83
+ for (const [stub, list] of Object.entries(calls)) {
84
+ const groups = new Map();
85
+ for (const c of list) {
86
+ if (!c.response || c.response.status === 501) {
87
+ unanswered.push(`${stub}: ${c.method} ${c.path}`);
88
+ continue;
89
+ }
90
+ const key = `${c.method} ${c.path}`;
91
+ groups.set(key, [...(groups.get(key) ?? []), c]);
92
+ }
93
+ for (const [on, group] of groups) {
94
+ const replies = group.map((c) => ({ status: c.response.status, ...(c.response.body ? { body: parse(c.response.body, c.response.headers["content-type"]) } : {}) }));
95
+ const same = replies.every((r) => JSON.stringify(r) === JSON.stringify(replies[0]));
96
+ steps.push({ stub, on, ...(same ? { reply: replies[0] } : { sequence: replies }) });
97
+ stubRoutes++;
98
+ }
99
+ }
100
+ if (unanswered.length)
101
+ notes.push(`These calls got no answer while recording (register a route, or give the stub an upstream / autoReply): ${unanswered.join(", ")}`);
102
+ // Values from earlier responses that later requests use (ids in paths, tokens in bodies) become captures.
103
+ const captured = new Map();
104
+ kept.forEach((x, i) => {
105
+ let reqPath = x.path;
106
+ let reqBody = x.body;
107
+ for (const [value, variable] of captured) {
108
+ reqPath = reqPath.split(value).join(`{{${variable}}}`);
109
+ reqBody = reqBody.split(value).join(`{{${variable}}}`);
110
+ }
111
+ const [method, pathOnly] = [x.method, reqPath];
112
+ const step = { request: `${method} ${pathOnly}` };
113
+ if (x.body) {
114
+ if (/json/i.test(x.contentType ?? ""))
115
+ step.json = parse(reqBody, "application/json");
116
+ else if (/x-www-form-urlencoded/i.test(x.contentType ?? ""))
117
+ step.form = Object.fromEntries(new URLSearchParams(reqBody));
118
+ else
119
+ step.body = reqBody;
120
+ }
121
+ const expectation = { status: x.status };
122
+ const json = /json/i.test(x.responseType ?? "") ? parse(x.response, "application/json") : undefined;
123
+ if (json !== undefined && typeof json === "object")
124
+ expectation.json = loosen(json);
125
+ else if (!/html/i.test(x.responseType ?? "") && x.response && x.response.length <= 200)
126
+ expectation.text = x.response;
127
+ step.expect = expectation;
128
+ // Capture top-level values that show up again later.
129
+ if (json && typeof json === "object" && !Array.isArray(json)) {
130
+ const later = kept.slice(i + 1).map((y) => `${y.path}\n${y.body}`).join("\n");
131
+ const capture = {};
132
+ for (const [key, value] of Object.entries(json)) {
133
+ if ((typeof value !== "string" && typeof value !== "number") || String(value).length < 3 || !later.includes(String(value)))
134
+ continue;
135
+ let variable = key.replace(/\W/g, "_");
136
+ while ([...captured.values()].includes(variable))
137
+ variable += "_";
138
+ capture[variable] = `json.${key}`;
139
+ captured.set(String(value), variable);
140
+ }
141
+ if (Object.keys(capture).length)
142
+ step.capture = capture;
143
+ }
144
+ steps.push(step);
145
+ });
146
+ for (const [stub, list] of Object.entries(calls)) {
147
+ const byCall = new Map();
148
+ for (const c of list)
149
+ if (c.response && c.response.status !== 501)
150
+ byCall.set(`${c.method} ${c.path}`, (byCall.get(`${c.method} ${c.path}`) ?? 0) + 1);
151
+ for (const [call, times] of byCall)
152
+ steps.push({ received: stub, call, times });
153
+ }
154
+ const tables = Object.keys(changes);
155
+ const counts = Object.fromEntries(Object.entries(changes).map(([table, c]) => [
156
+ table,
157
+ Object.fromEntries(Object.entries({ inserted: c.inserted.length, updated: c.updated.length, deleted: c.deleted.length }).filter(([, n]) => n > 0)),
158
+ ]));
159
+ steps.push({ changes: counts });
160
+ const skipped = exchanges.length - kept.length;
161
+ if (skipped)
162
+ notes.push(`${skipped} request(s) for static files (scripts, styles, images) were left out.`);
163
+ if (kept.length === 0)
164
+ notes.push("No requests to the app were recorded; send them to the proxy URL printed when recording started.");
165
+ const header = [
166
+ `# Recorded with \`npx slicetest record\` on ${now.toISOString().slice(0, 10)}. Review it before committing:`,
167
+ "# - give the scenario a name that says what it checks, and split it if it checks several things;",
168
+ "# - dates and UUIDs in responses are matched by type only, and `changes` only counts rows;",
169
+ "# tighten what matters (see https://github.com/revo1290/slicetest#yaml-scenarios).",
170
+ ...notes.map((n) => `# - ${n}`),
171
+ "# yaml-language-server: $schema=https://unpkg.com/slicetest/schema/scenario.schema.json",
172
+ "",
173
+ ].join("\n");
174
+ const yaml = header + stringify({ scenarios: [{ name, steps }] }, { lineWidth: 0 });
175
+ const summary = { requests: kept.length, skippedAssets: skipped, stubs: stubRoutes, tables, unanswered };
176
+ return { yaml, summary };
177
+ }
178
+ function parse(text, contentType) {
179
+ if (/json/i.test(contentType ?? "")) {
180
+ try {
181
+ return JSON.parse(text);
182
+ }
183
+ catch {
184
+ return text;
185
+ }
186
+ }
187
+ return text;
188
+ }
189
+ /** Dates and UUIDs change from run to run: expect their type instead of their value. */
190
+ function loosen(value) {
191
+ const walk = (v) => {
192
+ if (v === "[date]" || v === "[uuid]")
193
+ return { $type: "string" };
194
+ if (Array.isArray(v))
195
+ return v.map(walk);
196
+ if (v && typeof v === "object")
197
+ return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)]));
198
+ return v;
199
+ };
200
+ return walk(mask(value));
201
+ }
package/dist/runtime.d.ts CHANGED
@@ -3,6 +3,7 @@ import type { ResolvedOptions } from "./config.js";
3
3
  import { Dependency } from "./containers.js";
4
4
  import { Db } from "./db.js";
5
5
  import { HttpClient } from "./http.js";
6
+ import { Mailbox } from "./mail.js";
6
7
  import { Stub } from "./stub.js";
7
8
  import { type MaskOptions, type Trace } from "./trace.js";
8
9
  export interface ScenarioContext {
@@ -19,6 +20,8 @@ export interface ScenarioContext {
19
20
  * with timestamps and UUIDs masked. `expect(await trace()).toMatchSnapshot()`.
20
21
  */
21
22
  trace: (opts?: MaskOptions) => Promise<Trace>;
23
+ /** Mail the app sent during the scenario. Needs `mail: true` in the config. */
24
+ mail: Mailbox;
22
25
  }
23
26
  /** Everything one test file needs: its own database, stub servers and app process. */
24
27
  export declare class Runtime {
@@ -34,6 +37,7 @@ export declare class Runtime {
34
37
  private readonly recorders;
35
38
  private readonly recordDir?;
36
39
  readonly containers: Map<string, Dependency>;
40
+ readonly mailbox?: Mailbox | undefined;
37
41
  private constructor();
38
42
  get http(): HttpClient;
39
43
  static start(opts: ResolvedOptions, shared: {
package/dist/runtime.js CHANGED
@@ -6,6 +6,7 @@ import { Dependency } from "./containers.js";
6
6
  import { Db, formatChanges } from "./db.js";
7
7
  import { engineFor } from "./drivers/index.js";
8
8
  import { formatHistory, HttpClient } from "./http.js";
9
+ import { Mailbox } from "./mail.js";
9
10
  import { OpenApiSpec } from "./openapi.js";
10
11
  import { Recorder } from "./recording.js";
11
12
  import { Stub } from "./stub.js";
@@ -23,12 +24,13 @@ export class Runtime {
23
24
  recorders;
24
25
  recordDir;
25
26
  containers;
27
+ mailbox;
26
28
  #http;
27
29
  /** Responses from the app that don't match its OpenAPI spec, this scenario. */
28
30
  #contract = [];
29
31
  /** Documented responses seen in this file, for the run's coverage report. */
30
32
  #covered = new Set();
31
- constructor(app, services, db, stubs, opts, vars, specs, coverageDir, recorders = new Map(), recordDir, containers = new Map()) {
33
+ constructor(app, services, db, stubs, opts, vars, specs, coverageDir, recorders = new Map(), recordDir, containers = new Map(), mailbox) {
32
34
  this.app = app;
33
35
  this.services = services;
34
36
  this.db = db;
@@ -40,6 +42,7 @@ export class Runtime {
40
42
  this.recorders = recorders;
41
43
  this.recordDir = recordDir;
42
44
  this.containers = containers;
45
+ this.mailbox = mailbox;
43
46
  this.#http = this.#client();
44
47
  }
45
48
  #client() {
@@ -72,6 +75,7 @@ export class Runtime {
72
75
  const containers = new Map();
73
76
  const services = new Map();
74
77
  let db;
78
+ let mailbox;
75
79
  try {
76
80
  // Loaded first: a broken spec should fail before anything is started.
77
81
  const specs = {
@@ -102,6 +106,12 @@ export class Runtime {
102
106
  if (failed)
103
107
  throw failed.reason;
104
108
  const vars = { "db.url": url };
109
+ if (engine.name === "sqlite")
110
+ vars["db.path"] = (await import("./drivers/sqlite.js")).sqlitePath(url);
111
+ if (opts.mail) {
112
+ mailbox = await Mailbox.start();
113
+ Object.assign(vars, { "mail.host": mailbox.host, "mail.port": String(mailbox.port), "mail.url": mailbox.url });
114
+ }
105
115
  for (const [name, c] of containers) {
106
116
  vars[`container.${name}`] = c.address;
107
117
  vars[`container.${name}.host`] = c.host;
@@ -116,17 +126,19 @@ export class Runtime {
116
126
  vars[`service.${name}.port`] = String(started.port);
117
127
  }
118
128
  const app = await App.start(opts.app, opts.root, vars);
119
- return new Runtime(app, services, db, stubs, opts, vars, specs, shared.coverageDir, recorders, shared.recordDir, containers);
129
+ return new Runtime(app, services, db, stubs, opts, vars, specs, shared.coverageDir, recorders, shared.recordDir, containers, mailbox);
120
130
  }
121
131
  catch (e) {
122
132
  await Promise.all([...services.values()].map((s) => s.stop()));
123
133
  await db?.close();
124
134
  await Promise.all([...stubs.values()].map((s) => s.close()));
125
135
  await Promise.allSettled([...containers.values()].map((c) => c.stop()));
136
+ await mailbox?.close();
126
137
  throw e;
127
138
  }
128
139
  }
129
140
  context() {
141
+ const mailbox = this.mailbox;
130
142
  return {
131
143
  http: this.http,
132
144
  db: this.db,
@@ -151,7 +163,12 @@ export class Runtime {
151
163
  throw new Error(`slicetest: unknown container "${name}". Declared containers: ${[...this.containers.keys()].join(", ") || "(none)"}`);
152
164
  return c;
153
165
  },
154
- trace: async (opts) => mask(buildTrace(this.http.history, this.stubs.values(), await this.db.changesSinceStart()), opts),
166
+ trace: async (opts) => mask(buildTrace(this.http.history, this.stubs.values(), await this.db.changesSinceStart(), this.mailbox), opts),
167
+ get mail() {
168
+ if (!mailbox)
169
+ throw new Error("slicetest: mail is off. Add `mail: true` to the config and point the app's SMTP settings at {{mail.host}} / {{mail.port}}.");
170
+ return mailbox;
171
+ },
155
172
  };
156
173
  }
157
174
  /** The app and every service, for checks that apply to all of them. */
@@ -177,6 +194,7 @@ export class Runtime {
177
194
  stub.reset();
178
195
  for (const recorder of this.recorders.values())
179
196
  recorder.reset();
197
+ this.mailbox?.reset();
180
198
  await Promise.all([...this.containers.values()].map((c) => c.reset()));
181
199
  this.http.reset();
182
200
  for (const p of this.#processes())
@@ -259,6 +277,8 @@ export class Runtime {
259
277
  catch (e) {
260
278
  sections.push(`database changes during this scenario: unavailable (${e.message})`);
261
279
  }
280
+ if (this.mailbox)
281
+ sections.push(this.mailbox.describe());
262
282
  const logs = this.app.scenarioLogs();
263
283
  sections.push(logs ? `app output during this scenario:\n${logs}` : "app output during this scenario: (none)");
264
284
  for (const [name, service] of this.services) {
@@ -282,6 +302,7 @@ export class Runtime {
282
302
  this.db.close(),
283
303
  ...[...this.stubs.values()].map((s) => s.close()),
284
304
  ...[...this.containers.values()].map((c) => c.stop()),
305
+ this.mailbox?.close(),
285
306
  ]);
286
307
  const failed = results.find((r) => r.status === "rejected");
287
308
  if (failed)
package/dist/trace.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Changes } from "./db.js";
2
2
  import type { HttpResponse } from "./http.js";
3
+ import type { Mailbox } from "./mail.js";
3
4
  import type { Stub } from "./stub.js";
4
5
  /**
5
6
  * Everything one scenario did at the three boundaries: the requests it made to
@@ -19,6 +20,13 @@ export interface Trace {
19
20
  status?: number;
20
21
  }[]>;
21
22
  db: Changes;
23
+ /** Mail the app sent, when the `mail` option is on. */
24
+ mail?: {
25
+ from: string;
26
+ to: string[];
27
+ subject: string;
28
+ text: string;
29
+ }[];
22
30
  }
23
31
  export interface MaskOptions {
24
32
  /** Keys whose values are always replaced, e.g. `["token", "password_hash"]`. Matched at any depth. */
@@ -32,4 +40,4 @@ export interface MaskOptions {
32
40
  * `[uuid]`, and the values of `keys` / strings matching `patterns` by `[masked]`.
33
41
  */
34
42
  export declare function mask<T>(value: T, opts?: MaskOptions): T;
35
- export declare function buildTrace(history: readonly HttpResponse[], stubs: Iterable<Stub>, db: Changes): Trace;
43
+ export declare function buildTrace(history: readonly HttpResponse[], stubs: Iterable<Stub>, db: Changes, mailbox?: Mailbox): Trace;
package/dist/trace.js CHANGED
@@ -30,7 +30,7 @@ export function mask(value, opts = {}) {
30
30
  };
31
31
  return walk(value);
32
32
  }
33
- export function buildTrace(history, stubs, db) {
33
+ export function buildTrace(history, stubs, db, mailbox) {
34
34
  const trace = {
35
35
  http: history.map((r) => {
36
36
  const url = new URL(r.url, "http://app");
@@ -53,5 +53,7 @@ export function buildTrace(history, stubs, db) {
53
53
  };
54
54
  });
55
55
  }
56
+ if (mailbox)
57
+ trace.mail = mailbox.messages().map((m) => ({ from: m.from, to: m.to, subject: m.subject, text: m.text }));
56
58
  return trace;
57
59
  }
package/dist/vitest.js CHANGED
@@ -2,6 +2,7 @@ import path from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
3
  import { configDefaults } from "vitest/config";
4
4
  import { resolveOptions } from "./config.js";
5
+ import { SESSION_ENV } from "./record.js";
5
6
  import { parseScenarioFile } from "./yaml.js";
6
7
  /** YAML scenario files are picked up next to the regular test files. */
7
8
  export const YAML_SCENARIOS = "**/*.scenario.{yaml,yml}";
@@ -17,7 +18,9 @@ export function slicetest(options) {
17
18
  const root = path.resolve(config.root ?? process.cwd());
18
19
  // Mutated rather than returned: a returned list would replace Vitest's default include instead of extending it.
19
20
  const test = (config.test ??= {});
20
- test.include = [...(test.include ?? configDefaults.include), YAML_SCENARIOS];
21
+ // `slicetest record` runs only its session file.
22
+ if (!process.env[SESSION_ENV])
23
+ test.include = [...(test.include ?? configDefaults.include), YAML_SCENARIOS];
21
24
  return {
22
25
  test: {
23
26
  globalSetup: [path.join(here, `global-setup${ext}`)],
@@ -31,7 +34,7 @@ export function slicetest(options) {
31
34
  const file = id.split("?")[0];
32
35
  if (!YAML_ID.test(file))
33
36
  return;
34
- const doc = parseScenarioFile(code, path.relative(root ?? process.cwd(), file) || file);
37
+ const doc = { ...parseScenarioFile(code, path.relative(root ?? process.cwd(), file) || file), path: file };
35
38
  const runtime = JSON.stringify(path.join(here, `yaml-runtime${ext}`));
36
39
  return { code: `import { defineYamlScenarios } from ${runtime};\ndefineYamlScenarios(${JSON.stringify(doc)});\n`, map: null };
37
40
  },
@@ -1,3 +1,4 @@
1
+ import "./provided.js";
1
2
  import "./matchers.js";
2
3
  import type { YamlFile } from "./yaml.js";
3
4
  type Vars = Record<string, unknown>;
@@ -1,4 +1,6 @@
1
- import { expect } from "vitest";
1
+ import { expect, inject } from "vitest";
2
+ import { recordYamlFailure } from "./ci.js";
3
+ import "./provided.js";
2
4
  import { formatChanges } from "./db.js";
3
5
  import "./matchers.js";
4
6
  import { scenario } from "./scenario.js";
@@ -17,11 +19,17 @@ async function runSteps(doc, sc, steps, ctx, vars) {
17
19
  for (const [i, step] of steps.entries()) {
18
20
  try {
19
21
  // 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));
22
+ // Log and mail steps wait by themselves; `within` is their timeout.
23
+ await retry("within" in step && !("log" in step) && !("mail" in step) ? step.within : undefined, () => runStep(step, ctx, vars));
21
24
  }
22
25
  catch (e) {
23
26
  const label = step.name ?? describeStep(step);
24
27
  const where = `${doc.file}:${step.line} (${sc.name}, step ${i + 1}: ${label})`;
28
+ const ciDir = inject("slicetestDb")?.ciDir;
29
+ if (ciDir && doc.path) {
30
+ const message = (e instanceof Error ? e.message : String(e)).replace(/\x1b\[[0-9;]*m/g, "");
31
+ await recordYamlFailure(ciDir, { file: doc.path, line: step.line, scenario: sc.name, step: `step ${i + 1}: ${label}`, message }).catch(() => { });
32
+ }
25
33
  if (e instanceof Error) {
26
34
  e.message = `${where}\n${e.message}`;
27
35
  throw e;
@@ -32,7 +40,7 @@ async function runSteps(doc, sc, steps, ctx, vars) {
32
40
  }
33
41
  function describeStep(step) {
34
42
  if ("request" in step)
35
- return step.request;
43
+ return step.concurrency ? `${step.request} ×${step.concurrency}` : step.request;
36
44
  if ("stub" in step)
37
45
  return `stub ${step.stub} ${step.on}`;
38
46
  if ("received" in step)
@@ -47,6 +55,8 @@ function describeStep(step) {
47
55
  return "checkpoint";
48
56
  if ("snapshot" in step)
49
57
  return "snapshot";
58
+ if ("mail" in step)
59
+ return `mail${Object.entries(step.mail).map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`).join(",")}`;
50
60
  if ("log" in step)
51
61
  return `log ${step.from ? `from ${step.from} ` : ""}/${step.log}/`;
52
62
  return "sql";
@@ -84,6 +94,24 @@ async function runStep(step, ctx, vars) {
84
94
  await target.waitForLog(new RegExp(interpolate(step.log, vars)), step.within ?? 5000);
85
95
  return;
86
96
  }
97
+ if ("mail" in step) {
98
+ const filter = Object.fromEntries(Object.entries(interpolate(step.mail, vars)).map(([k, v]) => {
99
+ const re = v && typeof v === "object" && "$regex" in v ? new RegExp(String(v.$regex)) : undefined;
100
+ return [k, re ?? String(v)];
101
+ }));
102
+ const box = ctx.mail;
103
+ await retry(step.within ?? 5000, async () => {
104
+ const found = box.messages(filter);
105
+ if (step.times === undefined ? found.length === 0 : found.length !== step.times) {
106
+ const want = step.times === undefined ? "at least one message" : `${step.times} message(s)`;
107
+ throw new Error(`expected ${want} matching ${JSON.stringify(step.mail)}, got ${found.length}\n${box.describe()}`);
108
+ }
109
+ });
110
+ const last = box.last(filter);
111
+ if (last)
112
+ capture(step.capture, last, vars);
113
+ return;
114
+ }
87
115
  if ("changes" in step) {
88
116
  const actual = await ctx.db.changes();
89
117
  const expected = interpolate(step.changes, vars);
@@ -141,18 +169,29 @@ async function runStep(step, ctx, vars) {
141
169
  : step.form !== undefined
142
170
  ? ctx.http.form(interpolate(step.form, vars))
143
171
  : interpolate(step.body, vars);
144
- const res = await ctx.http.request(method, path, body, opts);
145
172
  const e = step.expect;
146
- if (e?.status !== undefined)
147
- expect(res).toHaveStatus(interpolate(e.status, vars));
148
- if (e?.headers !== undefined) {
149
- const expected = Object.fromEntries(Object.entries(e.headers).map(([k, v]) => [k.toLowerCase(), v]));
150
- check(Object.fromEntries(res.headers), expected, vars, "response headers");
173
+ const verify = (res) => {
174
+ if (e?.status !== undefined)
175
+ expect(res).toHaveStatus(interpolate(e.status, vars));
176
+ if (e?.headers !== undefined) {
177
+ const expected = Object.fromEntries(Object.entries(e.headers).map(([k, v]) => [k.toLowerCase(), v]));
178
+ check(Object.fromEntries(res.headers), expected, vars, "response headers");
179
+ }
180
+ if (e?.json !== undefined)
181
+ check(res.json, e.json, vars, "response JSON");
182
+ if (e?.text !== undefined)
183
+ check(res.text, e.text, vars, "response text");
184
+ };
185
+ if (step.concurrency !== undefined) {
186
+ // Each request gets its own body: a URLSearchParams body can only be read once.
187
+ const all = await ctx.http.concurrently(step.concurrency, () => ctx.http.request(method, path, body instanceof URLSearchParams ? new URLSearchParams(body) : body, opts));
188
+ if (e?.statuses !== undefined)
189
+ expect(all).toHaveStatuses(interpolate(e.statuses, vars));
190
+ all.forEach(verify);
191
+ return;
151
192
  }
152
- if (e?.json !== undefined)
153
- check(res.json, e.json, vars, "response JSON");
154
- if (e?.text !== undefined)
155
- check(res.text, e.text, vars, "response text");
193
+ const res = await ctx.http.request(method, path, body, opts);
194
+ verify(res);
156
195
  capture(step.capture, { status: res.status, json: res.json, text: res.text, headers: Object.fromEntries(res.headers) }, vars);
157
196
  return;
158
197
  }
package/dist/yaml.d.ts CHANGED
@@ -6,6 +6,8 @@
6
6
  */
7
7
  export interface YamlFile {
8
8
  file: string;
9
+ /** Absolute path, for CI annotations. */
10
+ path?: string;
9
11
  /** Steps run at the start of every scenario in the file. */
10
12
  setup: Step[];
11
13
  scenarios: YamlScenario[];
@@ -19,7 +21,7 @@ export interface YamlScenario {
19
21
  timeout?: number;
20
22
  steps: Step[];
21
23
  }
22
- export type Step = (StubStep | RequestStep | InsertStep | SqlStep | DbStep | ReceivedStep | ChangesStep | CheckpointStep | LogStep | SnapshotStep) & {
24
+ export type Step = (StubStep | RequestStep | InsertStep | SqlStep | DbStep | ReceivedStep | ChangesStep | CheckpointStep | LogStep | SnapshotStep | MailStep) & {
23
25
  line: number;
24
26
  name?: string;
25
27
  };
@@ -55,8 +57,11 @@ export interface RequestStep {
55
57
  form?: Record<string, unknown>;
56
58
  body?: string;
57
59
  follow?: boolean;
60
+ /** Send the request this many times at once. `expect` applies to every response; `statuses` counts them. */
61
+ concurrency?: number;
58
62
  expect?: {
59
63
  status?: number;
64
+ statuses?: Record<string, number>;
60
65
  headers?: Record<string, unknown>;
61
66
  json?: unknown;
62
67
  text?: unknown;
@@ -129,6 +134,24 @@ export interface SnapshotStep {
129
134
  /** Keys whose values are masked in addition to dates and UUIDs. */
130
135
  mask?: string[];
131
136
  }
137
+ /**
138
+ * The app sent mail matching `mail:` (strings are exact addresses for to/from and substrings
139
+ * otherwise; `{ $regex }` also works). Waits up to `within` ms (default 5000). `capture` reads
140
+ * the last match: `subject`, `text`, `links.0`.
141
+ */
142
+ export interface MailStep {
143
+ mail: {
144
+ to?: unknown;
145
+ from?: unknown;
146
+ subject?: unknown;
147
+ text?: unknown;
148
+ html?: unknown;
149
+ };
150
+ /** Exact number of matching messages. Default: at least one. */
151
+ times?: number;
152
+ within?: number;
153
+ capture?: Record<string, string>;
154
+ }
132
155
  export declare class YamlScenarioError extends Error {
133
156
  }
134
157
  export declare function parseScenarioFile(text: string, file: string): YamlFile;