slicetest 0.2.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.
Files changed (54) hide show
  1. package/README.md +188 -9
  2. package/dist/ci.d.ts +29 -0
  3. package/dist/ci.js +57 -0
  4. package/dist/cli.js +52 -3
  5. package/dist/config.d.ts +61 -8
  6. package/dist/config.js +46 -2
  7. package/dist/containers.d.ts +22 -0
  8. package/dist/containers.js +59 -0
  9. package/dist/doctor.d.ts +22 -0
  10. package/dist/doctor.js +190 -0
  11. package/dist/drivers/driver.d.ts +2 -0
  12. package/dist/drivers/index.d.ts +2 -2
  13. package/dist/drivers/index.js +13 -2
  14. package/dist/drivers/mysql.d.ts +29 -0
  15. package/dist/drivers/mysql.js +270 -0
  16. package/dist/drivers/sqlite.d.ts +23 -0
  17. package/dist/drivers/sqlite.js +203 -0
  18. package/dist/gen.d.ts +24 -0
  19. package/dist/gen.js +116 -0
  20. package/dist/global-setup.js +53 -5
  21. package/dist/http.d.ts +6 -0
  22. package/dist/http.js +14 -0
  23. package/dist/index.d.ts +5 -1
  24. package/dist/index.js +1 -0
  25. package/dist/init.d.ts +2 -0
  26. package/dist/init.js +180 -3
  27. package/dist/mail.d.ts +58 -0
  28. package/dist/mail.js +299 -0
  29. package/dist/matchers.d.ts +4 -0
  30. package/dist/matchers.js +33 -0
  31. package/dist/openapi.d.ts +19 -0
  32. package/dist/openapi.js +43 -1
  33. package/dist/provided.d.ts +2 -0
  34. package/dist/record-cli.d.ts +6 -0
  35. package/dist/record-cli.js +93 -0
  36. package/dist/record-session.d.ts +1 -0
  37. package/dist/record-session.js +10 -0
  38. package/dist/record.d.ts +46 -0
  39. package/dist/record.js +201 -0
  40. package/dist/recording.d.ts +43 -0
  41. package/dist/recording.js +148 -0
  42. package/dist/runtime.d.ts +17 -0
  43. package/dist/runtime.js +71 -6
  44. package/dist/stub.d.ts +1 -1
  45. package/dist/stub.js +14 -3
  46. package/dist/trace.d.ts +43 -0
  47. package/dist/trace.js +59 -0
  48. package/dist/vitest.js +5 -2
  49. package/dist/yaml-runtime.d.ts +1 -0
  50. package/dist/yaml-runtime.js +58 -13
  51. package/dist/yaml.d.ts +30 -1
  52. package/dist/yaml.js +41 -5
  53. package/package.json +18 -4
  54. package/schema/scenario.schema.json +173 -1
@@ -1,25 +1,29 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { createHash, randomBytes } from "node:crypto";
3
- import { mkdtemp, readdir, readFile, rm, stat } from "node:fs/promises";
3
+ import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { promisify } from "node:util";
7
+ import { annotation, appendSummary, failureAnnotations, failureSummary, onGitHub, repoPath, yamlFailures } from "./ci.js";
7
8
  import { configureContainerRuntime } from "./container-runtime.js";
8
9
  import { engineFor } from "./drivers/index.js";
10
+ import { coverageCacheFile } from "./gen.js";
9
11
  import { formatCoverage, OpenApiSpec } from "./openapi.js";
12
+ import { mergeRecordings } from "./recording.js";
10
13
  import "./provided.js";
11
14
  const exec = promisify(execFile);
12
15
  /** Runs once per vitest run: start the database server, migrate a template database, hand its location to the workers. */
