slicetest 0.2.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.
@@ -0,0 +1,270 @@
1
+ import mysql from "mysql2/promise";
2
+ /**
3
+ * Makes rows look like the Postgres driver's, so the same scenarios pass on
4
+ * both: BOOLEAN (`TINYINT(1)`) columns come back as booleans, BIGINT ids as
5
+ * numbers when they fit, DATETIME as UTC.
6
+ */
7
+ const options = {
8
+ supportBigNumbers: true,
9
+ bigNumberStrings: false,
10
+ timezone: "Z",
11
+ multipleStatements: true,
12
+ typeCast(field, next) {
13
+ if (field.type === "TINY" && field.length === 1) {
14
+ const v = field.string();
15
+ return v === null ? null : v === "1";
16
+ }
17
+ return next();
18
+ },
19
+ };
20
+ async function connect(url) {
21
+ const conn = await mysql.createConnection({ uri: url, ...options });
22
+ // Without a listener, a dropped connection would crash the worker; the next query reports it instead.
23
+ conn.on("error", () => { });
24
+ return conn;
25
+ }
26
+ function rowsOf(result) {
27
+ return Array.isArray(result) ? result : [];
28
+ }
29
+ export class MysqlDriver {
30
+ conn;
31
+ #keys = new Map();
32
+ constructor(conn) {
33
+ this.conn = conn;
34
+ }
35
+ static async connect(url) {
36
+ return new MysqlDriver(await connect(url));
37
+ }
38
+ async query(sql, params = []) {
39
+ const [rows] = await this.conn.query(sql, params);
40
+ return rowsOf(rows);
41
+ }
42
+ async queryAll(sqls) {
43
+ if (sqls.length === 0)
44
+ return [];
45
+ // With multipleStatements, several SELECTs come back as one array of result sets.
46
+ const [results] = await this.conn.query(sqls.join(";\n"));
47
+ return sqls.length === 1 ? [rowsOf(results)] : results.map(rowsOf);
48
+ }
49
+ async exec(script) {
50
+ await this.conn.query(script);
51
+ }
52
+ ident(name) {
53
+ return name
54
+ .split(".")
55
+ .map((part) => this.column(part))
56
+ .join(".");
57
+ }
58
+ column(name) {
59
+ return `\`${name.replace(/`/g, "``")}\``;
60
+ }
61
+ param(_n) {
62
+ return "?";
63
+ }
64
+ inList(column, values, params) {
65
+ if (values.length === 0)
66
+ return "FALSE";
67
+ params.push(...values);
68
+ return `${this.ident(column)} IN (${values.map(() => "?").join(", ")})`;
69
+ }
70
+ /**
71
+ * Base tables in the configured schemas. In MySQL a schema is a database;
72
+ * `public` (the default) stands for the database the URL points at.
73
+ */
74
+ async listTables(schemas, keep) {
75
+ const [{ current }] = (await this.query("SELECT DATABASE() AS current"));
76
+ const names = schemas.map((s) => (s === "public" ? current : s));
77
+ const rows = await this.query(`SELECT t.TABLE_SCHEMA AS \`schema\`, t.TABLE_NAME AS name,
78
+ (SELECT GROUP_CONCAT(k.COLUMN_NAME ORDER BY k.ORDINAL_POSITION SEPARATOR '\\n')
79
+ FROM information_schema.KEY_COLUMN_USAGE k
80
+ WHERE k.TABLE_SCHEMA = t.TABLE_SCHEMA AND k.TABLE_NAME = t.TABLE_NAME AND k.CONSTRAINT_NAME = 'PRIMARY') AS \`key\`
81
+ FROM information_schema.TABLES t
82
+ WHERE t.TABLE_TYPE = 'BASE TABLE' AND t.TABLE_SCHEMA IN (?)
83
+ ORDER BY 1, 2`, [names]);
84
+ return rows
85
+ .map((r) => ({
86
+ name: r.schema === current ? r.name : `${r.schema}.${r.name}`,
87
+ quoted: `${this.ident(r.schema)}.${this.ident(r.name)}`,
88
+ key: r.key ? r.key.split("\n") : [],
89
+ bare: r.name,
90
+ }))
91
+ .filter((t) => !keep.includes(t.bare) && !keep.includes(t.name))
92
+ .map(({ bare: _, ...t }) => t);
93
+ }
94
+ /**
95
+ * TRUNCATE also restarts AUTO_INCREMENT, but it is DDL and costs a few
96
+ * milliseconds per table, so only tables that have rows or a used counter are
97
+ * truncated. Foreign key checks are off for this session only.
98
+ */
99
+ async truncate(tables) {
100
+ if (tables.length === 0)
101
+ return;
102
+ const [, , counters, ...filled] = await this.queryAll([
103
+ // Otherwise information_schema serves AUTO_INCREMENT from a cache that can be a day old.
104
+ "SET SESSION information_schema_stats_expiry = 0",
105
+ "SELECT 1",
106
+ `SELECT CONCAT(TABLE_SCHEMA, '.', TABLE_NAME) AS t, AUTO_INCREMENT AS n FROM information_schema.TABLES WHERE AUTO_INCREMENT > 1`,
107
+ ...tables.map((t) => `SELECT 1 AS x FROM ${t.quoted} LIMIT 1`),
108
+ ]);
109
+ const used = new Set(counters.map((r) => this.ident(String(r.t))));
110
+ const dirty = tables.filter((t, i) => filled[i].length > 0 || used.has(t.quoted));
111
+ if (dirty.length === 0)
112
+ return;
113
+ await this.conn.query(`SET FOREIGN_KEY_CHECKS = 0; ${dirty.map((t) => `TRUNCATE TABLE ${t.quoted}`).join("; ")}; SET FOREIGN_KEY_CHECKS = 1`);
114
+ }
115
+ /** MySQL has no RETURNING: insert, then read the row back by its primary key. */
116
+ async insert(table, row) {
117
+ const cols = Object.keys(row);
118
+ const sql = cols.length === 0
119
+ ? `INSERT INTO ${this.ident(table)} () VALUES ()`
120
+ : `INSERT INTO ${this.ident(table)} (${cols.map((c) => this.column(c)).join(", ")}) VALUES (${cols.map(() => "?").join(", ")})`;
121
+ const [res] = (await this.conn.query(sql, Object.values(row)));
122
+ const key = await this.#primaryKey(table);
123
+ const where = {};
124
+ for (const k of key) {
125
+ if (k in row)
126
+ where[k] = row[k];
127
+ else if (key.length === 1 && res.insertId)
128
+ where[k] = res.insertId;
129
+ else
130
+ return [row];
131
+ }
132
+ if (key.length === 0)
133
+ return [row];
134
+ const conds = Object.keys(where).map((k) => `${this.column(k)} = ?`);
135
+ return this.query(`SELECT * FROM ${this.ident(table)} WHERE ${conds.join(" AND ")}`, Object.values(where));
136
+ }
137
+ async #primaryKey(table) {
138
+ let key = this.#keys.get(table);
139
+ if (!key) {
140
+ const [schema, name] = table.includes(".") ? table.split(".", 2) : [null, table];
141
+ const rows = await this.query(`SELECT COLUMN_NAME AS c FROM information_schema.KEY_COLUMN_USAGE
142
+ WHERE TABLE_SCHEMA = COALESCE(?, DATABASE()) AND TABLE_NAME = ? AND CONSTRAINT_NAME = 'PRIMARY'
143
+ ORDER BY ORDINAL_POSITION`, [schema, name]);
144
+ key = rows.map((r) => r.c);
145
+ this.#keys.set(table, key);
146
+ }
147
+ return key;
148
+ }
149
+ async close() {
150
+ await this.conn.end();
151
+ }
152
+ }
153
+ function withDatabase(url, database) {
154
+ const u = new URL(url);
155
+ u.pathname = `/${database}`;
156
+ return u.toString();
157
+ }
158
+ function quote(name) {
159
+ return `\`${name.replace(/`/g, "``")}\``;
160
+ }
161
+ class MysqlAdmin {
162
+ conn;
163
+ url;
164
+ constructor(conn, url) {
165
+ this.conn = conn;
166
+ this.url = url;
167
+ }
168
+ async databases(prefix) {
169
+ const [rows] = await this.conn.query("SELECT SCHEMA_NAME AS name FROM information_schema.SCHEMATA");
170
+ return rowsOf(rows)
171
+ .map((r) => String(r.name))
172
+ .filter((n) => n.startsWith(prefix));
173
+ }
174
+ async create(name) {
175
+ await this.conn.query(`CREATE DATABASE ${quote(name)}`);
176
+ }
177
+ /**
178
+ * MySQL has no template databases: recreate each table from `SHOW CREATE
179
+ * TABLE` (foreign keys included) and copy its rows, then views and triggers.
180
+ * Stored routines and events are not copied.
181
+ */
182
+ async clone(template, name) {
183
+ await this.create(name);
184
+ const target = await connect(withDatabase(this.url, name));
185
+ try {
186
+ await target.query("SET FOREIGN_KEY_CHECKS = 0");
187
+ const [tables] = await this.conn.query("SELECT TABLE_NAME AS name, TABLE_TYPE AS type FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME", [template]);
188
+ const list = rowsOf(tables);
189
+ for (const t of list.filter((t) => t.type === "BASE TABLE")) {
190
+ const [[create]] = (await this.conn.query(`SHOW CREATE TABLE ${quote(template)}.${quote(t.name)}`));
191
+ await target.query(String(create["Create Table"]));
192
+ const [cols] = await this.conn.query("SELECT COLUMN_NAME AS c FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND EXTRA NOT LIKE '%GENERATED%' ORDER BY ORDINAL_POSITION", [template, t.name]);
193
+ const names = rowsOf(cols).map((c) => quote(String(c.c))).join(", ");
194
+ await target.query(`INSERT INTO ${quote(t.name)} (${names}) SELECT ${names} FROM ${quote(template)}.${quote(t.name)}`);
195
+ }
196
+ // Views may depend on each other; retry the ones that fail until no progress is made.
197
+ let views = list.filter((t) => t.type === "VIEW");
198
+ while (views.length > 0) {
199
+ const failed = [];
200
+ let lastError;
201
+ for (const v of views) {
202
+ const [[create]] = (await this.conn.query(`SHOW CREATE VIEW ${quote(template)}.${quote(v.name)}`));
203
+ const sql = String(create["Create View"]).split(`${quote(template)}.`).join("");
204
+ await target.query(sql).catch((e) => {
205
+ failed.push(v);
206
+ lastError = e;
207
+ });
208
+ }
209
+ if (failed.length === views.length)
210
+ throw lastError;
211
+ views = failed;
212
+ }
213
+ const [triggers] = await this.conn.query(`SHOW TRIGGERS FROM ${quote(template)}`);
214
+ for (const trg of rowsOf(triggers)) {
215
+ const [[create]] = (await this.conn.query(`SHOW CREATE TRIGGER ${quote(template)}.${quote(String(trg.Trigger))}`));
216
+ await target.query(String(create["SQL Original Statement"]));
217
+ }
218
+ }
219
+ catch (e) {
220
+ await this.drop(name).catch(() => { });
221
+ throw e;
222
+ }
223
+ finally {
224
+ await target.end().catch(() => { });
225
+ }
226
+ }
227
+ /** DROP DATABASE waits for open transactions, so disconnect everyone using it first. */
228
+ async drop(name) {
229
+ const [rows] = await this.conn.query("SELECT ID AS id FROM information_schema.PROCESSLIST WHERE DB = ? AND ID <> CONNECTION_ID()", [name]);
230
+ for (const r of rowsOf(rows))
231
+ await this.conn.query(`KILL ${Number(r.id)}`).catch(() => { });
232
+ await this.conn.query(`DROP DATABASE IF EXISTS ${quote(name)}`);
233
+ }
234
+ async withLock(key, fn) {
235
+ const lock = key.slice(0, 64);
236
+ await this.conn.query("SELECT GET_LOCK(?, -1)", [lock]);
237
+ try {
238
+ return await fn();
239
+ }
240
+ finally {
241
+ await this.conn.query("SELECT RELEASE_LOCK(?)", [lock]);
242
+ }
243
+ }
244
+ urlFor(name) {
245
+ return withDatabase(this.url, name);
246
+ }
247
+ async close() {
248
+ await this.conn.end();
249
+ }
250
+ }
251
+ export const mysqlEngine = {
252
+ name: "mysql",
253
+ defaultImage: "mysql:8.4",
254
+ async startContainer(image, reuse) {
255
+ const { MySqlContainer } = await import("@testcontainers/mysql").catch(() => {
256
+ throw new Error("slicetest: starting a MySQL container needs the @testcontainers/mysql package: npm i -D @testcontainers/mysql (or set db.url)");
257
+ });
258
+ const definition = new MySqlContainer(image);
259
+ if (reuse)
260
+ definition.withReuse();
261
+ const container = await definition.start();
262
+ // Root, because slicetest creates and drops databases.
263
+ return { url: container.getConnectionUri(true), stop: () => container.stop() };
264
+ },
265
+ async admin(url) {
266
+ return new MysqlAdmin(await connect(url), url);
267
+ },
268
+ driver: (url) => MysqlDriver.connect(url),
269
+ atlasUrl: (url) => url,
270
+ };
package/dist/gen.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ /** Where the last run's OpenAPI coverage is kept for `slicetest gen --uncovered`. */
2
+ export declare function coverageCacheFile(root: string): string;
3
+ export interface GenOptions {
4
+ /** The app's spec, relative to `root`. */
5
+ spec: string;
6
+ /** Directory for the scenario files, relative to `root`. Default `scenarios`. */
7
+ out?: string;
8
+ /** Only responses the last run didn't produce. */
9
+ uncovered?: boolean;
10
+ force?: boolean;
11
+ }
12
+ /**
13
+ * Scenario skeletons for the documented responses of an OpenAPI spec, one
14
+ * `<resource>.gen.scenario.yaml` per first path segment. A 2xx response gets a
15
+ * runnable scenario, with a request built from the spec and, when the path
16
+ * needs an id, a step that creates the resource first through the collection's
17
+ * POST. Other responses become skipped scenarios: a to-do list of the states
18
+ * the tests still have to arrange.
19
+ */
20
+ export declare function gen(root: string, opts: GenOptions): Promise<{
21
+ written: string[];
22
+ skipped: string[];
23
+ count: number;
24
+ }>;
package/dist/gen.js ADDED
@@ -0,0 +1,116 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { stringify } from "yaml";
5
+ import { OpenApiSpec } from "./openapi.js";
6
+ /** Where the last run's OpenAPI coverage is kept for `slicetest gen --uncovered`. */
7
+ export function coverageCacheFile(root) {
8
+ return path.join(root, "node_modules", ".cache", "slicetest", "openapi-coverage.json");
9
+ }
10
+ /**
11
+ * Scenario skeletons for the documented responses of an OpenAPI spec, one
12
+ * `<resource>.gen.scenario.yaml` per first path segment. A 2xx response gets a
13
+ * runnable scenario, with a request built from the spec and, when the path
14
+ * needs an id, a step that creates the resource first through the collection's
15
+ * POST. Other responses become skipped scenarios: a to-do list of the states
16
+ * the tests still have to arrange.
17
+ */
18
+ export async function gen(root, opts) {
19
+ const spec = await OpenApiSpec.load(path.resolve(root, opts.spec), opts.spec);
20
+ const ops = spec.operations();
21
+ let covered = new Set();
22
+ if (opts.uncovered) {
23
+ const cache = coverageCacheFile(root);
24
+ if (!existsSync(cache))
25
+ throw new Error("slicetest: no coverage from a previous run yet. Run the scenarios once (with `openapi` set), then `slicetest gen --uncovered`.");
26
+ covered = new Set(JSON.parse(await readFile(cache, "utf8")));
27
+ }
28
+ const files = new Map();
29
+ let count = 0;
30
+ for (const op of ops) {
31
+ for (const key of op.responses) {
32
+ if (covered.has(`${op.method} ${op.template} ${key}`))
33
+ continue;
34
+ const resource = op.template.split("/").find((s) => s && !s.startsWith("{")) ?? "root";
35
+ const list = files.get(resource) ?? [];
36
+ list.push(scenarioFor(op, key, ops));
37
+ files.set(resource, list);
38
+ count++;
39
+ }
40
+ }
41
+ const dir = path.resolve(root, opts.out ?? "scenarios");
42
+ const written = [];
43
+ const skipped = [];
44
+ for (const [resource, scenarios] of files) {
45
+ const file = path.join(dir, `${resource}.gen.scenario.yaml`);
46
+ const rel = path.relative(root, file);
47
+ if (existsSync(file) && !opts.force) {
48
+ skipped.push(rel);
49
+ continue;
50
+ }
51
+ const header = [
52
+ "# yaml-language-server: $schema=https://unpkg.com/slicetest/schema/scenario.schema.json",
53
+ `# Generated by \`slicetest gen\` from ${opts.spec}${opts.uncovered ? " (responses the last run didn't cover)" : ""}.`,
54
+ "# Requests are built from the spec's examples and schemas: adjust them to your data, then drop the `skip`s one by one.",
55
+ "",
56
+ ].join("\n");
57
+ await mkdir(dir, { recursive: true });
58
+ await writeFile(file, header + stringify({ scenarios }, { lineWidth: 0, aliasDuplicateObjects: false }));
59
+ written.push(rel);
60
+ }
61
+ return { written, skipped, count };
62
+ }
63
+ function scenarioFor(op, key, all) {
64
+ const steps = [];
65
+ let pathText = op.template;
66
+ const success = /^2/.test(key);
67
+ let unresolved = false;
68
+ for (const [name, sample] of Object.entries(op.pathParams)) {
69
+ const creator = success ? creatorFor(op, name, all) : undefined;
70
+ if (creator) {
71
+ steps.push(requestStep(creator.op, creator.op.template, { status: Number(creator.status) }, { [name]: `json.${creator.field}` }));
72
+ pathText = pathText.replace(`{${name}}`, `{{${name}}}`);
73
+ }
74
+ else {
75
+ pathText = pathText.replace(`{${name}}`, encodeURIComponent(String(sample ?? 1)));
76
+ unresolved = true;
77
+ }
78
+ }
79
+ const status = /^\d{3}$/.test(key) ? Number(key) : undefined;
80
+ // Two error responses need no arranging: a made-up id is not found, and an empty body is invalid.
81
+ const notFound = status === 404 && Object.keys(op.pathParams).length > 0;
82
+ const invalid = (status === 400 || status === 422) && isFilledObject(op.json);
83
+ steps.push(requestStep(invalid ? { ...op, json: {} } : op, pathText, { status }));
84
+ const name = `${op.method} ${op.template} → ${key}${op.summary ? ` (${op.summary})` : ""}`;
85
+ const runnable = success ? !unresolved : notFound || invalid;
86
+ return runnable ? { name, steps } : { name: `${name} — TODO: arrange the state that produces this`, skip: true, steps };
87
+ }
88
+ /**
89
+ * The POST on the collection above `template` whose success response carries
90
+ * the value for `param`: `{id}` in `/polls/{id}/votes` comes from `POST /polls`'s `id`.
91
+ */
92
+ function creatorFor(op, param, all) {
93
+ const i = op.template.indexOf(`{${param}}`);
94
+ const collection = op.template.slice(0, i).replace(/\/$/, "");
95
+ const post = all.find((o) => o.method === "POST" && o.template === collection);
96
+ if (!post)
97
+ return undefined;
98
+ const field = [param, "id"].find((f) => post.createdFields.includes(f));
99
+ const status = post.responses.filter((k) => /^2\d\d$/.test(k)).sort()[0];
100
+ return field && status ? { op: post, field, status } : undefined;
101
+ }
102
+ function requestStep(op, pathText, expect, capture) {
103
+ const step = { request: `${op.method} ${pathText}` };
104
+ if (Object.keys(op.query).length)
105
+ step.query = Object.fromEntries(Object.entries(op.query).map(([k, v]) => [k, String(v)]));
106
+ if (op.json !== undefined)
107
+ step.json = op.json;
108
+ if (expect.status)
109
+ step.expect = { status: expect.status };
110
+ if (capture)
111
+ step.capture = capture;
112
+ return step;
113
+ }
114
+ function isFilledObject(v) {
115
+ return !!v && typeof v === "object" && !Array.isArray(v) && Object.keys(v).length > 0;
116
+ }
@@ -1,18 +1,20 @@
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
7
  import { configureContainerRuntime } from "./container-runtime.js";
