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.
@@ -1,16 +1,20 @@
1
1
  import { execFile } from "node:child_process";
2
- import { randomBytes } from "node:crypto";
3
- import { readdir, readFile, stat } from "node:fs/promises";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
4
+ import os from "node:os";
4
5
  import path from "node:path";
5
6
  import { promisify } from "node:util";
6
- import pg from "pg";
7
7
  import { configureContainerRuntime } from "./container-runtime.js";
8
- import { withDatabase } from "./db.js";
8
+ import { engineFor } from "./drivers/index.js";
9
+ import { coverageCacheFile } from "./gen.js";
10
+ import { formatCoverage, OpenApiSpec } from "./openapi.js";
11
+ import { mergeRecordings } from "./recording.js";
9
12
  import "./provided.js";
10
13
  const exec = promisify(execFile);
11
- /** Runs once per vitest run: start Postgres, migrate a template database, hand its location to the workers. */
14
+ /** Runs once per vitest run: start the database server, migrate a template database, hand its location to the workers. */
12
15
  export default async function setup(project) {
13
16
  const opts = project.getProvidedContext().slicetestOptions;
17
+ const engine = await engineFor(opts);
14
18
  let adminUrl;
15
19
  let stopContainer;
16
20
  if (opts.db.url) {
@@ -18,65 +22,190 @@ export default async function setup(project) {
18
22
  }
19
23
  else {
20
24
  configureContainerRuntime();
21
- const { PostgreSqlContainer } = await import("@testcontainers/postgresql");
22
- const container = await new PostgreSqlContainer(opts.db.image).start();
23
- adminUrl = container.getConnectionUri();
24
- stopContainer = () => container.stop();
25
+ const container = await engine.startContainer(opts.db.image, opts.db.reuse);
26
+ adminUrl = container.url;
27
+ if (!opts.db.reuse)
28
+ stopContainer = container.stop;
25
29
  }
26
30
  // Unique per run and project, so parallel runs and projects sharing one server never collide.
27
- const prefix = `slicetest_${randomBytes(4).toString("hex")}`;
28
- const template = `${prefix}_template`;
29
- const admin = new pg.Client({ connectionString: adminUrl });
31
+ // The timestamp lets a later run recognise databases left behind by a run that was killed.
32
+ const prefix = `${RUN_PREFIX}${Date.now().toString(36)}_${randomBytes(3).toString("hex")}`;
33
+ let admin;
34
+ let template;
30
35
  try {
31
- await admin.connect();
32
- await admin.query(`CREATE DATABASE "${template}"`);
33
- await migrate(opts, withDatabase(adminUrl, template));
36
+ admin = await engine.admin(adminUrl);
37
+ if (!stopContainer)
38
+ await dropStale(admin);
39
+ const key = opts.db.reuse ? await migrationKey(opts) : undefined;
40
+ template = key ? await cachedTemplate(admin, engine, opts, key) : await freshTemplate(admin, engine, opts, prefix);
34
41
  }
35
42
  catch (e) {
36
- await admin.end().catch(() => { });
43
+ await admin?.close().catch(() => { });
37
44
  await stopContainer?.();
38
45
  throw e;
39
46
  }
40
- project.provide("slicetestDb", { adminUrl, template, prefix });
47
+ const server = admin;
48
+ // Each worker writes the documented responses it saw here; they are merged when the run ends.
49
+ const coverageDir = opts.openapi.app ? await mkdtemp(path.join(os.tmpdir(), "slicetest-coverage-")) : undefined;
50
+ // Likewise for recordings made against real services, merged into the recordings files at the end.
51
+ const recording = Object.values(opts.recordings).some((r) => r.record);
52
+ const recordDir = recording ? await mkdtemp(path.join(os.tmpdir(), "slicetest-recordings-")) : undefined;
53
+ project.provide("slicetestDb", { adminUrl, template, prefix, coverageDir, recordDir });
41
54
  return async () => {
42
55
  try {
43
56
  if (!stopContainer) {
44
57
  // Shared server: drop only the databases this run created.
45
- const { rows } = await admin.query("SELECT datname FROM pg_database WHERE starts_with(datname, $1)", [`${prefix}_`]);
46
- for (const { datname } of rows)
47
- await admin.query(`DROP DATABASE "${datname}" WITH (FORCE)`);
58
+ for (const name of await server.databases(`${prefix}_`))
59
+ await server.drop(name);
48
60
  }
49
61
  }
50
62
  finally {
51
- await admin.end();
63
+ await server.close();
52
64
  await stopContainer?.();
65
+ if (coverageDir)
66
+ await reportCoverage(opts, coverageDir);
67
+ if (recordDir)
68
+ await saveRecordings(opts, recordDir);
53
69
  }
54
70
  };
55
71
  }
56
- async function migrate(opts, url) {
72
+ async function reportCoverage(opts, dir) {
73
+ try {
74
+ const hits = new Set();
75
+ for (const file of await readdir(dir)) {
76
+ for (const key of JSON.parse(await readFile(path.join(dir, file), "utf8")))
77
+ hits.add(key);
78
+ }
79
+ // No scenario ran (e.g. everything filtered out): nothing to report.
80
+ if (hits.size === 0)
81
+ return;
82
+ const spec = await OpenApiSpec.load(path.resolve(opts.root, opts.openapi.app), opts.openapi.app);
83
+ const report = formatCoverage(spec, hits);
84
+ // For `slicetest gen --uncovered`.
85
+ const cache = coverageCacheFile(opts.root);
86
+ await mkdir(path.dirname(cache), { recursive: true }).then(() => writeFile(cache, JSON.stringify([...hits]))).catch(() => { });
87
+ console.log(`\n${report.text}\n`);
88
+ const min = opts.openapi.minCoverage;
89
+ if (min !== undefined && report.percent < min) {
90
+ // Not thrown: Vitest reports teardown errors as a crash. The failing exit code is what CI needs.
91
+ console.error(`slicetest: OpenAPI coverage ${report.percent}% is below openapi.minCoverage (${min}%)\n`);
92
+ process.exitCode = 1;
93
+ }
94
+ }
95
+ finally {
96
+ await rm(dir, { recursive: true, force: true });
97
+ }
98
+ }
99
+ async function saveRecordings(opts, dir) {
100
+ try {
101
+ const files = await readdir(dir);
102
+ for (const [name, r] of Object.entries(opts.recordings)) {
103
+ const added = [];
104
+ for (const f of files.filter((f) => f.startsWith(`${name}.`)).sort())
105
+ added.push(...JSON.parse(await readFile(path.join(dir, f), "utf8")));
106
+ if (added.length === 0)
107
+ continue;
108
+ await mergeRecordings(path.resolve(opts.root, r.file), r.upstream, added);
109
+ console.log(`slicetest: recorded ${added.length} call(s) to ${r.upstream} in ${r.file}`);
110
+ }
111
+ }
112
+ finally {
113
+ await rm(dir, { recursive: true, force: true });
114
+ }
115
+ }
116
+ const RUN_PREFIX = "slicetest_r";
117
+ const TEMPLATE_PREFIX = "slicetest_tpl_";
118
+ const STALE_MS = 24 * 60 * 60 * 1000;
119
+ async function freshTemplate(admin, engine, opts, prefix) {
120
+ const template = `${prefix}_template`;
121
+ await admin.create(template);
122
+ await migrate(opts, engine, admin.urlFor(template));
123
+ return template;
124
+ }
125
+ /**
126
+ * The template for these migrations, built once and kept on the server. The
127
+ * lock makes concurrent runs wait for the first one instead of building their own.
128
+ */
129
+ async function cachedTemplate(admin, engine, opts, key) {
130
+ const name = `${TEMPLATE_PREFIX}${key}`;
131
+ return admin.withLock(name, async () => {
132
+ if ((await admin.databases(name)).includes(name))
133
+ return name;
134
+ await admin.create(name);
135
+ try {
136
+ await migrate(opts, engine, admin.urlFor(name));
137
+ }
138
+ catch (e) {
139
+ // Never leave a half-migrated template behind for later runs to reuse.
140
+ await admin.drop(name).catch(() => { });
141
+ throw e;
142
+ }
143
+ return name;
144
+ });
145
+ }
146
+ /**
147
+ * A hash of everything that determines the migrated schema, or undefined when
148
+ * that can't be known (a migration command without `inputs`).
149
+ */
150
+ export async function migrationKey(opts) {
151
+ const m = opts.db.migrate;
152
+ const hash = createHash("sha256").update(`v1\0${opts.db.image}\0${JSON.stringify(m ?? null)}\0`);
153
+ const inputs = !m ? [] : "atlas" in m ? [atlasDirPath(m.atlas.dir, opts.root)] : "sql" in m ? [path.resolve(opts.root, m.sql)] : m.inputs?.map((p) => path.resolve(opts.root, p));
154
+ if (!inputs)
155
+ return undefined;
156
+ for (const input of inputs) {
157
+ for (const file of await filesUnder(input)) {
158
+ hash.update(`${path.relative(opts.root, file).replace(/\\/g, "/")}\0`);
159
+ hash.update(await readFile(file));
160
+ hash.update("\0");
161
+ }
162
+ }
163
+ return hash.digest("hex").slice(0, 20);
164
+ }
165
+ async function filesUnder(target) {
166
+ const info = await stat(target).catch(() => undefined);
167
+ if (!info)
168
+ throw new Error(`slicetest: migration input not found: ${target}`);
169
+ if (!info.isDirectory())
170
+ return [target];
171
+ const entries = await readdir(target, { recursive: true, withFileTypes: true });
172
+ return entries
173
+ .filter((e) => e.isFile())
174
+ .map((e) => path.join(e.parentPath, e.name))
175
+ .sort();
176
+ }
177
+ function atlasDirPath(dir, root) {
178
+ return path.resolve(root, dir.replace(/^file:\/\//, ""));
179
+ }
180
+ /** Databases from runs that were killed before cleaning up, recognised by the timestamp in their name. */
181
+ async function dropStale(admin) {
182
+ for (const datname of await admin.databases(RUN_PREFIX)) {
183
+ const started = parseInt(datname.slice(RUN_PREFIX.length).split("_")[0], 36);
184
+ if (Number.isFinite(started) && Date.now() - started > STALE_MS) {
185
+ await admin.drop(datname).catch(() => { });
186
+ }
187
+ }
188
+ }
189
+ async function migrate(opts, engine, url) {
57
190
  const m = opts.db.migrate;
58
191
  if (!m)
59
192
  return;
60
193
  if ("atlas" in m) {
61
194
  const dir = atlasDirUrl(m.atlas.dir, opts.root);
62
- const u = new URL(url);
63
- if (!u.searchParams.has("sslmode"))
64
- u.searchParams.set("sslmode", "disable");
65
- await run("atlas", ["migrate", "apply", "--url", u.toString(), "--dir", dir], opts.root);
195
+ await run("atlas", ["migrate", "apply", "--url", engine.atlasUrl(url), "--dir", dir], opts.root);
66
196
  }
67
197
  else if ("sql" in m) {
68
198
  const target = path.resolve(opts.root, m.sql);
69
199
  const files = (await stat(target)).isDirectory()
70
200
  ? (await readdir(target)).filter((f) => f.endsWith(".sql")).sort().map((f) => path.join(target, f))
71
201
  : [target];
72
- const client = new pg.Client({ connectionString: url });
73
- await client.connect();
202
+ const driver = await engine.driver(url);
74
203
  try {
75
204
  for (const file of files)
76
- await client.query(await readFile(file, "utf8"));
205
+ await driver.exec(await readFile(file, "utf8"));
77
206
  }
78
207
  finally {
79
- await client.end();
208
+ await driver.close();
80
209
  }
81
210
  }
82
211
  else {
package/dist/http.d.ts CHANGED
@@ -19,6 +19,7 @@ export interface RequestOptions {
19
19
  declare class Session {
20
20
  cookies: Map<string, string>;
21
21
  history: HttpResponse[];
22
+ listeners: ((res: HttpResponse) => void)[];
22
23
  }
23
24
  /** HTTP client bound to the app under test. Keeps cookies for the duration of a scenario. */
24
25
  export declare class HttpClient {
@@ -38,6 +39,8 @@ export declare class HttpClient {
38
39
  patch(path: string, body?: unknown, opts?: RequestOptions): Promise<HttpResponse>;
39
40
  /** Strings, URLSearchParams, FormData, Blob and byte arrays are sent as-is; anything else is sent as JSON. */
40
41
  request(method: string, path: string, body?: unknown, options?: RequestOptions): Promise<HttpResponse>;
42
+ /** Call `fn` with every response this client (or one derived with `with()`) receives. */
43
+ onResponse(fn: (res: HttpResponse) => void): void;
41
44
  /** Cookies the app has set during this scenario. Mutations are sent with later requests. */
42
45
  get cookies(): Map<string, string>;
43
46
  clearCookies(): void;
package/dist/http.js CHANGED
@@ -2,6 +2,7 @@
2
2
  class Session {
3
3
  cookies = new Map();
4
4
  history = [];
5
+ listeners = [];
5
6
  }
6
7
  const HISTORY = 20;
7
8
  /** HTTP client bound to the app under test. Keeps cookies for the duration of a scenario. */
@@ -96,8 +97,14 @@ export class HttpClient {
96
97
  durationMs: Math.round(performance.now() - started),
97
98
  };
98
99
  this.#record(out);
100
+ for (const listener of this.#session.listeners)
101
+ listener(out);
99
102
  return out;
100
103
  }
104
+ /** Call `fn` with every response this client (or one derived with `with()`) receives. */
105
+ onResponse(fn) {
106
+ this.#session.listeners.push(fn);
107
+ }
101
108
  #record(res) {
102
109
  this.#session.history.push(res);
103
110
  if (this.#session.history.length > HISTORY)
package/dist/index.d.ts CHANGED
@@ -1,7 +1,11 @@
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
- export type { Db, Row, Where, RowsOptions } from "./db.js";
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 { SlicetestOptions, ContainerOptions } from "./config.js";
11
+ 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 ADDED
@@ -0,0 +1,20 @@
1
+ import type { CliConfig } from "./cli.js";
2
+ /**
3
+ * `npx slicetest init`: look at a project and write a starting
4
+ * slicetest.config.yaml plus one scenario. Detection is best effort; every
5
+ * guess is listed so the user knows what to check.
6
+ */
7
+ export interface Detected {
8
+ config: CliConfig;
9
+ /** One line per guess, e.g. "app: package.json has a start script". */
10
+ notes: string[];
11
+ }
12
+ export declare function detect(root: string): Promise<Detected>;
13
+ export declare function init(root: string, { force }?: {
14
+ force?: boolean | undefined;
15
+ }): Promise<{
16
+ files: string[];
17
+ notes: string[];
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 ADDED
@@ -0,0 +1,236 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { parse, stringify } from "yaml";
5
+ const TODO_COMMAND = "echo 'TODO: the command that starts your app' && exit 1";
6
+ export async function detect(root) {
7
+ const notes = [];
8
+ const has = (p) => existsSync(path.join(root, p));
9
+ const read = async (p) => (has(p) ? readFile(path.join(root, p), "utf8") : "");
10
+ const pkg = has("package.json") ? JSON.parse(await read("package.json")) : undefined;
11
+ const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
12
+ const python = `${await read("requirements.txt")}\n${await read("pyproject.toml")}`.toLowerCase();
13
+ const gemfile = await read("Gemfile");
14
+ // --- app ---
15
+ let command = TODO_COMMAND;
16
+ const env = { PORT: "{{app.port}}", DATABASE_URL: "{{db.url}}" };
17
+ if (pkg?.scripts?.start) {
18
+ command = "npm start";
19
+ notes.push("app: `npm start` (package.json start script). It must listen on $PORT.");
20
+ }
21
+ else if (pkg?.scripts?.dev) {
22
+ command = "npm run dev";
23
+ notes.push("app: `npm run dev` (no start script). A production start command is usually faster to boot.");
24
+ }
25
+ else if (has("manage.py")) {
26
+ command = "python manage.py runserver 127.0.0.1:{{app.port}} --noreload";
27
+ notes.push("app: Django (manage.py)");
28
+ }
29
+ else if (python.includes("uvicorn") || python.includes("fastapi")) {
30
+ command = "uvicorn main:app --port {{app.port}}";
31
+ notes.push("app: FastAPI/uvicorn. Adjust `main:app` to your module.");
32
+ }
33
+ else if (python.includes("flask")) {
34
+ command = "flask run --port {{app.port}}";
35
+ notes.push("app: Flask");
36
+ }
37
+ else if (/\brails\b/.test(gemfile)) {
38
+ command = "bin/rails server -p {{app.port}}";
39
+ notes.push("app: Rails");
40
+ }
41
+ else if (has("go.mod")) {
42
+ command = "go run .";
43
+ notes.push("app: Go (go.mod). It must listen on $PORT.");
44
+ }
45
+ else if (has("Cargo.toml")) {
46
+ command = "cargo run";
47
+ notes.push("app: Rust (Cargo.toml). It must listen on $PORT.");
48
+ }
49
+ else {
50
+ notes.push("app: couldn't tell how to start the app. Set app.command.");
51
+ }
52
+ // --- migrations ---
53
+ let migrate;
54
+ const migrationsSql = has("migrations") && (await readdir(path.join(root, "migrations"))).some((f) => f.endsWith(".sql"));
55
+ if (has("atlas.hcl") || has("migrations/atlas.sum")) {
56
+ migrate = { atlas: { dir: "file://migrations" } };
57
+ notes.push("db: Atlas migrations in migrations/");
58
+ }
59
+ else if (has("prisma/schema.prisma")) {
60
+ migrate = { command: "npx prisma migrate deploy", inputs: ["prisma/migrations"] };
61
+ notes.push("db: Prisma (prisma migrate deploy)");
62
+ }
63
+ else if (has("alembic.ini")) {
64
+ const location = /^\s*script_location\s*=\s*(\S+)/m.exec(await read("alembic.ini"))?.[1] ?? "alembic";
65
+ migrate = { command: "alembic upgrade head", inputs: [path.posix.join(location.replace("%(here)s/", ""), "versions")] };
66
+ notes.push("db: Alembic (alembic upgrade head)");
67
+ }
68
+ else if (has("manage.py")) {
69
+ migrate = { command: "python manage.py migrate" };
70
+ notes.push("db: Django migrations. Add `inputs` (your apps' migrations dirs) to cache them between runs.");
71
+ }
72
+ else if (/\brails\b/.test(gemfile) && has("db/migrate")) {
73
+ migrate = { command: "bin/rails db:migrate", inputs: ["db/migrate"] };
74
+ notes.push("db: Rails migrations");
75
+ }
76
+ else if (deps["drizzle-kit"]) {
77
+ migrate = { command: "npx drizzle-kit migrate", inputs: ["drizzle"] };
78
+ notes.push("db: Drizzle (drizzle-kit migrate). Check that `inputs` points at your migrations folder.");
79
+ }
80
+ else if (deps.knex) {
81
+ migrate = { command: "npx knex migrate:latest", inputs: ["migrations"] };
82
+ notes.push("db: Knex migrations");
83
+ }
84
+ else if (migrationsSql) {
85
+ migrate = { sql: "migrations" };
86
+ notes.push("db: plain SQL files in migrations/, applied in name order");
87
+ }
88
+ else if (has("schema.sql")) {
89
+ migrate = { sql: "schema.sql" };
90
+ notes.push("db: schema.sql");
91
+ }
92
+ else {
93
+ notes.push("db: no migrations found; the database starts empty. Set db.migrate.");
94
+ }
95
+ // --- docker compose: the database image and other dependencies ---
96
+ const { db: composeDb, containers, appEnv } = await fromCompose(root, notes);
97
+ Object.assign(env, appEnv);
98
+ const mysqlDeps = !!deps.mysql2 || !!deps.mysql || /\b(pymysql|mysqlclient|aiomysql)\b/.test(python) || /\bgem ['"]mysql2['"]/.test(gemfile);
99
+ if (!composeDb.engine && mysqlDeps) {
100
+ composeDb.engine = "mysql";
101
+ notes.push("db: MySQL (a MySQL driver is a dependency). Install mysql2 and @testcontainers/mysql next to slicetest.");
102
+ }
103
+ // --- OpenAPI ---
104
+ const openapi = ["openapi.yaml", "openapi.yml", "openapi.json", "docs/openapi.yaml", "docs/openapi.yml", "docs/openapi.json"].find(has);
105
+ if (openapi)
106
+ notes.push(`openapi: ${openapi}. Every response will be checked against it.`);
107
+ const config = {
108
+ app: { command, env, ready: { path: "/" } },
109
+ ...(migrate || Object.keys(composeDb).length ? { db: { ...composeDb, ...(migrate ? { migrate } : {}) } } : {}),
110
+ ...(Object.keys(containers).length ? { containers } : {}),
111
+ stubs: [],
112
+ ...(openapi ? { openapi } : {}),
113
+ };
114
+ return { config, notes };
115
+ }
116
+ const SCENARIO = `# yaml-language-server: $schema=https://unpkg.com/slicetest/schema/scenario.schema.json
117
+ # A first scenario. Run it with: npx slicetest
118
+ scenarios:
119
+ - name: the app answers
120
+ steps:
121
+ - request: GET /
122
+ expect: { status: 200 }
123
+ `;
124
+ export async function init(root, { force = false } = {}) {
125
+ const configFile = path.join(root, "slicetest.config.yaml");
126
+ const scenarioFile = path.join(root, "scenarios", "smoke.scenario.yaml");
127
+ const existing = [configFile, scenarioFile].filter((f) => existsSync(f));
128
+ if (existing.length && !force) {
129
+ throw new Error(`slicetest: ${existing.map((f) => path.relative(root, f)).join(", ")} already exists. Use --force to overwrite.`);
130
+ }
131
+ const { config, notes } = await detect(root);
132
+ const header = [
133
+ "# slicetest config, generated by `npx slicetest init`. Paths are relative to this file.",
134
+ "# Everything the Vitest plugin accepts works here: https://github.com/revo1290/slicetest#configuration-reference",
135
+ "#",
136
+ ...notes.map((n) => `# - ${n}`),
137
+ "",
138
+ ].join("\n");
139
+ await writeFile(configFile, `${header}${stringify(config)}`);
140
+ await mkdir(path.dirname(scenarioFile), { recursive: true });
141
+ await writeFile(scenarioFile, SCENARIO);
142
+ return { files: [configFile, scenarioFile].map((f) => path.relative(root, f)), notes };
143
+ }
144
+ const COMPOSE_FILES = ["compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml"];
145
+ /** Known images: the port they listen on, how to empty them, and the variable apps usually read. */
146
+ const KNOWN = [
147
+ { match: /(^|\/)(redis|redis-stack|keydb)(:|$)/, port: 6379, reset: ["redis-cli", "FLUSHALL"], env: (n) => ["REDIS_URL", `redis://{{container.${n}}}`] },
148
+ { match: /(^|\/)valkey(:|$)/, port: 6379, reset: ["valkey-cli", "FLUSHALL"], env: (n) => ["REDIS_URL", `redis://{{container.${n}}}`] },
149
+ { match: /(^|\/)memcached(:|$)/, port: 11211 },
150
+ {
151
+ match: /(^|\/)mongo(:|$)/,
152
+ port: 27017,
153
+ reset: ["mongosh", "--quiet", "--eval", "db.getMongo().getDBNames().filter((n) => !['admin', 'config', 'local'].includes(n)).forEach((n) => db.getSiblingDB(n).dropDatabase())"],
154
+ env: (n) => ["MONGODB_URL", `mongodb://{{container.${n}}}`],
155
+ },
156
+ { match: /(^|\/)(elasticsearch|opensearch)(:|$)/, port: 9200, env: (n) => ["ELASTICSEARCH_URL", `http://{{container.${n}}}`] },
157
+ { match: /(^|\/)minio(:|$)/, port: 9000, env: (n) => ["S3_ENDPOINT", `http://{{container.${n}}}`] },
158
+ { match: /(^|\/)rabbitmq(:|$)/, port: 5672, env: (n) => ["AMQP_URL", `amqp://guest:guest@{{container.${n}}}`] },
159
+ ];
160
+ /**
161
+ * Reads docker compose: a postgres / mysql service sets the database engine
162
+ * and image; Redis, Mongo, MinIO and other images become `containers`.
163
+ */
164
+ async function fromCompose(root, notes) {
165
+ const db = {};
166
+ const containers = {};
167
+ const appEnv = {};
168
+ const file = COMPOSE_FILES.find((f) => existsSync(path.join(root, f)));
169
+ if (!file)
170
+ return { db, containers, appEnv };
171
+ let doc;
172
+ try {
173
+ doc = (parse(await readFile(path.join(root, file), "utf8")) ?? {});
174
+ }
175
+ catch (e) {
176
+ notes.push(`${file}: couldn't parse it (${e.message}); skipped`);
177
+ return { db, containers, appEnv };
178
+ }
179
+ for (const [name, svc] of Object.entries(doc.services ?? {})) {
180
+ const image = typeof svc?.image === "string" ? svc.image : undefined;
181
+ if (!image) {
182
+ if (svc?.build)
183
+ notes.push(`${file}: service "${name}" is built from source; if it's the app, app.command replaces it`);
184
+ continue;
185
+ }
186
+ if (/(^|\/)(postgres|postgis)(:|$)/.test(image) || /(^|\/)postgis\//.test(image)) {
187
+ db.image = image;
188
+ notes.push(`db: Postgres image ${image} (${file} service "${name}")`);
189
+ continue;
190
+ }
191
+ if (/(^|\/)(mysql|mariadb)(:|$)/.test(image)) {
192
+ db.engine = "mysql";
193
+ db.image = image;
194
+ notes.push(`db: MySQL image ${image} (${file} service "${name}"). Install mysql2 and @testcontainers/mysql next to slicetest.`);
195
+ continue;
196
+ }
197
+ const known = KNOWN.find((k) => k.match.test(image));
198
+ const port = known?.port ?? containerPort(svc.ports?.[0] ?? svc.expose?.[0]);
199
+ if (!port) {
200
+ notes.push(`${file}: service "${name}" (${image}) exposes no port; skipped`);
201
+ continue;
202
+ }
203
+ const environment = envOf(svc.environment);
204
+ const command = Array.isArray(svc.command) ? svc.command.map(String) : typeof svc.command === "string" ? svc.command.split(/\s+/).filter(Boolean) : undefined;
205
+ containers[name] = {
206
+ image,
207
+ port,
208
+ ...(Object.keys(environment).length ? { env: environment } : {}),
209
+ ...(command?.length ? { command } : {}),
210
+ ...(known?.reset ? { reset: known.reset } : {}),
211
+ };
212
+ const [key, value] = known?.env?.(name) ?? [];
213
+ if (key && value)
214
+ appEnv[key] = value;
215
+ 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
+ }
217
+ return { db, containers, appEnv };
218
+ }
219
+ /** `"6379:6379"`, `"127.0.0.1:5432:5432/tcp"`, `9000`, `{ target: 6379 }` → the port inside the container. */
220
+ export function containerPort(spec) {
221
+ if (spec && typeof spec === "object" && "target" in spec)
222
+ return Number(spec.target) || undefined;
223
+ if (typeof spec !== "string" && typeof spec !== "number")
224
+ return undefined;
225
+ const last = String(spec).split("/")[0].split(":").at(-1);
226
+ const n = Number(last.split("-")[0]);
227
+ return Number.isInteger(n) && n > 0 ? n : undefined;
228
+ }
229
+ function envOf(environment) {
230
+ if (Array.isArray(environment)) {
231
+ return Object.fromEntries(environment.map(String).filter((e) => e.includes("=")).map((e) => [e.slice(0, e.indexOf("=")), e.slice(e.indexOf("=") + 1)]));
232
+ }
233
+ if (environment && typeof environment === "object")
234
+ return Object.fromEntries(Object.entries(environment).map(([k, v]) => [k, String(v ?? "")]));
235
+ return {};
236
+ }
@@ -0,0 +1,74 @@
1
+ interface Operation {
2
+ /** `paths` key, e.g. `/polls/{id}`. */
3
+ template: string;
4
+ method: string;
5
+ op: Record<string, any>;
6
+ }
7
+ export interface OperationSketch {
8
+ method: string;
9
+ template: string;
10
+ summary?: string;
11
+ pathParams: Record<string, unknown>;
12
+ query: Record<string, unknown>;
13
+ json?: unknown;
14
+ /** Documented response keys, e.g. `201`, `4XX`, `default`. */
15
+ responses: string[];
16
+ /** Top-level properties of the first 2xx JSON response. */
17
+ createdFields: string[];
18
+ }
19
+ export interface Message {
20
+ status?: number;
21
+ contentType?: string;
22
+ /** Parsed JSON when the body is JSON, else the raw text. */
23
+ body: unknown;
24
+ query?: URLSearchParams;
25
+ }
26
+ /**
27
+ * An OpenAPI 3.0 / 3.1 document, used to check that real traffic matches it:
28
+ * the app's responses against the app's own spec, and the app's calls to a
29
+ * stubbed service (and the stub's canned replies) against that service's spec.
30
+ */
31
+ export declare class OpenApiSpec {
32
+ #private;
33
+ readonly file: string;
34
+ private readonly doc;
35
+ private constructor();
36
+ /** `file` is read; `label` (default: `file`) is how messages refer to it. */
37
+ static load(file: string, label?: string): Promise<OpenApiSpec>;
38
+ find(method: string, path: string): Operation | undefined;
39
+ /** Problems with a response to `method path`; empty when it matches the spec. */
40
+ /** The documented response (`200`, `4XX`, `default`) that `status` falls under, for coverage. */
41
+ responseKey(method: string, path: string, status: number): string | undefined;
42
+ /** Every documented response as `METHOD /template key`, in document order. */
43
+ responseKeys(): string[];
44
+ checkResponse(method: string, path: string, res: Message): string[];
45
+ /**
46
+ * A response the real service could send to `method path`: the lowest
47
+ * documented 2xx, with its example if the spec has one, else a value built
48
+ * from its schema. Undefined when the operation isn't in the spec.
49
+ */
50
+ exampleResponse(method: string, path: string): {
51
+ status: number;
52
+ headers?: Record<string, string>;
53
+ body?: unknown;
54
+ } | undefined;
55
+ /**
56
+ * Every operation with what it takes to call it: sample values for its
57
+ * required path and query parameters and for its JSON request body, and the
58
+ * properties of its first 2xx JSON response. Used by `slicetest gen`.
59
+ */
60
+ operations(): OperationSketch[];
61
+ /** Problems with a request the app sent to `method path`. */
62
+ checkRequest(method: string, path: string, req: Message): string[];
63
+ }
64
+ /**
65
+ * Coverage report: which documented responses the scenarios produced.
66
+ * `hits` are `responseKey()` values collected from every worker.
67
+ */
68
+ export declare function formatCoverage(spec: OpenApiSpec, hits: Set<string>): {
69
+ covered: number;
70
+ total: number;
71
+ percent: number;
72
+ text: string;
73
+ };
74
+ export {};