13
16
  export default async function setup(project) {
14
17
  const opts = project.getProvidedContext().slicetestOptions;
15
- const engine = engineFor(opts);
18
+ const engine = await engineFor(opts);
16
19
  let adminUrl;
17
20
  let stopContainer;
18
21
  if (opts.db.url) {
19
22
  adminUrl = opts.db.url;
20
23
  }
21
24
  else {
22
- configureContainerRuntime();
25
+ if (!engine.local)
26
+ configureContainerRuntime();
23
27
  const container = await engine.startContainer(opts.db.image, opts.db.reuse);
24
28
  adminUrl = container.url;
25
29
  if (!opts.db.reuse)
@@ -45,7 +49,12 @@ export default async function setup(project) {
45
49
  const server = admin;
46
50
  // Each worker writes the documented responses it saw here; they are merged when the run ends.
47
51
  const coverageDir = opts.openapi.app ? await mkdtemp(path.join(os.tmpdir(), "slicetest-coverage-")) : undefined;
48
- project.provide("slicetestDb", { adminUrl, template, prefix, coverageDir });
52
+ // Likewise for recordings made against real services, merged into the recordings files at the end.
53
+ const recording = Object.values(opts.recordings).some((r) => r.record);
54
+ const recordDir = recording ? await mkdtemp(path.join(os.tmpdir(), "slicetest-recordings-")) : undefined;
55
+ // On GitHub Actions, failed YAML steps are collected here and turned into annotations at the end.
56
+ const ciDir = onGitHub() ? await mkdtemp(path.join(os.tmpdir(), "slicetest-ci-")) : undefined;
57
+ project.provide("slicetestDb", { adminUrl, template, prefix, coverageDir, recordDir, ciDir });
49
58
  return async () => {
50
59
  try {
51
60
  if (!stopContainer) {
@@ -59,6 +68,10 @@ export default async function setup(project) {
59
68
  await stopContainer?.();
60
69
  if (coverageDir)
61
70
  await reportCoverage(opts, coverageDir);
71
+ if (recordDir)
72
+ await saveRecordings(opts, recordDir);
73
+ if (ciDir)
74
+ await reportToGitHub(ciDir);
62
75
  }
63
76
  };
64
77
  }
@@ -74,11 +87,18 @@ async function reportCoverage(opts, dir) {
74
87
  return;
75
88
  const spec = await OpenApiSpec.load(path.resolve(opts.root, opts.openapi.app), opts.openapi.app);
76
89
  const report = formatCoverage(spec, hits);
90
+ // For `slicetest gen --uncovered`.
91
+ const cache = coverageCacheFile(opts.root);
92
+ await mkdir(path.dirname(cache), { recursive: true }).then(() => writeFile(cache, JSON.stringify([...hits]))).catch(() => { });
77
93
  console.log(`\n${report.text}\n`);
94
+ await appendSummary(report.markdown);
78
95
  const min = opts.openapi.minCoverage;
79
96
  if (min !== undefined && report.percent < min) {
80
97
  // Not thrown: Vitest reports teardown errors as a crash. The failing exit code is what CI needs.
81
- console.error(`slicetest: OpenAPI coverage ${report.percent}% is below openapi.minCoverage (${min}%)\n`);
98
+ const message = `OpenAPI coverage ${report.percent}% is below openapi.minCoverage (${min}%)`;
99
+ console.error(`slicetest: ${message}\n`);
100
+ if (onGitHub())
101
+ console.log(annotation("error", message, { file: repoPath(path.resolve(opts.root, opts.openapi.app)), title: "slicetest: OpenAPI coverage" }));
82
102
  process.exitCode = 1;
83
103
  }
84
104
  }
@@ -86,6 +106,34 @@ async function reportCoverage(opts, dir) {
86
106
  await rm(dir, { recursive: true, force: true });
87
107
  }
88
108
  }
109
+ async function reportToGitHub(dir) {
110
+ try {
111
+ const failures = await yamlFailures(dir);
112
+ for (const line of failureAnnotations(failures))
113
+ console.log(line);
114
+ await appendSummary(failureSummary(failures));
115
+ }
116
+ finally {
117
+ await rm(dir, { recursive: true, force: true });
118
+ }
119
+ }
120
+ async function saveRecordings(opts, dir) {
121
+ try {
122
+ const files = await readdir(dir);
123
+ for (const [name, r] of Object.entries(opts.recordings)) {
124
+ const added = [];
125
+ for (const f of files.filter((f) => f.startsWith(`${name}.`)).sort())
126
+ added.push(...JSON.parse(await readFile(path.join(dir, f), "utf8")));
127
+ if (added.length === 0)
128
+ continue;
129
+ await mergeRecordings(path.resolve(opts.root, r.file), r.upstream, added);
130
+ console.log(`slicetest: recorded ${added.length} call(s) to ${r.upstream} in ${r.file}`);
131
+ }
132
+ }
133
+ finally {
134
+ await rm(dir, { recursive: true, force: true });
135
+ }
136
+ }
89
137
  const RUN_PREFIX = "slicetest_r";
90
138
  const TEMPLATE_PREFIX = "slicetest_tpl_";
91
139
  const STALE_MS = 24 * 60 * 60 * 1000;
package/dist/http.d.ts CHANGED
@@ -37,6 +37,12 @@ export declare class HttpClient {
37
37
  post(path: string, body?: unknown, opts?: RequestOptions): Promise<HttpResponse>;
38
38
  put(path: string, body?: unknown, opts?: RequestOptions): Promise<HttpResponse>;
39
39
  patch(path: string, body?: unknown, opts?: RequestOptions): Promise<HttpResponse>;
40
+ /**
41
+ * Run `send` `n` times at once and wait for every response, to provoke races
42
+ * (double bookings, lost updates). Pair with `toHaveStatuses({ 201: 1, 409: n - 1 })`
43
+ * and a check of the database. Requests are released together once all are prepared.
44
+ */
45
+ concurrently(n: number, send: (i: number) => Promise<HttpResponse>): Promise<HttpResponse[]>;
40
46
  /** Strings, URLSearchParams, FormData, Blob and byte arrays are sent as-is; anything else is sent as JSON. */
41
47
  request(method: string, path: string, body?: unknown, options?: RequestOptions): Promise<HttpResponse>;
42
48
  /** Call `fn` with every response this client (or one derived with `with()`) receives. */
package/dist/http.js CHANGED
@@ -41,6 +41,20 @@ export class HttpClient {
41
41
  patch(path, body, opts) {
42
42
  return this.request("PATCH", path, body, opts);
43
43
  }
44
+ /**
45
+ * Run `send` `n` times at once and wait for every response, to provoke races
46
+ * (double bookings, lost updates). Pair with `toHaveStatuses({ 201: 1, 409: n - 1 })`
47
+ * and a check of the database. Requests are released together once all are prepared.
48
+ */
49
+ async concurrently(n, send) {
50
+ if (!Number.isInteger(n) || n < 1)
51
+ throw new Error(`slicetest: concurrently() needs a positive number of requests, got ${n}`);
52
+ let release;
53
+ const gate = new Promise((r) => (release = r));
54
+ const all = Promise.all(Array.from({ length: n }, (_, i) => gate.then(() => send(i))));
55
+ release();
56
+ return all;
57
+ }
44
58
  /** Strings, URLSearchParams, FormData, Blob and byte arrays are sent as-is; anything else is sent as JSON. */
45
59
  async request(method, path, body, options = {}) {
46
60
  const opts = merge(this.defaults, options);
package/dist/index.d.ts CHANGED
@@ -1,8 +1,12 @@
1
1
  export { scenario } from "./scenario.js";
2
+ export { mask } from "./trace.js";
3
+ export type { Trace, MaskOptions } from "./trace.js";
2
4
  export type { ScenarioContext } from "./runtime.js";
3
5
  export type { Db, Row, Where, RowsOptions, Changes, TableChanges } from "./db.js";
4
6
  export type { Stub, RecordedCall, StubResponse, Responder, MatchOptions, RouteBuilder } from "./stub.js";
5
7
  export type { HttpClient, HttpResponse, RequestOptions } from "./http.js";
6
8
  export type { App } from "./app.js";
7
- export type { SlicetestOptions } from "./config.js";
9
+ export type { Dependency } from "./containers.js";
10
+ export type { Mailbox, Mail, MailFilter } from "./mail.js";
11
+ export type { SlicetestOptions, ContainerOptions } from "./config.js";
8
12
  export type {} from "./matchers.js";
package/dist/index.js CHANGED
@@ -1 +1,2 @@
1
1
  export { scenario } from "./scenario.js";
2
+ export { mask } from "./trace.js";
package/dist/init.d.ts CHANGED
@@ -16,3 +16,5 @@ export declare function init(root: string, { force }?: {
16
16
  files: string[];
17
17
  notes: string[];
18
18
  }>;
19
+ /** `"6379:6379"`, `"127.0.0.1:5432:5432/tcp"`, `9000`, `{ target: 6379 }` → the port inside the container. */
20
+ export declare function containerPort(spec: unknown): number | undefined;
package/dist/init.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
- import { stringify } from "yaml";
4
+ import { parse, stringify } from "yaml";
5
5
  const TODO_COMMAND = "echo 'TODO: the command that starts your app' && exit 1";
6
6
  export async function detect(root) {
7
7
  const notes = [];
@@ -92,18 +92,93 @@ export async function detect(root) {
92
92
  else {
93
93
  notes.push("db: no migrations found; the database starts empty. Set db.migrate.");
94
94
  }
95
+ // --- docker compose: the database image and other dependencies ---
96
+ const { db: composeDb, containers, appEnv, mail: composeMail } = await fromCompose(root, notes);
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 stubs = await stubsFromEnvExample(root, read, env, notes);
111
+ const mysqlDeps = !!deps.mysql2 || !!deps.mysql || /\b(pymysql|mysqlclient|aiomysql)\b/.test(python) || /\bgem ['"]mysql2['"]/.test(gemfile);
112
+ if (!composeDb.engine && mysqlDeps) {
113
+ composeDb.engine = "mysql";
114
+ notes.push("db: MySQL (a MySQL driver is a dependency). Install mysql2 and @testcontainers/mysql next to slicetest.");
115
+ }
95
116
  // --- OpenAPI ---
96
117
  const openapi = ["openapi.yaml", "openapi.yml", "openapi.json", "docs/openapi.yaml", "docs/openapi.yml", "docs/openapi.json"].find(has);
97
118
  if (openapi)
98
119
  notes.push(`openapi: ${openapi}. Every response will be checked against it.`);
99
120
  const config = {
100
121
  app: { command, env, ready: { path: "/" } },
101
- ...(migrate ? { db: { migrate } } : {}),
102
- stubs: [],
122
+ ...(migrate || Object.keys(composeDb).length ? { db: { ...composeDb, ...(migrate ? { migrate } : {}) } } : {}),
123
+ ...(Object.keys(containers).length ? { containers } : {}),
124
+ ...(mail ? { mail: true } : {}),
125
+ stubs,
103
126
  ...(openapi ? { openapi } : {}),
104
127
  };
105
128
  return { config, notes };
106
129
  }
130
+ async function djangoSettings(root) {
131
+ for (const dir of await readdir(root, { withFileTypes: true }).catch(() => [])) {
132
+ if (dir.isDirectory() && existsSync(path.join(root, dir.name, "settings.py")))
133
+ return path.join(dir.name, "settings.py");
134
+ }
135
+ return "settings.py";
136
+ }
137
+ /** Where the project says it uses SQLite, and the URL form its framework reads. */
138
+ async function detectSqlite(root, read, deps, python, gemfile) {
139
+ if (/provider\s*=\s*"sqlite"/.test(await read("prisma/schema.prisma")))
140
+ return { why: "Prisma provider", url: "file:{{db.path}}" };
141
+ if (/adapter:\s*sqlite3/.test(await read("config/database.yml")))
142
+ return { why: "config/database.yml", url: "sqlite3:{{db.path}}" };
143
+ if (/\bgem ['"]sqlite3['"]/.test(gemfile) && !/\bgem ['"](pg|mysql2)['"]/.test(gemfile))
144
+ return { why: "sqlite3 gem", url: "sqlite3:{{db.path}}" };
145
+ if (existsSync(path.join(root, "manage.py")) && /django\.db\.backends\.sqlite3/.test(await read(await djangoSettings(root)))) {
146
+ return { why: "Django settings; make DATABASES read DATABASE_URL, e.g. with dj-database-url", url: "{{db.url}}" };
147
+ }
148
+ const pgOrMysql = deps.pg || deps.postgres || deps.mysql2 || /\b(psycopg|asyncpg|pymysql|mysqlclient)/.test(python);
149
+ if (!pgOrMysql && (deps["better-sqlite3"] || deps.sqlite3 || deps["@libsql/client"]))
150
+ return { why: "a SQLite driver is a dependency", url: "{{db.url}}" };
151
+ return undefined;
152
+ }
153
+ /** Hosts that are never third-party APIs to stub. */
154
+ const LOCAL_HOST = /^(localhost|127\.|0\.0\.0\.0|\[::1\]|host\.docker\.internal|[\w-]+$)/;
155
+ /**
156
+ * `.env.example` lines like `STRIPE_API_BASE=https://api.stripe.com` name the
157
+ * services the app calls: each becomes a stub recorded from that URL, and the
158
+ * variable points the app at the stub.
159
+ */
160
+ async function stubsFromEnvExample(root, read, env, notes) {
161
+ const file = [".env.example", ".env.sample", ".env.template", ".env.dist"].find((f) => existsSync(path.join(root, f)));
162
+ if (!file)
163
+ return [];
164
+ const stubs = [];
165
+ for (const line of (await read(file)).split(/\r?\n/)) {
166
+ const m = /^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=\s*["']?(https?:\/\/[^\s"'#]+)/.exec(line);
167
+ if (!m || !/(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]))
168
+ continue;
169
+ const url = new URL(m[2]);
170
+ if (LOCAL_HOST.test(url.hostname))
171
+ continue;
172
+ const base = url.hostname.split(".").filter((p) => !["api", "www", "com", "io", "net", "org", "co", "dev", "app"].includes(p))[0] ?? url.hostname;
173
+ let name = base.replace(/[^\w-]/g, "-").toLowerCase();
174
+ while (stubs.some((s) => s.name === name))
175
+ name += "2";
176
+ stubs.push({ name, upstream: url.origin });
177
+ env[m[1]] = `{{stub.${name}}}${url.pathname.replace(/\/$/, "")}`;
178
+ notes.push(`stubs.${name}: ${m[1]} in ${file} points at ${url.origin}. Record it once with SLICETEST_RECORD=${name}, or register routes in scenarios`);
179
+ }
180
+ return stubs;
181
+ }
107
182
  const SCENARIO = `# yaml-language-server: $schema=https://unpkg.com/slicetest/schema/scenario.schema.json
108
183
  # A first scenario. Run it with: npx slicetest
109
184
  scenarios:
@@ -132,3 +207,105 @@ export async function init(root, { force = false } = {}) {
132
207
  await writeFile(scenarioFile, SCENARIO);
133
208
  return { files: [configFile, scenarioFile].map((f) => path.relative(root, f)), notes };
134
209
  }
210
+ const COMPOSE_FILES = ["compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml"];
211
+ /** Development mail servers; `mail: true` does their job in-process. */
212
+ const MAIL_CATCHERS = /(^|\/)(mailpit|mailhog|maildev|smtp4dev|greenmail[\w-]*|mailcatcher|inbucket)(:|$)/;
213
+ /** Known images: the port they listen on, how to empty them, and the variable apps usually read. */
214
+ const KNOWN = [
215
+ { match: /(^|\/)(redis|redis-stack|keydb)(:|$)/, port: 6379, reset: ["redis-cli", "FLUSHALL"], env: (n) => ["REDIS_URL", `redis://{{container.${n}}}`] },
216
+ { match: /(^|\/)valkey(:|$)/, port: 6379, reset: ["valkey-cli", "FLUSHALL"], env: (n) => ["REDIS_URL", `redis://{{container.${n}}}`] },
217
+ { match: /(^|\/)memcached(:|$)/, port: 11211 },
218
+ {
219
+ match: /(^|\/)mongo(:|$)/,
220
+ port: 27017,
221
+ reset: ["mongosh", "--quiet", "--eval", "db.getMongo().getDBNames().filter((n) => !['admin', 'config', 'local'].includes(n)).forEach((n) => db.getSiblingDB(n).dropDatabase())"],
222
+ env: (n) => ["MONGODB_URL", `mongodb://{{container.${n}}}`],
223
+ },
224
+ { match: /(^|\/)(elasticsearch|opensearch)(:|$)/, port: 9200, env: (n) => ["ELASTICSEARCH_URL", `http://{{container.${n}}}`] },
225
+ { match: /(^|\/)minio(:|$)/, port: 9000, env: (n) => ["S3_ENDPOINT", `http://{{container.${n}}}`] },
226
+ { match: /(^|\/)rabbitmq(:|$)/, port: 5672, env: (n) => ["AMQP_URL", `amqp://guest:guest@{{container.${n}}}`] },
227
+ ];
228
+ /**
229
+ * Reads docker compose: a postgres / mysql service sets the database engine
230
+ * and image; Redis, Mongo, MinIO and other images become `containers`.
231
+ */
232
+ async function fromCompose(root, notes) {
233
+ const db = {};
234
+ const containers = {};
235
+ const appEnv = {};
236
+ let mail = false;
237
+ const file = COMPOSE_FILES.find((f) => existsSync(path.join(root, f)));
238
+ if (!file)
239
+ return { db, containers, appEnv, mail };
240
+ let doc;
241
+ try {
242
+ doc = (parse(await readFile(path.join(root, file), "utf8")) ?? {});
243
+ }
244
+ catch (e) {
245
+ notes.push(`${file}: couldn't parse it (${e.message}); skipped`);
246
+ return { db, containers, appEnv, mail };
247
+ }
248
+ for (const [name, svc] of Object.entries(doc.services ?? {})) {
249
+ const image = typeof svc?.image === "string" ? svc.image : undefined;
250
+ if (!image) {
251
+ if (svc?.build)
252
+ notes.push(`${file}: service "${name}" is built from source; if it's the app, app.command replaces it`);
253
+ continue;
254
+ }
255
+ if (/(^|\/)(postgres|postgis)(:|$)/.test(image) || /(^|\/)postgis\//.test(image)) {
256
+ db.image = image;
257
+ notes.push(`db: Postgres image ${image} (${file} service "${name}")`);
258
+ continue;
259
+ }
260
+ if (/(^|\/)(mysql|mariadb)(:|$)/.test(image)) {
261
+ db.engine = "mysql";
262
+ db.image = image;
263
+ notes.push(`db: MySQL image ${image} (${file} service "${name}"). Install mysql2 and @testcontainers/mysql next to slicetest.`);
264
+ continue;
265
+ }
266
+ if (MAIL_CATCHERS.test(image)) {
267
+ mail = true;
268
+ Object.assign(appEnv, { SMTP_HOST: "{{mail.host}}", SMTP_PORT: "{{mail.port}}" });
269
+ 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`);
270
+ continue;
271
+ }
272
+ const known = KNOWN.find((k) => k.match.test(image));
273
+ const port = known?.port ?? containerPort(svc.ports?.[0] ?? svc.expose?.[0]);
274
+ if (!port) {
275
+ notes.push(`${file}: service "${name}" (${image}) exposes no port; skipped`);
276
+ continue;
277
+ }
278
+ const environment = envOf(svc.environment);
279
+ const command = Array.isArray(svc.command) ? svc.command.map(String) : typeof svc.command === "string" ? svc.command.split(/\s+/).filter(Boolean) : undefined;
280
+ containers[name] = {
281
+ image,
282
+ port,
283
+ ...(Object.keys(environment).length ? { env: environment } : {}),
284
+ ...(command?.length ? { command } : {}),
285
+ ...(known?.reset ? { reset: known.reset } : {}),
286
+ };
287
+ const [key, value] = known?.env?.(name) ?? [];
288
+ if (key && value)
289
+ appEnv[key] = value;
290
+ 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"}`);
291
+ }
292
+ return { db, containers, appEnv, mail };
293
+ }
294
+ /** `"6379:6379"`, `"127.0.0.1:5432:5432/tcp"`, `9000`, `{ target: 6379 }` → the port inside the container. */
295
+ export function containerPort(spec) {
296
+ if (spec && typeof spec === "object" && "target" in spec)
297
+ return Number(spec.target) || undefined;
298
+ if (typeof spec !== "string" && typeof spec !== "number")
299
+ return undefined;
300
+ const last = String(spec).split("/")[0].split(":").at(-1);
301
+ const n = Number(last.split("-")[0]);
302
+ return Number.isInteger(n) && n > 0 ? n : undefined;
303
+ }
304
+ function envOf(environment) {
305
+ if (Array.isArray(environment)) {
306
+ return Object.fromEntries(environment.map(String).filter((e) => e.includes("=")).map((e) => [e.slice(0, e.indexOf("=")), e.slice(e.indexOf("=") + 1)]));
307
+ }
308
+ if (environment && typeof environment === "object")
309
+ return Object.fromEntries(Object.entries(environment).map(([k, v]) => [k, String(v ?? "")]));
310
+ return {};
311
+ }
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;