8
8
  import { engineFor } from "./drivers/index.js";
9
+ import { coverageCacheFile } from "./gen.js";
9
10
  import { formatCoverage, OpenApiSpec } from "./openapi.js";
11
+ import { mergeRecordings } from "./recording.js";
10
12
  import "./provided.js";
11
13
  const exec = promisify(execFile);
12
14
  /** Runs once per vitest run: start the database server, migrate a template database, hand its location to the workers. */
13
15
  export default async function setup(project) {
14
16
  const opts = project.getProvidedContext().slicetestOptions;
15
- const engine = engineFor(opts);
17
+ const engine = await engineFor(opts);
16
18
  let adminUrl;
17
19
  let stopContainer;
18
20
  if (opts.db.url) {
@@ -45,7 +47,10 @@ export default async function setup(project) {
45
47
  const server = admin;
46
48
  // Each worker writes the documented responses it saw here; they are merged when the run ends.
47
49
  const coverageDir = opts.openapi.app ? await mkdtemp(path.join(os.tmpdir(), "slicetest-coverage-")) : undefined;
48
- project.provide("slicetestDb", { adminUrl, template, prefix, coverageDir });
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 });
49
54
  return async () => {
50
55
  try {
51
56
  if (!stopContainer) {
@@ -59,6 +64,8 @@ export default async function setup(project) {
59
64
  await stopContainer?.();
60
65
  if (coverageDir)
61
66
  await reportCoverage(opts, coverageDir);
67
+ if (recordDir)
68
+ await saveRecordings(opts, recordDir);
62
69
  }
63
70
  };
64
71
  }
@@ -74,6 +81,9 @@ async function reportCoverage(opts, dir) {
74
81
  return;
75
82
  const spec = await OpenApiSpec.load(path.resolve(opts.root, opts.openapi.app), opts.openapi.app);
76
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(() => { });
77
87
  console.log(`\n${report.text}\n`);
78
88
  const min = opts.openapi.minCoverage;
79
89
  if (min !== undefined && report.percent < min) {
@@ -86,6 +96,23 @@ async function reportCoverage(opts, dir) {
86
96
  await rm(dir, { recursive: true, force: true });
87
97
  }
88
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
+ }
89
116
  const RUN_PREFIX = "slicetest_r";
90
117
  const TEMPLATE_PREFIX = "slicetest_tpl_";
91
118
  const STALE_MS = 24 * 60 * 60 * 1000;
package/dist/index.d.ts CHANGED
@@ -1,8 +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
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";
8
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 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,13 +92,22 @@ 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 } = 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
+ }
95
103
  // --- OpenAPI ---
96
104
  const openapi = ["openapi.yaml", "openapi.yml", "openapi.json", "docs/openapi.yaml", "docs/openapi.yml", "docs/openapi.json"].find(has);
97
105
  if (openapi)
98
106
  notes.push(`openapi: ${openapi}. Every response will be checked against it.`);
99
107
  const config = {
100
108
  app: { command, env, ready: { path: "/" } },
101
- ...(migrate ? { db: { migrate } } : {}),
109
+ ...(migrate || Object.keys(composeDb).length ? { db: { ...composeDb, ...(migrate ? { migrate } : {}) } } : {}),
110
+ ...(Object.keys(containers).length ? { containers } : {}),
102
111
  stubs: [],
103
112
  ...(openapi ? { openapi } : {}),
104
113
  };
@@ -132,3 +141,96 @@ export async function init(root, { force = false } = {}) {
132
141
  await writeFile(scenarioFile, SCENARIO);
133
142
  return { files: [configFile, scenarioFile].map((f) => path.relative(root, f)), notes };
134
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
+ }
package/dist/openapi.d.ts CHANGED
@@ -4,6 +4,18 @@ interface Operation {
4
4
  method: string;
5
5
  op: Record<string, any>;
6
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
+ }
7
19
  export interface Message {
8
20
  status?: number;
9
21
  contentType?: string;
@@ -40,6 +52,12 @@ export declare class OpenApiSpec {
40
52
  headers?: Record<string, string>;
41
53
  body?: unknown;
42
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[];
43
61
  /** Problems with a request the app sent to `method path`. */
44
62
  checkRequest(method: string, path: string, req: Message): string[];
45
63
  }