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.
@@ -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
+ };
@@ -0,0 +1,19 @@
1
+ import type { Driver, Engine, Row, Table } from "./driver.js";
2
+ export declare class PostgresDriver implements Driver {
3
+ private readonly client;
4
+ private constructor();
5
+ static connect(url: string): Promise<PostgresDriver>;
6
+ query<T extends Row = Row>(sql: string, params?: unknown[]): Promise<T[]>;
7
+ queryAll(sqls: string[]): Promise<Row[][]>;
8
+ exec(script: string): Promise<void>;
9
+ ident(name: string): string;
10
+ column(name: string): string;
11
+ param(n: number): string;
12
+ inList(column: string, values: unknown[], params: unknown[]): string;
13
+ /** Ordinary and partitioned tables, skipping partitions and tables owned by extensions (e.g. PostGIS's `spatial_ref_sys`). */
14
+ listTables(schemas: string[], keep: string[]): Promise<Table[]>;
15
+ truncate(tables: Table[]): Promise<void>;
16
+ insert(table: string, row: Row): Promise<Row[]>;
17
+ close(): Promise<void>;
18
+ }
19
+ export declare const postgres: Engine;
@@ -0,0 +1,164 @@
1
+ import pg from "pg";
2
+ const INT8 = 20;
3
+ /** int8 columns (bigserial ids, count(*)) come back as numbers when they fit, instead of strings. */
4
+ const types = {
5
+ getTypeParser(oid, format) {
6
+ if (oid === INT8 && format !== "binary") {
7
+ return (v) => {
8
+ const n = Number(v);
9
+ return Number.isSafeInteger(n) ? n : v;
10
+ };
11
+ }
12
+ return pg.types.getTypeParser(oid, format);
13
+ },
14
+ };
15
+ export class PostgresDriver {
16
+ client;
17
+ constructor(client) {
18
+ this.client = client;
19
+ }
20
+ static async connect(url) {
21
+ const client = new pg.Client({ connectionString: url, types });
22
+ // Without a listener, a dropped connection would crash the worker; the next query reports it instead.
23
+ client.on("error", () => { });
24
+ await client.connect();
25
+ return new PostgresDriver(client);
26
+ }
27
+ async query(sql, params = []) {
28
+ return (await this.client.query(sql, params)).rows;
29
+ }
30
+ async queryAll(sqls) {
31
+ if (sqls.length === 0)
32
+ return [];
33
+ // The simple query protocol runs several statements in one round trip.
34
+ const res = (await this.client.query(sqls.join(";\n")));
35
+ return (Array.isArray(res) ? res : [res]).map((r) => r.rows);
36
+ }
37
+ async exec(script) {
38
+ await this.client.query(script);
39
+ }
40
+ ident(name) {
41
+ return name
42
+ .split(".")
43
+ .map((part) => this.column(part))
44
+ .join(".");
45
+ }
46
+ column(name) {
47
+ return `"${name.replace(/"/g, '""')}"`;
48
+ }
49
+ param(n) {
50
+ return `$${n}`;
51
+ }
52
+ inList(column, values, params) {
53
+ params.push(values);
54
+ return `${this.ident(column)} = ANY($${params.length})`;
55
+ }
56
+ /** Ordinary and partitioned tables, skipping partitions and tables owned by extensions (e.g. PostGIS's `spatial_ref_sys`). */
57
+ async listTables(schemas, keep) {
58
+ const rows = await this.query(`SELECT n.nspname AS schema, c.relname AS name,
59
+ (SELECT array_agg(a.attname::text ORDER BY k.ord)
60
+ FROM pg_index i
61
+ CROSS JOIN LATERAL unnest(i.indkey) WITH ORDINALITY AS k(attnum, ord)
62
+ JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = k.attnum
63
+ WHERE i.indrelid = c.oid AND i.indisprimary) AS key
64
+ FROM pg_class c
65
+ JOIN pg_namespace n ON n.oid = c.relnamespace
66
+ WHERE c.relkind IN ('r', 'p')
67
+ AND NOT c.relispartition
68
+ AND n.nspname = ANY($1)
69
+ AND NOT (c.relname = ANY($2) OR n.nspname || '.' || c.relname = ANY($2))
70
+ AND NOT EXISTS (
71
+ SELECT 1 FROM pg_depend d
72
+ WHERE d.classid = 'pg_class'::regclass AND d.objid = c.oid AND d.deptype = 'e')
73
+ ORDER BY 1, 2`, [schemas, keep]);
74
+ return rows.map((r) => ({
75
+ name: r.schema === "public" ? r.name : `${r.schema}.${r.name}`,
76
+ quoted: `${this.ident(r.schema)}.${this.ident(r.name)}`,
77
+ key: r.key ?? [],
78
+ }));
79
+ }
80
+ async truncate(tables) {
81
+ if (tables.length > 0)
82
+ await this.client.query(`TRUNCATE ${tables.map((t) => t.quoted).join(", ")} RESTART IDENTITY CASCADE`);
83
+ }
84
+ async insert(table, row) {
85
+ const cols = Object.keys(row);
86
+ const sql = cols.length === 0
87
+ ? `INSERT INTO ${this.ident(table)} DEFAULT VALUES RETURNING *`
88
+ : `INSERT INTO ${this.ident(table)} (${cols.map((c) => this.ident(c)).join(", ")}) VALUES (${cols.map((_, i) => `$${i + 1}`).join(", ")}) RETURNING *`;
89
+ return this.query(sql, Object.values(row));
90
+ }
91
+ async close() {
92
+ await this.client.end();
93
+ }
94
+ }
95
+ function withDatabase(url, database) {
96
+ const u = new URL(url);
97
+ u.pathname = `/${database}`;
98
+ return u.toString();
99
+ }
100
+ function quote(name) {
101
+ return `"${name.replace(/"/g, '""')}"`;
102
+ }
103
+ class PostgresAdmin {
104
+ client;
105
+ url;
106
+ constructor(client, url) {
107
+ this.client = client;
108
+ this.url = url;
109
+ }
110
+ async databases(prefix) {
111
+ const { rows } = await this.client.query("SELECT datname FROM pg_database WHERE starts_with(datname, $1)", [prefix]);
112
+ return rows.map((r) => r.datname);
113
+ }
114
+ async create(name) {
115
+ await this.client.query(`CREATE DATABASE ${quote(name)}`);
116
+ }
117
+ async clone(template, name) {
118
+ await this.client.query(`CREATE DATABASE ${quote(name)} TEMPLATE ${quote(template)}`);
119
+ }
120
+ async drop(name) {
121
+ await this.client.query(`DROP DATABASE IF EXISTS ${quote(name)} WITH (FORCE)`);
122
+ }
123
+ async withLock(key, fn) {
124
+ await this.client.query("SELECT pg_advisory_lock(hashtext($1))", [key]);
125
+ try {
126
+ return await fn();
127
+ }
128
+ finally {
129
+ await this.client.query("SELECT pg_advisory_unlock(hashtext($1))", [key]);
130
+ }
131
+ }
132
+ urlFor(name) {
133
+ return withDatabase(this.url, name);
134
+ }
135
+ async close() {
136
+ await this.client.end();
137
+ }
138
+ }
139
+ export const postgres = {
140
+ name: "postgres",
141
+ defaultImage: "postgres:17-alpine",
142
+ async startContainer(image, reuse) {
143
+ const { PostgreSqlContainer } = await import("@testcontainers/postgresql");
144
+ const definition = new PostgreSqlContainer(image);
145
+ // A reused container is left running and found again by its configuration on the next run.
146
+ if (reuse)
147
+ definition.withReuse();
148
+ const container = await definition.start();
149
+ return { url: container.getConnectionUri(), stop: () => container.stop() };
150
+ },
151
+ async admin(url) {
152
+ const client = new pg.Client({ connectionString: url });
153
+ client.on("error", () => { });
154
+ await client.connect();
155
+ return new PostgresAdmin(client, url);
156
+ },
157
+ driver: (url) => PostgresDriver.connect(url),
158
+ atlasUrl(url) {
159
+ const u = new URL(url);
160
+ if (!u.searchParams.has("sslmode"))
161
+ u.searchParams.set("sslmode", "disable");
162
+ return u.toString();
163
+ },
164
+ };
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,7 +1,13 @@
1
1
  import type { TestProject } from "vitest/node";
2
+ import type { ResolvedOptions } from "./config.js";
2
3
  import "./provided.js";
3
- /** Runs once per vitest run: start Postgres, migrate a template database, hand its location to the workers. */
4
+ /** Runs once per vitest run: start the database server, migrate a template database, hand its location to the workers. */
4
5
  export default function setup(project: TestProject): Promise<() => Promise<void>>;
6
+ /**
7
+ * A hash of everything that determines the migrated schema, or undefined when
8
+ * that can't be known (a migration command without `inputs`).
9
+ */
10
+ export declare function migrationKey(opts: Pick<ResolvedOptions, "db" | "root">): Promise<string | undefined>;
5
11
  /**
6
12
  * Atlas wants `file://<path>` with forward slashes. Absolute paths (including
7
13
  * Windows drive paths) are made relative to the root, which is the cwd Atlas runs in.