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
@@ -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,23 @@
1
+ import type { Driver, Engine, Row, Table } from "./driver.js";
2
+ /** `sqlite:///tmp/a.db` → `/tmp/a.db`; `sqlite:///C:/x/a.db` → `C:/x/a.db`. Plain paths pass through. */
3
+ export declare function sqlitePath(url: string): string;
4
+ export declare function sqliteUrl(file: string): string;
5
+ export declare class SqliteDriver implements Driver {
6
+ private readonly db;
7
+ private constructor();
8
+ static connect(url: string): Promise<SqliteDriver>;
9
+ query<T extends Row = Row>(sql: string, params?: unknown[]): Promise<T[]>;
10
+ queryAll(sqls: string[]): Promise<Row[][]>;
11
+ exec(script: string): Promise<void>;
12
+ ident(name: string): string;
13
+ column(name: string): string;
14
+ param(n: number): string;
15
+ inList(column: string, values: unknown[], params: unknown[]): string;
16
+ /** SQLite has one schema per file (`main`), so `schemas` doesn't apply. */
17
+ listTables(_schemas: string[], keep: string[]): Promise<Table[]>;
18
+ /** DELETE, then forget AUTOINCREMENT counters. Foreign keys are off for this connection only. */
19
+ truncate(tables: Table[]): Promise<void>;
20
+ insert(table: string, row: Row): Promise<Row[]>;
21
+ close(): Promise<void>;
22
+ }
23
+ export declare const sqliteEngine: Engine;
@@ -0,0 +1,203 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, mkdtemp, open, readdir, rm } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ let loaded;
6
+ /** `node:sqlite` warns that it's experimental on every import; that's noise in test output. */
7
+ function load() {
8
+ return (loaded ??= (async () => {
9
+ const emit = process.emitWarning;
10
+ process.emitWarning = ((warning, ...rest) => {
11
+ if (String(typeof warning === "string" ? warning : warning.message).includes("SQLite"))
12
+ return;
13
+ emit.call(process, warning, ...rest);
14
+ });
15
+ try {
16
+ return await import("node:sqlite");
17
+ }
18
+ catch {
19
+ throw new Error(`slicetest: db.engine "sqlite" needs Node.js 22.5 or later (node:sqlite); this is ${process.versions.node}`);
20
+ }
21
+ finally {
22
+ process.emitWarning = emit;
23
+ }
24
+ })());
25
+ }
26
+ /** `sqlite:///tmp/a.db` → `/tmp/a.db`; `sqlite:///C:/x/a.db` → `C:/x/a.db`. Plain paths pass through. */
27
+ export function sqlitePath(url) {
28
+ if (!url.startsWith("sqlite:"))
29
+ return url;
30
+ const p = decodeURIComponent(url.replace(/^sqlite:(\/\/)?/, "").replace(/[?#].*$/, ""));
31
+ return /^\/[a-zA-Z]:[\\/]/.test(p) ? p.slice(1) : p;
32
+ }
33
+ export function sqliteUrl(file) {
34
+ const p = path.resolve(file).replace(/\\/g, "/");
35
+ return `sqlite://${p.startsWith("/") ? "" : "/"}${p}`;
36
+ }
37
+ /** Values node:sqlite can't bind are stored the way an app would store them. */
38
+ function bind(v) {
39
+ if (typeof v === "boolean")
40
+ return v ? 1 : 0;
41
+ if (v instanceof Date)
42
+ return v.toISOString();
43
+ if (v !== null && typeof v === "object" && !(v instanceof Uint8Array))
44
+ return JSON.stringify(v);
45
+ return v === undefined ? null : v;
46
+ }
47
+ async function openDb(file) {
48
+ const { DatabaseSync } = await load();
49
+ const db = new DatabaseSync(file);
50
+ // The app may be writing; wait for its lock instead of failing with SQLITE_BUSY.
51
+ db.exec("PRAGMA busy_timeout = 5000");
52
+ return db;
53
+ }
54
+ export class SqliteDriver {
55
+ db;
56
+ constructor(db) {
57
+ this.db = db;
58
+ }
59
+ static async connect(url) {
60
+ return new SqliteDriver(await openDb(sqlitePath(url)));
61
+ }
62
+ async query(sql, params = []) {
63
+ const stmt = this.db.prepare(sql);
64
+ const values = params.map(bind);
65
+ // Rows come with a null prototype; plain objects compare and print as expected.
66
+ return stmt.columns().length === 0 ? (stmt.run(...values), []) : stmt.all(...values).map((r) => ({ ...r }));
67
+ }
68
+ async queryAll(sqls) {
69
+ return Promise.all(sqls.map((s) => this.query(s)));
70
+ }
71
+ async exec(script) {
72
+ this.db.exec(script);
73
+ }
74
+ ident(name) {
75
+ return name
76
+ .split(".")
77
+ .map((part) => this.column(part))
78
+ .join(".");
79
+ }
80
+ column(name) {
81
+ return `"${name.replace(/"/g, '""')}"`;
82
+ }
83
+ param(n) {
84
+ return `?${n}`;
85
+ }
86
+ inList(column, values, params) {
87
+ if (values.length === 0)
88
+ return "0";
89
+ const start = params.length;
90
+ params.push(...values);
91
+ return `${this.ident(column)} IN (${values.map((_, i) => `?${start + i + 1}`).join(", ")})`;
92
+ }
93
+ /** SQLite has one schema per file (`main`), so `schemas` doesn't apply. */
94
+ async listTables(_schemas, keep) {
95
+ const rows = await this.query(`SELECT m.name AS name,
96
+ (SELECT group_concat(name, char(10)) FROM (SELECT name FROM pragma_table_info(m.name) WHERE pk > 0 ORDER BY pk)) AS key
97
+ FROM sqlite_master m
98
+ WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite\\_%' ESCAPE '\\'
99
+ ORDER BY 1`);
100
+ return rows
101
+ .filter((r) => !keep.includes(r.name) && !keep.includes(`main.${r.name}`))
102
+ .map((r) => ({ name: r.name, quoted: this.ident(r.name), key: r.key ? r.key.split("\n") : [] }));
103
+ }
104
+ /** DELETE, then forget AUTOINCREMENT counters. Foreign keys are off for this connection only. */
105
+ async truncate(tables) {
106
+ if (tables.length === 0)
107
+ return;
108
+ const sequence = (await this.query("SELECT 1 FROM sqlite_master WHERE name = 'sqlite_sequence'")).length > 0;
109
+ const names = tables.map((t) => `'${t.name.replace(/'/g, "''")}'`).join(", ");
110
+ this.db.exec(`PRAGMA foreign_keys = OFF; BEGIN IMMEDIATE; ${tables.map((t) => `DELETE FROM ${t.quoted};`).join(" ")}` +
111
+ `${sequence ? ` DELETE FROM sqlite_sequence WHERE name IN (${names});` : ""} COMMIT; PRAGMA foreign_keys = ON;`);
112
+ }
113
+ async insert(table, row) {
114
+ const cols = Object.keys(row);
115
+ const sql = cols.length === 0
116
+ ? `INSERT INTO ${this.ident(table)} DEFAULT VALUES RETURNING *`
117
+ : `INSERT INTO ${this.ident(table)} (${cols.map((c) => this.column(c)).join(", ")}) VALUES (${cols.map((_, i) => `?${i + 1}`).join(", ")}) RETURNING *`;
118
+ return this.query(sql, Object.values(row));
119
+ }
120
+ async close() {
121
+ this.db.close();
122
+ }
123
+ }
124
+ class SqliteAdmin {
125
+ dir;
126
+ constructor(dir) {
127
+ this.dir = dir;
128
+ }
129
+ #file(name) {
130
+ return path.join(this.dir, `${name}.db`);
131
+ }
132
+ async databases(prefix) {
133
+ return (await readdir(this.dir)).filter((f) => f.endsWith(".db") && f.startsWith(prefix)).map((f) => f.slice(0, -3));
134
+ }
135
+ async create(name) {
136
+ const db = await openDb(this.#file(name));
137
+ // Persisted in the file: the app and slicetest can then read and write at the same time.
138
+ db.exec("PRAGMA journal_mode = WAL");
139
+ db.close();
140
+ }
141
+ async clone(template, name) {
142
+ const db = await openDb(this.#file(template));
143
+ try {
144
+ db.prepare("VACUUM INTO ?").run(this.#file(name));
145
+ }
146
+ finally {
147
+ db.close();
148
+ }
149
+ const copy = await openDb(this.#file(name));
150
+ copy.exec("PRAGMA journal_mode = WAL");
151
+ copy.close();
152
+ }
153
+ async drop(name) {
154
+ for (const suffix of ["", "-wal", "-shm", "-journal"])
155
+ await rm(this.#file(name) + suffix, { force: true });
156
+ }
157
+ /** A lock file, for runs sharing the directory (with `reuse`). */
158
+ async withLock(key, fn) {
159
+ const lock = path.join(this.dir, `${key}.lock`);
160
+ const deadline = Date.now() + 120_000;
161
+ for (;;) {
162
+ try {
163
+ await (await open(lock, "wx")).close();
164
+ break;
165
+ }
166
+ catch (e) {
167
+ if (e.code !== "EEXIST" || Date.now() > deadline)
168
+ throw e;
169
+ await new Promise((r) => setTimeout(r, 100));
170
+ }
171
+ }
172
+ try {
173
+ return await fn();
174
+ }
175
+ finally {
176
+ await rm(lock, { force: true });
177
+ }
178
+ }
179
+ urlFor(name) {
180
+ return sqliteUrl(this.#file(name));
181
+ }
182
+ async close() { }
183
+ }
184
+ export const sqliteEngine = {
185
+ name: "sqlite",
186
+ defaultImage: "",
187
+ local: true,
188
+ /** No container: a directory of database files, kept between runs with `reuse`. */
189
+ async startContainer(_image, reuse) {
190
+ await load();
191
+ const dir = reuse ? path.join(os.tmpdir(), "slicetest-sqlite") : await mkdtemp(path.join(os.tmpdir(), "slicetest-sqlite-"));
192
+ await mkdir(dir, { recursive: true });
193
+ return { url: sqliteUrl(dir), stop: () => rm(dir, { recursive: true, force: true }) };
194
+ },
195
+ async admin(url) {
196
+ const dir = sqlitePath(url);
197
+ if (!existsSync(dir))
198
+ await mkdir(dir, { recursive: true });
199
+ return new SqliteAdmin(dir);
200
+ },
201
+ driver: (url) => SqliteDriver.connect(url),
202
+ atlasUrl: (url) => `sqlite://${sqlitePath(url)}`,
203
+ };
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
+ }