slicetest 0.3.0 → 0.5.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.
Files changed (60) hide show
  1. package/README.md +211 -7
  2. package/dist/auth.d.ts +45 -0
  3. package/dist/auth.js +128 -0
  4. package/dist/ci.d.ts +29 -0
  5. package/dist/ci.js +57 -0
  6. package/dist/cli.js +31 -3
  7. package/dist/config.d.ts +25 -2
  8. package/dist/config.js +25 -4
  9. package/dist/db.d.ts +26 -0
  10. package/dist/db.js +50 -0
  11. package/dist/doctor.d.ts +22 -0
  12. package/dist/doctor.js +190 -0
  13. package/dist/drivers/driver.d.ts +29 -0
  14. package/dist/drivers/index.js +2 -0
  15. package/dist/drivers/mysql.d.ts +2 -1
  16. package/dist/drivers/mysql.js +38 -0
  17. package/dist/drivers/postgres.d.ts +2 -1
  18. package/dist/drivers/postgres.js +28 -0
  19. package/dist/drivers/sqlite.d.ts +24 -0
  20. package/dist/drivers/sqlite.js +255 -0
  21. package/dist/factory.d.ts +21 -0
  22. package/dist/factory.js +129 -0
  23. package/dist/gen.d.ts +1 -0
  24. package/dist/gen.js +9 -4
  25. package/dist/global-setup.js +24 -3
  26. package/dist/http.d.ts +13 -0
  27. package/dist/http.js +24 -0
  28. package/dist/index.d.ts +5 -1
  29. package/dist/index.js +2 -0
  30. package/dist/init.js +123 -5
  31. package/dist/mail.d.ts +58 -0
  32. package/dist/mail.js +299 -0
  33. package/dist/matchers.d.ts +4 -0
  34. package/dist/matchers.js +33 -0
  35. package/dist/openapi.d.ts +9 -0
  36. package/dist/openapi.js +29 -1
  37. package/dist/provided.d.ts +1 -0
  38. package/dist/query-log.d.ts +49 -0
  39. package/dist/query-log.js +261 -0
  40. package/dist/record-cli.d.ts +6 -0
  41. package/dist/record-cli.js +93 -0
  42. package/dist/record-session.d.ts +1 -0
  43. package/dist/record-session.js +10 -0
  44. package/dist/record.d.ts +46 -0
  45. package/dist/record.js +201 -0
  46. package/dist/runtime.d.ts +10 -0
  47. package/dist/runtime.js +62 -3
  48. package/dist/stub.d.ts +32 -0
  49. package/dist/stub.js +87 -0
  50. package/dist/trace.d.ts +9 -1
  51. package/dist/trace.js +3 -1
  52. package/dist/vitest.js +5 -2
  53. package/dist/webhook.d.ts +40 -0
  54. package/dist/webhook.js +52 -0
  55. package/dist/yaml-runtime.d.ts +1 -0
  56. package/dist/yaml-runtime.js +92 -15
  57. package/dist/yaml.d.ts +59 -1
  58. package/dist/yaml.js +88 -5
  59. package/package.json +11 -4
  60. package/schema/scenario.schema.json +329 -1
package/dist/index.d.ts CHANGED
@@ -3,9 +3,13 @@ export { mask } from "./trace.js";
3
3
  export type { Trace, MaskOptions } from "./trace.js";
4
4
  export type { ScenarioContext } from "./runtime.js";
5
5
  export type { Db, Row, Where, RowsOptions, Changes, TableChanges } from "./db.js";
6
- export type { Stub, RecordedCall, StubResponse, Responder, MatchOptions, RouteBuilder } from "./stub.js";
6
+ export type { Stub, RecordedCall, StubResponse, Responder, MatchOptions, RouteBuilder, ChaosOptions } from "./stub.js";
7
7
  export type { HttpClient, HttpResponse, RequestOptions } from "./http.js";
8
8
  export type { App } from "./app.js";
9
9
  export type { Dependency } from "./containers.js";
10
+ export type { Mailbox, Mail, MailFilter } from "./mail.js";
11
+ export type { Issuer, TokenOptions, AuthOptions } from "./auth.js";
12
+ export { normalizeSql, type Query, type QueryList } from "./query-log.js";
13
+ export { signWebhook, type WebhookOptions, type WebhookProvider, type HmacScheme } from "./webhook.js";
10
14
  export type { SlicetestOptions, ContainerOptions } from "./config.js";
11
15
  export type {} from "./matchers.js";
package/dist/index.js CHANGED
@@ -1,2 +1,4 @@
1
1
  export { scenario } from "./scenario.js";
2
2
  export { mask } from "./trace.js";
3
+ export { normalizeSql } from "./query-log.js";
4
+ export { signWebhook } from "./webhook.js";
package/dist/init.js CHANGED
@@ -93,8 +93,27 @@ export async function detect(root) {
93
93
  notes.push("db: no migrations found; the database starts empty. Set db.migrate.");
94
94
  }
95
95
  // --- docker compose: the database image and other dependencies ---
96
- const { db: composeDb, containers, appEnv } = await fromCompose(root, notes);
96
+ const { db: composeDb, containers, appEnv, mail: composeMail } = await fromCompose(root, notes);
97
97
  Object.assign(env, appEnv);
98
+ let mail = composeMail;
99
+ if (!mail && (deps.nodemailer || /\b(flask-mail|fastapi-mail|django-anymail)\b/.test(python) || (has("manage.py") && /EMAIL_HOST/.test(await read(await djangoSettings(root)))))) {
100
+ mail = true;
101
+ Object.assign(env, { SMTP_HOST: "{{mail.host}}", SMTP_PORT: "{{mail.port}}" });
102
+ notes.push("mail: the app sends mail; slicetest catches it over SMTP (SMTP_HOST / SMTP_PORT, rename them to what your app reads)");
103
+ }
104
+ const sqlite = await detectSqlite(root, read, deps, python, gemfile);
105
+ if (sqlite && !composeDb.engine) {
106
+ composeDb.engine = "sqlite";
107
+ env.DATABASE_URL = sqlite.url;
108
+ notes.push(`db: SQLite (${sqlite.why}), no container needed. The app gets DATABASE_URL=${sqlite.url}; {{db.path}} is the plain file path`);
109
+ }
110
+ const auth = await authFromEnvExample(root, read, env, notes);
111
+ const jwtDeps = ["jose", "jsonwebtoken", "passport-jwt", "express-oauth2-jwt-bearer", "express-jwt", "jwks-rsa", "@fastify/jwt", "next-auth"].filter((d) => deps[d]);
112
+ const jwtPython = /\b(pyjwt|python-jose|authlib|fastapi-azure-auth|djangorestframework-simplejwt)\b/i.exec(python)?.[1];
113
+ if (!auth && (jwtDeps.length || jwtPython)) {
114
+ notes.push(`auth: ${jwtDeps[0] ?? jwtPython} is a dependency, so the app may verify JWTs. To test with real tokens, add \`auth: true\` and pass {{auth.issuer}} / {{auth.jwks}} / {{auth.audience}} to the variables the app reads`);
115
+ }
116
+ const stubs = await stubsFromEnvExample(root, read, env, notes);
98
117
  const mysqlDeps = !!deps.mysql2 || !!deps.mysql || /\b(pymysql|mysqlclient|aiomysql)\b/.test(python) || /\bgem ['"]mysql2['"]/.test(gemfile);
99
118
  if (!composeDb.engine && mysqlDeps) {
100
119
  composeDb.engine = "mysql";
@@ -108,11 +127,101 @@ export async function detect(root) {
108
127
  app: { command, env, ready: { path: "/" } },
109
128
  ...(migrate || Object.keys(composeDb).length ? { db: { ...composeDb, ...(migrate ? { migrate } : {}) } } : {}),
110
129
  ...(Object.keys(containers).length ? { containers } : {}),
111
- stubs: [],
130
+ ...(mail ? { mail: true } : {}),
131
+ ...(auth ? { auth: true } : {}),
132
+ stubs,
112
133
  ...(openapi ? { openapi } : {}),
113
134
  };
114
135
  return { config, notes };
115
136
  }
137
+ async function djangoSettings(root) {
138
+ for (const dir of await readdir(root, { withFileTypes: true }).catch(() => [])) {
139
+ if (dir.isDirectory() && existsSync(path.join(root, dir.name, "settings.py")))
140
+ return path.join(dir.name, "settings.py");
141
+ }
142
+ return "settings.py";
143
+ }
144
+ /** Where the project says it uses SQLite, and the URL form its framework reads. */
145
+ async function detectSqlite(root, read, deps, python, gemfile) {
146
+ if (/provider\s*=\s*"sqlite"/.test(await read("prisma/schema.prisma")))
147
+ return { why: "Prisma provider", url: "file:{{db.path}}" };
148
+ if (/adapter:\s*sqlite3/.test(await read("config/database.yml")))
149
+ return { why: "config/database.yml", url: "sqlite3:{{db.path}}" };
150
+ if (/\bgem ['"]sqlite3['"]/.test(gemfile) && !/\bgem ['"](pg|mysql2)['"]/.test(gemfile))
151
+ return { why: "sqlite3 gem", url: "sqlite3:{{db.path}}" };
152
+ if (existsSync(path.join(root, "manage.py")) && /django\.db\.backends\.sqlite3/.test(await read(await djangoSettings(root)))) {
153
+ return { why: "Django settings; make DATABASES read DATABASE_URL, e.g. with dj-database-url", url: "{{db.url}}" };
154
+ }
155
+ const pgOrMysql = deps.pg || deps.postgres || deps.mysql2 || /\b(psycopg|asyncpg|pymysql|mysqlclient)/.test(python);
156
+ if (!pgOrMysql && (deps["better-sqlite3"] || deps.sqlite3 || deps["@libsql/client"]))
157
+ return { why: "a SQLite driver is a dependency", url: "{{db.url}}" };
158
+ return undefined;
159
+ }
160
+ /** Variables that tell the app which token issuer to trust, and what slicetest passes instead. */
161
+ const AUTH_VARS = [
162
+ [/JWKS(_URL|_URI|_ENDPOINT)?$/, "{{auth.jwks}}"],
163
+ [/(ISSUER|ISSUER_URL|ISSUER_BASE_URL|AUTHORITY)$/, "{{auth.issuer}}"],
164
+ [/^(AUTH0|OKTA|KEYCLOAK|COGNITO|OIDC|OAUTH2?|AUTH|JWT)_?(DOMAIN|URL|BASE_URL)$/, "{{auth.issuer}}"],
165
+ [/(AUDIENCE|_AUD)$/, "{{auth.audience}}"],
166
+ ];
167
+ /**
168
+ * `.env.example` variables naming a token issuer (`OIDC_ISSUER`, `AUTH0_DOMAIN`,
169
+ * `JWKS_URL`, `JWT_AUDIENCE`) turn on `auth`, with the variables pointed at
170
+ * slicetest's issuer. Returns whether any was found.
171
+ */
172
+ async function authFromEnvExample(root, read, env, notes) {
173
+ const file = [".env.example", ".env.sample", ".env.template", ".env.dist"].find((f) => existsSync(path.join(root, f)));
174
+ if (!file)
175
+ return false;
176
+ const found = [];
177
+ for (const line of (await read(file)).split(/\r?\n/)) {
178
+ const name = /^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=/.exec(line)?.[1];
179
+ if (!name || name in env)
180
+ continue;
181
+ const match = AUTH_VARS.find(([re]) => re.test(name));
182
+ // AUDIENCE alone doesn't say the app verifies tokens; it comes along with an issuer or JWKS.
183
+ if (!match)
184
+ continue;
185
+ env[name] = match[1];
186
+ found.push(name);
187
+ }
188
+ if (!found.some((n) => env[n] !== "{{auth.audience}}")) {
189
+ for (const n of found)
190
+ delete env[n];
191
+ return false;
192
+ }
193
+ notes.push(`auth: ${found.join(", ")} in ${file} point at slicetest's OpenID issuer; scenarios mint tokens with auth.token({ sub, ... }) or \`auth:\` on a YAML request. An issuer the app expects as a bare domain (Auth0) may need the variable reshaped`);
194
+ return true;
195
+ }
196
+ /** Hosts that are never third-party APIs to stub. */
197
+ const LOCAL_HOST = /^(localhost|127\.|0\.0\.0\.0|\[::1\]|host\.docker\.internal|[\w-]+$)/;
198
+ /**
199
+ * `.env.example` lines like `STRIPE_API_BASE=https://api.stripe.com` name the
200
+ * services the app calls: each becomes a stub recorded from that URL, and the
201
+ * variable points the app at the stub.
202
+ */
203
+ async function stubsFromEnvExample(root, read, env, notes) {
204
+ const file = [".env.example", ".env.sample", ".env.template", ".env.dist"].find((f) => existsSync(path.join(root, f)));
205
+ if (!file)
206
+ return [];
207
+ const stubs = [];
208
+ for (const line of (await read(file)).split(/\r?\n/)) {
209
+ const m = /^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=\s*["']?(https?:\/\/[^\s"'#]+)/.exec(line);
210
+ if (!m || m[1] in env || !/(URL|URI|ENDPOINT|HOST|BASE)$/.test(m[1]) || /DATABASE|REDIS|MONGO|AMQP|SMTP|MAIL|CALLBACK|REDIRECT|FRONTEND|PUBLIC|APP_URL|SITE_URL|WEBHOOK_URL$/.test(m[1]))
211
+ continue;
212
+ const url = new URL(m[2]);
213
+ if (LOCAL_HOST.test(url.hostname))
214
+ continue;
215
+ const base = url.hostname.split(".").filter((p) => !["api", "www", "com", "io", "net", "org", "co", "dev", "app"].includes(p))[0] ?? url.hostname;
216
+ let name = base.replace(/[^\w-]/g, "-").toLowerCase();
217
+ while (stubs.some((s) => s.name === name))
218
+ name += "2";
219
+ stubs.push({ name, upstream: url.origin });
220
+ env[m[1]] = `{{stub.${name}}}${url.pathname.replace(/\/$/, "")}`;
221
+ notes.push(`stubs.${name}: ${m[1]} in ${file} points at ${url.origin}. Record it once with SLICETEST_RECORD=${name}, or register routes in scenarios`);
222
+ }
223
+ return stubs;
224
+ }
116
225
  const SCENARIO = `# yaml-language-server: $schema=https://unpkg.com/slicetest/schema/scenario.schema.json
117
226
  # A first scenario. Run it with: npx slicetest
118
227
  scenarios:
@@ -142,6 +251,8 @@ export async function init(root, { force = false } = {}) {
142
251
  return { files: [configFile, scenarioFile].map((f) => path.relative(root, f)), notes };
143
252
  }
144
253
  const COMPOSE_FILES = ["compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml"];
254
+ /** Development mail servers; `mail: true` does their job in-process. */
255
+ const MAIL_CATCHERS = /(^|\/)(mailpit|mailhog|maildev|smtp4dev|greenmail[\w-]*|mailcatcher|inbucket)(:|$)/;
145
256
  /** Known images: the port they listen on, how to empty them, and the variable apps usually read. */
146
257
  const KNOWN = [
147
258
  { match: /(^|\/)(redis|redis-stack|keydb)(:|$)/, port: 6379, reset: ["redis-cli", "FLUSHALL"], env: (n) => ["REDIS_URL", `redis://{{container.${n}}}`] },
@@ -165,16 +276,17 @@ async function fromCompose(root, notes) {
165
276
  const db = {};
166
277
  const containers = {};
167
278
  const appEnv = {};
279
+ let mail = false;
168
280
  const file = COMPOSE_FILES.find((f) => existsSync(path.join(root, f)));
169
281
  if (!file)
170
- return { db, containers, appEnv };
282
+ return { db, containers, appEnv, mail };
171
283
  let doc;
172
284
  try {
173
285
  doc = (parse(await readFile(path.join(root, file), "utf8")) ?? {});
174
286
  }
175
287
  catch (e) {
176
288
  notes.push(`${file}: couldn't parse it (${e.message}); skipped`);
177
- return { db, containers, appEnv };
289
+ return { db, containers, appEnv, mail };
178
290
  }
179
291
  for (const [name, svc] of Object.entries(doc.services ?? {})) {
180
292
  const image = typeof svc?.image === "string" ? svc.image : undefined;
@@ -194,6 +306,12 @@ async function fromCompose(root, notes) {
194
306
  notes.push(`db: MySQL image ${image} (${file} service "${name}"). Install mysql2 and @testcontainers/mysql next to slicetest.`);
195
307
  continue;
196
308
  }
309
+ if (MAIL_CATCHERS.test(image)) {
310
+ mail = true;
311
+ Object.assign(appEnv, { SMTP_HOST: "{{mail.host}}", SMTP_PORT: "{{mail.port}}" });
312
+ notes.push(`mail: ${image} (${file} service "${name}") is replaced by slicetest's own SMTP server, passed to the app as SMTP_HOST / SMTP_PORT. Rename them to what your app reads`);
313
+ continue;
314
+ }
197
315
  const known = KNOWN.find((k) => k.match.test(image));
198
316
  const port = known?.port ?? containerPort(svc.ports?.[0] ?? svc.expose?.[0]);
199
317
  if (!port) {
@@ -214,7 +332,7 @@ async function fromCompose(root, notes) {
214
332
  appEnv[key] = value;
215
333
  notes.push(`containers.${name}: ${image} (${file})${key ? `, passed to the app as ${key}` : `, at {{container.${name}}}`}${known?.reset ? "" : ". Add `reset` to empty it between scenarios"}`);
216
334
  }
217
- return { db, containers, appEnv };
335
+ return { db, containers, appEnv, mail };
218
336
  }
219
337
  /** `"6379:6379"`, `"127.0.0.1:5432:5432/tcp"`, `9000`, `{ target: 6379 }` → the port inside the container. */
220
338
  export function containerPort(spec) {
package/dist/mail.d.ts ADDED
@@ -0,0 +1,58 @@
1
+ /**
2
+ * An SMTP server that accepts every message and keeps it, so scenarios can
3
+ * check the mail an app sends (sign-up confirmations, password resets,
4
+ * receipts) without a real mail server. Apps in any language reach it at
5
+ * `{{mail.host}}:{{mail.port}}` with no TLS; any credentials are accepted.
6
+ */
7
+ export interface Mail {
8
+ from: string;
9
+ /** Envelope recipients (RCPT TO), which include Cc and Bcc. */
10
+ to: string[];
11
+ subject: string;
12
+ /** The text/plain part, decoded. */
13
+ text: string;
14
+ /** The text/html part, decoded, if there is one. */
15
+ html?: string;
16
+ /** Header names are lower-case. Repeated headers are joined with ", ". */
17
+ headers: Record<string, string>;
18
+ /** Every http(s) URL in the text and HTML parts, in order, without duplicates. */
19
+ links: string[];
20
+ raw: string;
21
+ }
22
+ /**
23
+ * Which messages to look at. Strings match the address exactly (`to`, `from`,
24
+ * case-insensitive) or as a substring (`subject`, `text`, `html`); RegExps test the value.
25
+ */
26
+ export interface MailFilter {
27
+ to?: string | RegExp;
28
+ from?: string | RegExp;
29
+ subject?: string | RegExp;
30
+ text?: string | RegExp;
31
+ html?: string | RegExp;
32
+ }
33
+ export declare class Mailbox {
34
+ #private;
35
+ private readonly server;
36
+ private constructor();
37
+ static start(): Promise<Mailbox>;
38
+ get host(): string;
39
+ get port(): number;
40
+ /** `smtp://127.0.0.1:<port>`, for libraries configured with a URL. */
41
+ get url(): string;
42
+ /** Messages received during this scenario, oldest first. */
43
+ messages(filter?: MailFilter): Mail[];
44
+ /** The most recent message matching `filter`, or undefined. */
45
+ last(filter?: MailFilter): Mail | undefined;
46
+ /**
47
+ * The first message matching `filter`, waiting up to `within` ms for the app
48
+ * to send it. Fails with the messages that did arrive.
49
+ */
50
+ waitFor(filter?: MailFilter, { within }?: {
51
+ within?: number | undefined;
52
+ }): Promise<Mail>;
53
+ /** One line per message received this scenario, for failure output. */
54
+ describe(): string;
55
+ reset(): void;
56
+ close(): Promise<void>;
57
+ }
58
+ export declare function parseMail(raw: string, from: string, to: string[]): Mail;
package/dist/mail.js ADDED
@@ -0,0 +1,299 @@
1
+ import net from "node:net";
2
+ export class Mailbox {
3
+ server;
4
+ #messages = [];
5
+ #waiters = new Set();
6
+ #sockets = new Set();
7
+ constructor(server) {
8
+ this.server = server;
9
+ }
10
+ static async start() {
11
+ const box = new Mailbox(net.createServer((socket) => box.#session(socket)));
12
+ await new Promise((resolve, reject) => {
13
+ box.server.once("error", reject);
14
+ box.server.listen(0, "127.0.0.1", resolve);
15
+ });
16
+ return box;
17
+ }
18
+ get host() {
19
+ return "127.0.0.1";
20
+ }
21
+ get port() {
22
+ return this.server.address().port;
23
+ }
24
+ /** `smtp://127.0.0.1:<port>`, for libraries configured with a URL. */
25
+ get url() {
26
+ return `smtp://${this.host}:${this.port}`;
27
+ }
28
+ /** Messages received during this scenario, oldest first. */
29
+ messages(filter = {}) {
30
+ return this.#messages.filter((m) => matches(m, filter));
31
+ }
32
+ /** The most recent message matching `filter`, or undefined. */
33
+ last(filter = {}) {
34
+ return this.messages(filter).at(-1);
35
+ }
36
+ /**
37
+ * The first message matching `filter`, waiting up to `within` ms for the app
38
+ * to send it. Fails with the messages that did arrive.
39
+ */
40
+ async waitFor(filter = {}, { within = 5000 } = {}) {
41
+ const deadline = Date.now() + within;
42
+ for (;;) {
43
+ const found = this.messages(filter)[0];
44
+ if (found)
45
+ return found;
46
+ const left = deadline - Date.now();
47
+ if (left <= 0)
48
+ throw new Error(`slicetest: no mail matching ${describeFilter(filter)} within ${within}ms\n${this.describe()}`);
49
+ await new Promise((resolve) => {
50
+ const done = () => {
51
+ clearTimeout(timer);
52
+ this.#waiters.delete(done);
53
+ resolve();
54
+ };
55
+ const timer = setTimeout(done, left);
56
+ this.#waiters.add(done);
57
+ });
58
+ }
59
+ }
60
+ /** One line per message received this scenario, for failure output. */
61
+ describe() {
62
+ if (this.#messages.length === 0)
63
+ return "mail sent during this scenario: (none)";
64
+ return `mail sent during this scenario:\n${this.#messages.map((m) => ` ${m.from} → ${m.to.join(", ")}: ${JSON.stringify(m.subject)}`).join("\n")}`;
65
+ }
66
+ reset() {
67
+ this.#messages = [];
68
+ }
69
+ async close() {
70
+ for (const s of this.#sockets)
71
+ s.destroy();
72
+ await new Promise((resolve) => this.server.close(() => resolve()));
73
+ }
74
+ #receive(mail) {
75
+ this.#messages.push(mail);
76
+ for (const w of [...this.#waiters])
77
+ w();
78
+ }
79
+ /** A minimal SMTP (RFC 5321) session: no TLS, AUTH accepted, one message per MAIL … DATA. */
80
+ #session(socket) {
81
+ this.#sockets.add(socket);
82
+ socket.on("close", () => this.#sockets.delete(socket));
83
+ socket.on("error", () => { });
84
+ const reply = (line) => socket.write(`${line}\r\n`);
85
+ let buffer = "";
86
+ let from = "";
87
+ let to = [];
88
+ let data;
89
+ let auth;
90
+ reply("220 slicetest ESMTP");
91
+ socket.setEncoding("utf8");
92
+ socket.on("data", (chunk) => {
93
+ buffer += chunk;
94
+ let i;
95
+ while ((i = buffer.indexOf("\n")) >= 0) {
96
+ const line = buffer.slice(0, i).replace(/\r$/, "");
97
+ buffer = buffer.slice(i + 1);
98
+ if (data) {
99
+ if (line === ".") {
100
+ this.#receive(parseMail(data.join("\r\n"), from, to));
101
+ data = undefined;
102
+ reply("250 OK: queued");
103
+ }
104
+ else
105
+ data.push(line.startsWith(".") ? line.slice(1) : line);
106
+ continue;
107
+ }
108
+ if (auth) {
109
+ auth = auth === "login-user" ? "login-pass" : undefined;
110
+ reply(auth ? "334 UGFzc3dvcmQ6" : "235 Authentication successful");
111
+ continue;
112
+ }
113
+ const [verb = "", ...rest] = line.split(" ");
114
+ const arg = rest.join(" ");
115
+ switch (verb.toUpperCase()) {
116
+ case "EHLO":
117
+ reply("250-slicetest");
118
+ reply("250-AUTH PLAIN LOGIN");
119
+ reply("250-8BITMIME");
120
+ reply("250-SMTPUTF8");
121
+ reply("250 SIZE 52428800");
122
+ break;
123
+ case "HELO":
124
+ reply("250 slicetest");
125
+ break;
126
+ case "AUTH": {
127
+ const [mech = "", initial] = arg.split(" ");
128
+ if (mech.toUpperCase() === "LOGIN") {
129
+ auth = initial ? "login-pass" : "login-user";
130
+ reply(initial ? "334 UGFzc3dvcmQ6" : "334 VXNlcm5hbWU6");
131
+ }
132
+ else if (initial)
133
+ reply("235 Authentication successful");
134
+ else {
135
+ auth = "plain";
136
+ reply("334 ");
137
+ }
138
+ break;
139
+ }
140
+ case "MAIL":
141
+ from = address(arg);
142
+ to = [];
143
+ reply("250 OK");
144
+ break;
145
+ case "RCPT":
146
+ to.push(address(arg));
147
+ reply("250 OK");
148
+ break;
149
+ case "DATA":
150
+ if (to.length === 0)
151
+ reply("503 RCPT first");
152
+ else {
153
+ data = [];
154
+ reply("354 End data with <CR><LF>.<CR><LF>");
155
+ }
156
+ break;
157
+ case "RSET":
158
+ from = "";
159
+ to = [];
160
+ reply("250 OK");
161
+ break;
162
+ case "NOOP":
163
+ reply("250 OK");
164
+ break;
165
+ case "QUIT":
166
+ reply("221 Bye");
167
+ socket.end();
168
+ break;
169
+ default:
170
+ reply(`502 ${verb} not implemented`);
171
+ }
172
+ }
173
+ });
174
+ }
175
+ }
176
+ function address(arg) {
177
+ const m = /<([^>]*)>/.exec(arg) ?? /:\s*(\S+)/.exec(arg);
178
+ return (m?.[1] ?? "").trim();
179
+ }
180
+ function matches(mail, f) {
181
+ const addr = (want, values) => want === undefined || values.some((v) => (typeof want === "string" ? v.toLowerCase() === want.toLowerCase() : test(want, v)));
182
+ const text = (want, value) => want === undefined || (value !== undefined && (typeof want === "string" ? value.includes(want) : test(want, value)));
183
+ return addr(f.to, mail.to) && addr(f.from, [mail.from]) && text(f.subject, mail.subject) && text(f.text, mail.text) && text(f.html, mail.html);
184
+ }
185
+ function test(re, s) {
186
+ re.lastIndex = 0;
187
+ return re.test(s);
188
+ }
189
+ function describeFilter(f) {
190
+ const parts = Object.entries(f).map(([k, v]) => `${k}: ${v instanceof RegExp ? String(v) : JSON.stringify(v)}`);
191
+ return parts.length ? `{ ${parts.join(", ")} }` : "(any)";
192
+ }
193
+ /** Headers are unfolded and lower-cased; the body is everything after the blank line. */
194
+ function splitPart(raw) {
195
+ const end = raw.search(/\r?\n\r?\n/);
196
+ const head = end < 0 ? raw : raw.slice(0, end);
197
+ const body = end < 0 ? "" : raw.slice(end).replace(/^\r?\n\r?\n/, "");
198
+ const headers = {};
199
+ for (const line of head.replace(/\r?\n[ \t]+/g, " ").split(/\r?\n/)) {
200
+ const i = line.indexOf(":");
201
+ if (i <= 0)
202
+ continue;
203
+ const name = line.slice(0, i).trim().toLowerCase();
204
+ const value = line.slice(i + 1).trim();
205
+ headers[name] = headers[name] ? `${headers[name]}, ${value}` : value;
206
+ }
207
+ return { headers, body };
208
+ }
209
+ export function parseMail(raw, from, to) {
210
+ const top = splitPart(raw);
211
+ const texts = [];
212
+ const walk = (part) => {
213
+ const type = (part.headers["content-type"] ?? "text/plain").toLowerCase();
214
+ const boundary = /boundary="?([^";]+)"?/i.exec(part.headers["content-type"] ?? "")?.[1];
215
+ if (type.startsWith("multipart/") && boundary) {
216
+ const pieces = part.body.split(new RegExp(`\\r?\\n?--${escape(boundary)}(?:--)?[ \\t]*\\r?\\n?`));
217
+ for (const piece of pieces.slice(1))
218
+ if (piece.trim())
219
+ walk(splitPart(piece));
220
+ return;
221
+ }
222
+ if (!type.startsWith("text/") || /attachment/i.test(part.headers["content-disposition"] ?? ""))
223
+ return;
224
+ texts.push({ type, text: decodeBody(part) });
225
+ };
226
+ walk(top);
227
+ const text = texts.find((t) => t.type.startsWith("text/plain"))?.text;
228
+ const html = texts.find((t) => t.type.startsWith("text/html"))?.text;
229
+ const headerFrom = /<([^>]+)>/.exec(top.headers.from ?? "")?.[1] ?? top.headers.from;
230
+ return {
231
+ from: from || headerFrom || "",
232
+ to: to.length ? to : (top.headers.to ?? "").split(",").map((a) => (/<([^>]+)>/.exec(a)?.[1] ?? a).trim()).filter(Boolean),
233
+ subject: decodeWords(top.headers.subject ?? ""),
234
+ text: text ?? (html ? htmlToText(html) : ""),
235
+ html,
236
+ headers: Object.fromEntries(Object.entries(top.headers).map(([k, v]) => [k, decodeWords(v)])),
237
+ links: links([text ?? "", html ?? ""].join("\n")),
238
+ raw,
239
+ };
240
+ }
241
+ function charsetOf(part) {
242
+ return /charset="?([^";]+)"?/i.exec(part.headers["content-type"] ?? "")?.[1] ?? "utf-8";
243
+ }
244
+ function decodeBody(part) {
245
+ const enc = (part.headers["content-transfer-encoding"] ?? "").toLowerCase();
246
+ const bytes = enc === "base64" ? Buffer.from(part.body.replace(/\s+/g, ""), "base64")
247
+ : enc === "quoted-printable" ? quotedPrintable(part.body)
248
+ : Buffer.from(part.body, "utf8");
249
+ return decodeCharset(bytes, charsetOf(part)).replace(/\r\n/g, "\n").replace(/\n$/, "");
250
+ }
251
+ function quotedPrintable(s) {
252
+ const bytes = [];
253
+ const src = s.replace(/=\r?\n/g, "");
254
+ for (let i = 0; i < src.length; i++) {
255
+ if (src[i] === "=" && /^[0-9A-F]{2}$/i.test(src.slice(i + 1, i + 3))) {
256
+ bytes.push(parseInt(src.slice(i + 1, i + 3), 16));
257
+ i += 2;
258
+ }
259
+ else
260
+ bytes.push(...Buffer.from(src[i], "utf8"));
261
+ }
262
+ return Buffer.from(bytes);
263
+ }
264
+ function decodeCharset(bytes, charset) {
265
+ try {
266
+ return new TextDecoder(charset).decode(bytes);
267
+ }
268
+ catch {
269
+ return bytes.toString("utf8");
270
+ }
271
+ }
272
+ /** RFC 2047 encoded words: `=?UTF-8?B?...?=` and `=?ISO-2022-JP?Q?...?=`. */
273
+ function decodeWords(s) {
274
+ return s
275
+ .replace(/\?=\s+=\?/g, "?==?")
276
+ .replace(/=\?([^?]+)\?([BbQq])\?([^?]*)\?=/g, (_, charset, enc, text) => {
277
+ const bytes = enc.toUpperCase() === "B" ? Buffer.from(text, "base64") : quotedPrintable(text.replace(/_/g, " "));
278
+ return decodeCharset(bytes, charset);
279
+ });
280
+ }
281
+ function htmlToText(html) {
282
+ return html
283
+ .replace(/<(script|style)[\s\S]*?<\/\1>/gi, "")
284
+ .replace(/<br\s*\/?>|<\/p>/gi, "\n")
285
+ .replace(/<[^>]+>/g, "")
286
+ .replace(/&amp;/g, "&")
287
+ .replace(/&lt;/g, "<")
288
+ .replace(/&gt;/g, ">")
289
+ .replace(/&quot;/g, '"')
290
+ .replace(/&#39;/g, "'")
291
+ .trim();
292
+ }
293
+ function links(s) {
294
+ const found = (s.match(/https?:\/\/[^\s"'<>)\]]+/g) ?? []).map((u) => u.replace(/&amp;/g, "&").replace(/[.,;:!?]+$/, ""));
295
+ return [...new Set(found)];
296
+ }
297
+ function escape(s) {
298
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
299
+ }
@@ -1,4 +1,5 @@
1
1
  import { type Where } from "./db.js";
2
+ import type { HttpResponse } from "./http.js";
2
3
  import { type MatchOptions } from "./stub.js";
3
4
  interface SlicetestMatchers<R = unknown> {
4
5
  /** The stub received at least one call matching `method path` (and `match`, if given). */
@@ -7,6 +8,8 @@ interface SlicetestMatchers<R = unknown> {
7
8
  toHaveReceivedTimes(n: number, method?: string, path?: string | RegExp, match?: MatchOptions): R;
8
9
  /** The response has this status; the failure message shows the response body. */
9
10
  toHaveStatus(status: number): R;
11
+ /** An array of responses has exactly these status counts, e.g. `{ 201: 1, 409: 9 }`. */
12
+ toHaveStatuses(counts: Record<number, number>): R;
10
13
  /** Async: the table has at least one row matching `where` (`count` for an exact number). */
11
14
  toHaveRow(table: string, where?: Where, count?: number): Promise<void>;
12
15
  }
@@ -14,4 +17,5 @@ declare module "vitest" {
14
17
  interface Matchers<R extends void | Promise<void> = void | Promise<void>, T = unknown> extends SlicetestMatchers<R> {
15
18
  }
16
19
  }
20
+ export declare function statusCounts(responses: HttpResponse[]): Record<number, number>;
17
21
  export {};
package/dist/matchers.js CHANGED
@@ -17,6 +17,25 @@ function assertStub(received) {
17
17
  if (!(received instanceof Stub))
18
18
  throw new TypeError("slicetest: expected a stub, e.g. expect(stub(\"slack\"))");
19
19
  }
20
+ export function statusCounts(responses) {
21
+ const out = {};
22
+ for (const r of responses)
23
+ out[r.status] = (out[r.status] ?? 0) + 1;
24
+ return out;
25
+ }
26
+ function fmtCounts(c) {
27
+ return `{ ${Object.entries(c).map(([k, n]) => `${k}: ${n}`).join(", ")} }`;
28
+ }
29
+ /** One example response per status, which is usually what explains the odd one out. */
30
+ function describeResponses(responses) {
31
+ const seen = new Map();
32
+ for (const r of responses)
33
+ if (!seen.has(r.status))
34
+ seen.set(r.status, r);
35
+ return [...seen.values()]
36
+ .map((r) => ` ${r.status || "failed"}: ${r.method} ${r.url} ${(r.text.length > 200 ? `${r.text.slice(0, 200)}…` : r.text).replace(/\s+/g, " ") || "(empty)"}`)
37
+ .join("\n");
38
+ }
20
39
  expect.extend({
21
40
  toHaveReceived(received, method, path, match) {
22
41
  assertStub(received);
@@ -53,6 +72,20 @@ expect.extend({
53
72
  expected: status,
54
73
  };
55
74
  },
75
+ toHaveStatuses(received, counts) {
76
+ if (!Array.isArray(received))
77
+ throw new TypeError("slicetest: toHaveStatuses expects an array of responses, e.g. from http.concurrently()");
78
+ const actual = statusCounts(received);
79
+ const expected = Object.fromEntries(Object.entries(counts).filter(([, n]) => n > 0).map(([k, n]) => [Number(k), n]));
80
+ const pass = this.equals(actual, expected);
81
+ return {
82
+ pass,
83
+ message: () => `expected ${received.length} responses ${this.isNot ? "not " : ""}to have statuses ${fmtCounts(expected)}, got ${fmtCounts(actual)}\n` +
84
+ describeResponses(received),
85
+ actual,
86
+ expected,
87
+ };
88
+ },
56
89
  async toHaveRow(received, table, where = {}, count) {
57
90
  if (!(received instanceof Db))
58
91
  throw new TypeError("slicetest: expected the db handle, e.g. expect(db)");
package/dist/openapi.d.ts CHANGED
@@ -15,6 +15,14 @@ export interface OperationSketch {
15
15
  responses: string[];
16
16
  /** Top-level properties of the first 2xx JSON response. */
17
17
  createdFields: string[];
18
+ /**
19
+ * The operation requires a bearer token (an `http: bearer`, `oauth2` or
20
+ * `openIdConnect` security scheme), with the scopes it lists. Undefined when
21
+ * it needs no token or accepts other credentials only.
22
+ */
23
+ bearer?: {
24
+ scopes: string[];
25
+ };
18
26
  }
19
27
  export interface Message {
20
28
  status?: number;
@@ -69,6 +77,7 @@ export declare function formatCoverage(spec: OpenApiSpec, hits: Set<string>): {
69
77
  covered: number;
70
78
  total: number;
71
79
  percent: number;
80
+ markdown: string;
72
81
  text: string;
73
82
  };
74
83
  export {};