slicetest 0.3.0 → 0.5.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 (60) hide show
  1. package/README.md +211 -7
  2. package/dist/auth.d.ts +45 -0
  3. package/dist/auth.js +128 -0
  4. package/dist/ci.d.ts +29 -0
  5. package/dist/ci.js +57 -0
  6. package/dist/cli.js +31 -3
  7. package/dist/config.d.ts +25 -2
  8. package/dist/config.js +25 -4
  9. package/dist/db.d.ts +26 -0
  10. package/dist/db.js +50 -0
  11. package/dist/doctor.d.ts +22 -0
  12. package/dist/doctor.js +190 -0
  13. package/dist/drivers/driver.d.ts +29 -0
  14. package/dist/drivers/index.js +2 -0
  15. package/dist/drivers/mysql.d.ts +2 -1
  16. package/dist/drivers/mysql.js +38 -0
  17. package/dist/drivers/postgres.d.ts +2 -1
  18. package/dist/drivers/postgres.js +28 -0
  19. package/dist/drivers/sqlite.d.ts +24 -0
  20. package/dist/drivers/sqlite.js +255 -0
  21. package/dist/factory.d.ts +21 -0
  22. package/dist/factory.js +129 -0
  23. package/dist/gen.d.ts +1 -0
  24. package/dist/gen.js +9 -4
  25. package/dist/global-setup.js +24 -3
  26. package/dist/http.d.ts +13 -0
  27. package/dist/http.js +24 -0
  28. package/dist/index.d.ts +5 -1
  29. package/dist/index.js +2 -0
  30. package/dist/init.js +123 -5
  31. package/dist/mail.d.ts +58 -0
  32. package/dist/mail.js +299 -0
  33. package/dist/matchers.d.ts +4 -0
  34. package/dist/matchers.js +33 -0
  35. package/dist/openapi.d.ts +9 -0
  36. package/dist/openapi.js +29 -1
  37. package/dist/provided.d.ts +1 -0
  38. package/dist/query-log.d.ts +49 -0
  39. package/dist/query-log.js +261 -0
  40. package/dist/record-cli.d.ts +6 -0
  41. package/dist/record-cli.js +93 -0
  42. package/dist/record-session.d.ts +1 -0
  43. package/dist/record-session.js +10 -0
  44. package/dist/record.d.ts +46 -0
  45. package/dist/record.js +201 -0
  46. package/dist/runtime.d.ts +10 -0
  47. package/dist/runtime.js +62 -3
  48. package/dist/stub.d.ts +32 -0
  49. package/dist/stub.js +87 -0
  50. package/dist/trace.d.ts +9 -1
  51. package/dist/trace.js +3 -1
  52. package/dist/vitest.js +5 -2
  53. package/dist/webhook.d.ts +40 -0
  54. package/dist/webhook.js +52 -0
  55. package/dist/yaml-runtime.d.ts +1 -0
  56. package/dist/yaml-runtime.js +92 -15
  57. package/dist/yaml.d.ts +59 -1
  58. package/dist/yaml.js +88 -5
  59. package/package.json +11 -4
  60. package/schema/scenario.schema.json +329 -1
@@ -1,4 +1,4 @@
1
- import type { Driver, Engine, Row, Table } from "./driver.js";
1
+ import type { Driver, Engine, Row, Table, TableShape } from "./driver.js";
2
2
  export declare class PostgresDriver implements Driver {
3
3
  private readonly client;
4
4
  private constructor();
@@ -14,6 +14,7 @@ export declare class PostgresDriver implements Driver {
14
14
  listTables(schemas: string[], keep: string[]): Promise<Table[]>;
15
15
  truncate(tables: Table[]): Promise<void>;
16
16
  insert(table: string, row: Row): Promise<Row[]>;
17
+ describe(table: string): Promise<TableShape>;
17
18
  close(): Promise<void>;
18
19
  }
19
20
  export declare const postgres: Engine;
@@ -88,6 +88,34 @@ export class PostgresDriver {
88
88
  : `INSERT INTO ${this.ident(table)} (${cols.map((c) => this.ident(c)).join(", ")}) VALUES (${cols.map((_, i) => `$${i + 1}`).join(", ")}) RETURNING *`;
89
89
  return this.query(sql, Object.values(row));
90
90
  }
91
+ async describe(table) {
92
+ const [{ oid } = { oid: null }] = await this.query("SELECT to_regclass($1)::oid AS oid", [this.ident(table)]);
93
+ if (oid === null)
94
+ throw new Error(`slicetest: there is no table "${table}"`);
95
+ const columns = await this.query(`SELECT a.attname AS name, format_type(a.atttypid, a.atttypmod) AS type, NOT a.attnotnull AS nullable,
96
+ (a.atthasdef OR a.attidentity <> '' OR a.attgenerated <> '') AS has_default,
97
+ information_schema._pg_char_max_length(a.atttypid, a.atttypmod) AS max,
98
+ (SELECT array_agg(e.enumlabel::text ORDER BY e.enumsortorder) FROM pg_enum e WHERE e.enumtypid = a.atttypid) AS values
99
+ FROM pg_attribute a
100
+ WHERE a.attrelid = $1 AND a.attnum > 0 AND NOT a.attisdropped
101
+ ORDER BY a.attnum`, [oid]);
102
+ const keys = await this.query(`SELECT (SELECT array_agg(a.attname::text ORDER BY k.ord) FROM unnest(c.conkey) WITH ORDINALITY k(n, ord)
103
+ JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.n) AS columns,
104
+ n.nspname AS schema, r.relname AS table,
105
+ (SELECT array_agg(a.attname::text ORDER BY k.ord) FROM unnest(c.confkey) WITH ORDINALITY k(n, ord)
106
+ JOIN pg_attribute a ON a.attrelid = c.confrelid AND a.attnum = k.n) AS references
107
+ FROM pg_constraint c
108
+ JOIN pg_class r ON r.oid = c.confrelid
109
+ JOIN pg_namespace n ON n.oid = r.relnamespace
110
+ WHERE c.contype = 'f' AND c.conrelid = $1
111
+ ORDER BY c.conname`, [oid]);
112
+ const checks = await this.query("SELECT pg_get_constraintdef(oid) AS def FROM pg_constraint WHERE contype = 'c' AND conrelid = $1", [oid]);
113
+ return {
114
+ columns: columns.map((c) => ({ name: c.name, type: c.type, nullable: c.nullable, hasDefault: c.has_default, maxLength: c.max ?? undefined, values: c.values ?? undefined })),
115
+ foreignKeys: keys.map((k) => ({ columns: k.columns, table: k.schema === "public" ? k.table : `${k.schema}.${k.table}`, references: k.references })),
116
+ checks: checks.map((c) => c.def),
117
+ };
118
+ }
91
119
  async close() {
92
120
  await this.client.end();
93
121
  }
@@ -0,0 +1,24 @@
1
+ import type { Driver, Engine, Row, Table, TableShape } 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
+ describe(table: string): Promise<TableShape>;
22
+ close(): Promise<void>;
23
+ }
24
+ export declare const sqliteEngine: Engine;
@@ -0,0 +1,255 @@
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
+ /** The parenthesised bodies of every CHECK in a CREATE TABLE statement. */
39
+ function checkBodies(sql) {
40
+ const bodies = [];
41
+ for (const m of sql.matchAll(/\bcheck\s*\(/gi)) {
42
+ let depth = 1;
43
+ let i = m.index + m[0].length;
44
+ const start = i;
45
+ for (; i < sql.length && depth > 0; i++) {
46
+ if (sql[i] === "'")
47
+ i = sql.indexOf("'", i + 1);
48
+ else if (sql[i] === "(")
49
+ depth++;
50
+ else if (sql[i] === ")")
51
+ depth--;
52
+ if (i < 0)
53
+ return bodies;
54
+ }
55
+ bodies.push(sql.slice(start, i - 1));
56
+ }
57
+ return bodies;
58
+ }
59
+ function bind(v) {
60
+ if (typeof v === "boolean")
61
+ return v ? 1 : 0;
62
+ if (v instanceof Date)
63
+ return v.toISOString();
64
+ if (v !== null && typeof v === "object" && !(v instanceof Uint8Array))
65
+ return JSON.stringify(v);
66
+ return v === undefined ? null : v;
67
+ }
68
+ async function openDb(file) {
69
+ const { DatabaseSync } = await load();
70
+ const db = new DatabaseSync(file);
71
+ // The app may be writing; wait for its lock instead of failing with SQLITE_BUSY.
72
+ db.exec("PRAGMA busy_timeout = 5000");
73
+ return db;
74
+ }
75
+ export class SqliteDriver {
76
+ db;
77
+ constructor(db) {
78
+ this.db = db;
79
+ }
80
+ static async connect(url) {
81
+ return new SqliteDriver(await openDb(sqlitePath(url)));
82
+ }
83
+ async query(sql, params = []) {
84
+ const stmt = this.db.prepare(sql);
85
+ const values = params.map(bind);
86
+ // Rows come with a null prototype; plain objects compare and print as expected.
87
+ return stmt.columns().length === 0 ? (stmt.run(...values), []) : stmt.all(...values).map((r) => ({ ...r }));
88
+ }
89
+ async queryAll(sqls) {
90
+ return Promise.all(sqls.map((s) => this.query(s)));
91
+ }
92
+ async exec(script) {
93
+ this.db.exec(script);
94
+ }
95
+ ident(name) {
96
+ return name
97
+ .split(".")
98
+ .map((part) => this.column(part))
99
+ .join(".");
100
+ }
101
+ column(name) {
102
+ return `"${name.replace(/"/g, '""')}"`;
103
+ }
104
+ param(n) {
105
+ return `?${n}`;
106
+ }
107
+ inList(column, values, params) {
108
+ if (values.length === 0)
109
+ return "0";
110
+ const start = params.length;
111
+ params.push(...values);
112
+ return `${this.ident(column)} IN (${values.map((_, i) => `?${start + i + 1}`).join(", ")})`;
113
+ }
114
+ /** SQLite has one schema per file (`main`), so `schemas` doesn't apply. */
115
+ async listTables(_schemas, keep) {
116
+ const rows = await this.query(`SELECT m.name AS name,
117
+ (SELECT group_concat(name, char(10)) FROM (SELECT name FROM pragma_table_info(m.name) WHERE pk > 0 ORDER BY pk)) AS key
118
+ FROM sqlite_master m
119
+ WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite\\_%' ESCAPE '\\'
120
+ ORDER BY 1`);
121
+ return rows
122
+ .filter((r) => !keep.includes(r.name) && !keep.includes(`main.${r.name}`))
123
+ .map((r) => ({ name: r.name, quoted: this.ident(r.name), key: r.key ? r.key.split("\n") : [] }));
124
+ }
125
+ /** DELETE, then forget AUTOINCREMENT counters. Foreign keys are off for this connection only. */
126
+ async truncate(tables) {
127
+ if (tables.length === 0)
128
+ return;
129
+ const sequence = (await this.query("SELECT 1 FROM sqlite_master WHERE name = 'sqlite_sequence'")).length > 0;
130
+ const names = tables.map((t) => `'${t.name.replace(/'/g, "''")}'`).join(", ");
131
+ this.db.exec(`PRAGMA foreign_keys = OFF; BEGIN IMMEDIATE; ${tables.map((t) => `DELETE FROM ${t.quoted};`).join(" ")}` +
132
+ `${sequence ? ` DELETE FROM sqlite_sequence WHERE name IN (${names});` : ""} COMMIT; PRAGMA foreign_keys = ON;`);
133
+ }
134
+ async insert(table, row) {
135
+ const cols = Object.keys(row);
136
+ const sql = cols.length === 0
137
+ ? `INSERT INTO ${this.ident(table)} DEFAULT VALUES RETURNING *`
138
+ : `INSERT INTO ${this.ident(table)} (${cols.map((c) => this.column(c)).join(", ")}) VALUES (${cols.map((_, i) => `?${i + 1}`).join(", ")}) RETURNING *`;
139
+ return this.query(sql, Object.values(row));
140
+ }
141
+ async describe(table) {
142
+ const [master] = await this.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?1", [table]);
143
+ if (!master)
144
+ throw new Error(`slicetest: there is no table "${table}"`);
145
+ const columns = await this.query("SELECT name, type, \"notnull\", dflt_value, pk, hidden FROM pragma_table_xinfo(?1) ORDER BY cid", [table]);
146
+ const pk = columns.filter((c) => c.pk > 0);
147
+ // An INTEGER PRIMARY KEY is the rowid: SQLite assigns it.
148
+ const rowid = pk.length === 1 && pk[0].type.toUpperCase() === "INTEGER" ? pk[0].name : undefined;
149
+ const keys = await this.query('SELECT id, "table", "from", "to" FROM pragma_foreign_key_list(?1) ORDER BY id, seq', [table]);
150
+ const foreignKeys = new Map();
151
+ for (const k of keys) {
152
+ const fk = foreignKeys.get(k.id) ?? { columns: [], table: k.table, references: [] };
153
+ fk.columns.push(k.from);
154
+ if (k.to !== null)
155
+ fk.references.push(k.to);
156
+ foreignKeys.set(k.id, fk);
157
+ }
158
+ return {
159
+ columns: columns
160
+ .filter((c) => c.hidden !== 1)
161
+ .map((c) => ({
162
+ name: c.name,
163
+ type: c.type,
164
+ nullable: c.notnull === 0 && c.pk === 0,
165
+ hasDefault: c.dflt_value !== null || c.hidden > 1 || c.name === rowid,
166
+ maxLength: Number(/\((\d+)\)/.exec(c.type)?.[1]) || undefined,
167
+ })),
168
+ foreignKeys: [...foreignKeys.values()],
169
+ checks: checkBodies(master.sql),
170
+ };
171
+ }
172
+ async close() {
173
+ this.db.close();
174
+ }
175
+ }
176
+ class SqliteAdmin {
177
+ dir;
178
+ constructor(dir) {
179
+ this.dir = dir;
180
+ }
181
+ #file(name) {
182
+ return path.join(this.dir, `${name}.db`);
183
+ }
184
+ async databases(prefix) {
185
+ return (await readdir(this.dir)).filter((f) => f.endsWith(".db") && f.startsWith(prefix)).map((f) => f.slice(0, -3));
186
+ }
187
+ async create(name) {
188
+ const db = await openDb(this.#file(name));
189
+ // Persisted in the file: the app and slicetest can then read and write at the same time.
190
+ db.exec("PRAGMA journal_mode = WAL");
191
+ db.close();
192
+ }
193
+ async clone(template, name) {
194
+ const db = await openDb(this.#file(template));
195
+ try {
196
+ db.prepare("VACUUM INTO ?").run(this.#file(name));
197
+ }
198
+ finally {
199
+ db.close();
200
+ }
201
+ const copy = await openDb(this.#file(name));
202
+ copy.exec("PRAGMA journal_mode = WAL");
203
+ copy.close();
204
+ }
205
+ async drop(name) {
206
+ for (const suffix of ["", "-wal", "-shm", "-journal"])
207
+ await rm(this.#file(name) + suffix, { force: true });
208
+ }
209
+ /** A lock file, for runs sharing the directory (with `reuse`). */
210
+ async withLock(key, fn) {
211
+ const lock = path.join(this.dir, `${key}.lock`);
212
+ const deadline = Date.now() + 120_000;
213
+ for (;;) {
214
+ try {
215
+ await (await open(lock, "wx")).close();
216
+ break;
217
+ }
218
+ catch (e) {
219
+ if (e.code !== "EEXIST" || Date.now() > deadline)
220
+ throw e;
221
+ await new Promise((r) => setTimeout(r, 100));
222
+ }
223
+ }
224
+ try {
225
+ return await fn();
226
+ }
227
+ finally {
228
+ await rm(lock, { force: true });
229
+ }
230
+ }
231
+ urlFor(name) {
232
+ return sqliteUrl(this.#file(name));
233
+ }
234
+ async close() { }
235
+ }
236
+ export const sqliteEngine = {
237
+ name: "sqlite",
238
+ defaultImage: "",
239
+ local: true,
240
+ /** No container: a directory of database files, kept between runs with `reuse`. */
241
+ async startContainer(_image, reuse) {
242
+ await load();
243
+ const dir = reuse ? path.join(os.tmpdir(), "slicetest-sqlite") : await mkdtemp(path.join(os.tmpdir(), "slicetest-sqlite-"));
244
+ await mkdir(dir, { recursive: true });
245
+ return { url: sqliteUrl(dir), stop: () => rm(dir, { recursive: true, force: true }) };
246
+ },
247
+ async admin(url) {
248
+ const dir = sqlitePath(url);
249
+ if (!existsSync(dir))
250
+ await mkdir(dir, { recursive: true });
251
+ return new SqliteAdmin(dir);
252
+ },
253
+ driver: (url) => SqliteDriver.connect(url),
254
+ atlasUrl: (url) => `sqlite://${sqlitePath(url)}`,
255
+ };
@@ -0,0 +1,21 @@
1
+ import type { Column, Row, TableShape } from "./drivers/driver.js";
2
+ /**
3
+ * `db.make()`: rows that satisfy the schema without spelling it out. Required
4
+ * columns the caller doesn't give get a value of their type, enum and
5
+ * `CHECK (col IN (...))` columns their first allowed value, and required
6
+ * foreign keys a parent row made the same way. Values are numbered per
7
+ * scenario, so they are unique and the same on every run.
8
+ */
9
+ export declare class Factory {
10
+ #private;
11
+ private readonly describe;
12
+ private readonly insert;
13
+ constructor(describe: (table: string) => Promise<TableShape>, insert: (table: string, row: Row) => Promise<Row[]>);
14
+ /** Restart numbering; called when the database is reset. */
15
+ reset(): void;
16
+ make(table: string, overrides?: Row, path?: string[]): Promise<Row>;
17
+ }
18
+ /** String literals allowed by a CHECK on `column` such as `status IN ('open', 'closed')` or `status = ANY (ARRAY[...])`. */
19
+ export declare function allowedValues(column: string, checks: string[]): string[] | undefined;
20
+ /** A value of `column`'s type, distinct for each `n`. */
21
+ export declare function valueFor(table: string, column: Column, n: number, allowed?: string[]): unknown;
@@ -0,0 +1,129 @@
1
+ /**
2
+ * `db.make()`: rows that satisfy the schema without spelling it out. Required
3
+ * columns the caller doesn't give get a value of their type, enum and
4
+ * `CHECK (col IN (...))` columns their first allowed value, and required
5
+ * foreign keys a parent row made the same way. Values are numbered per
6
+ * scenario, so they are unique and the same on every run.
7
+ */
8
+ export class Factory {
9
+ describe;
10
+ insert;
11
+ #n = 0;
12
+ #shapes = new Map();
13
+ constructor(describe, insert) {
14
+ this.describe = describe;
15
+ this.insert = insert;
16
+ }
17
+ /** Restart numbering; called when the database is reset. */
18
+ reset() {
19
+ this.#n = 0;
20
+ }
21
+ async make(table, overrides = {}, path = []) {
22
+ const shape = await this.#shape(table);
23
+ for (const col of Object.keys(overrides)) {
24
+ if (!shape.columns.some((c) => c.name === col)) {
25
+ throw new Error(`slicetest: db.make("${table}"): there is no column "${col}" (columns: ${shape.columns.map((c) => c.name).join(", ")})`);
26
+ }
27
+ }
28
+ const n = ++this.#n;
29
+ const row = { ...overrides };
30
+ const byName = new Map(shape.columns.map((c) => [c.name, c]));
31
+ const needed = (name) => {
32
+ const c = byName.get(name);
33
+ return !!c && !c.nullable && !c.hasDefault && !(name in row);
34
+ };
35
+ for (const fk of shape.foreignKeys) {
36
+ // Only fill keys that must be set and that the caller left entirely to us.
37
+ if (!fk.columns.some(needed) || fk.columns.some((c) => c in overrides))
38
+ continue;
39
+ if (fk.table === table || path.includes(fk.table)) {
40
+ const chain = [...path, table, fk.table].join(" → ");
41
+ throw new Error(`slicetest: db.make("${table}") can't create a parent for ${fk.columns.join(", ")}: the foreign keys form a cycle (${chain}). Pass ${fk.columns.join(", ")} yourself.`);
42
+ }
43
+ const parent = await this.make(fk.table, {}, [...path, table]);
44
+ const refs = fk.references.length ? fk.references : (await this.#primaryKey(fk.table, parent));
45
+ fk.columns.forEach((c, i) => (row[c] = parent[refs[i]]));
46
+ }
47
+ for (const c of shape.columns) {
48
+ if (needed(c.name))
49
+ row[c.name] = valueFor(table, c, n, allowedValues(c.name, shape.checks));
50
+ }
51
+ try {
52
+ const [stored] = await this.insert(table, row);
53
+ return stored ?? row;
54
+ }
55
+ catch (e) {
56
+ const generated = Object.fromEntries(Object.entries(row).filter(([k]) => !(k in overrides)));
57
+ throw new Error(`slicetest: db.make("${table}") failed: ${e.message}\n` +
58
+ ` generated: ${JSON.stringify(generated)}\n` +
59
+ ` pass the columns that need specific values, e.g. db.make("${table}", { ${Object.keys(generated)[0] ?? "column"}: ... })`, { cause: e });
60
+ }
61
+ }
62
+ #shape(table) {
63
+ let shape = this.#shapes.get(table);
64
+ if (!shape) {
65
+ shape = this.describe(table);
66
+ this.#shapes.set(table, shape);
67
+ shape.catch(() => this.#shapes.delete(table));
68
+ }
69
+ return shape;
70
+ }
71
+ /** SQLite foreign keys may omit the referenced columns: they mean the parent's primary key, or its rowid. */
72
+ async #primaryKey(table, parent) {
73
+ const shape = await this.#shape(table);
74
+ const key = shape.columns.filter((c) => c.hasDefault && /^integer$/i.test(c.type)).map((c) => c.name);
75
+ return key.length ? key : Object.keys(parent).slice(0, 1);
76
+ }
77
+ }
78
+ /** String literals allowed by a CHECK on `column` such as `status IN ('open', 'closed')` or `status = ANY (ARRAY[...])`. */
79
+ export function allowedValues(column, checks) {
80
+ const esc = column.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
81
+ const mention = new RegExp(`(^|[^\\w])[\`"]?${esc}[\`"]?\\)?(::\\w+(\\s\\w+)?)?\\s*(in\\s*\\(|=\\s*any\\s*\\()`, "i");
82
+ for (const check of checks) {
83
+ if (!mention.test(check))
84
+ continue;
85
+ const literals = [...check.matchAll(/(?:_\w+)?'((?:[^']|'')*)'/g)].map((m) => m[1].replace(/''/g, "'"));
86
+ if (literals.length)
87
+ return literals;
88
+ }
89
+ return undefined;
90
+ }
91
+ const EPOCH = Date.UTC(2026, 0, 1);
92
+ /** A value of `column`'s type, distinct for each `n`. */
93
+ export function valueFor(table, column, n, allowed) {
94
+ const values = column.values?.length ? column.values : allowed;
95
+ if (values?.length)
96
+ return values[0];
97
+ const t = column.type.toLowerCase();
98
+ if (/\[\]$/.test(t))
99
+ return "{}";
100
+ if (/^bool/.test(t) || t === "tinyint(1)")
101
+ return false;
102
+ if (/uuid/.test(t))
103
+ return `00000000-0000-4000-8000-${n.toString(16).padStart(12, "0")}`;
104
+ if (/json/.test(t))
105
+ return "{}";
106
+ if (/^(timestamp|datetime)/.test(t))
107
+ return new Date(EPOCH + n * 1000).toISOString().slice(0, 19).replace("T", " ");
108
+ if (/^date/.test(t))
109
+ return new Date(EPOCH + (n - 1) * 86_400_000).toISOString().slice(0, 10);
110
+ if (/^time/.test(t))
111
+ return "00:00:00";
112
+ if (/int|serial|numeric|decimal|real|double|float|money|number/.test(t))
113
+ return n;
114
+ if (/bytea|blob|binary/.test(t))
115
+ return Buffer.from(String(n));
116
+ if (/^inet/.test(t))
117
+ return `10.0.${Math.floor(n / 256) % 256}.${n % 256}`;
118
+ if (t === "" || /char|text|clob|string|citext|name/.test(t))
119
+ return text(table, column, n);
120
+ throw new Error(`slicetest: db.make("${table}") doesn't know how to make a value for ${column.name} (${column.type}); pass it yourself: db.make("${table}", { ${column.name}: ... })`);
121
+ }
122
+ function text(table, column, n) {
123
+ const name = column.name.toLowerCase();
124
+ let s = /e_?mail/.test(name) ? `${table}-${n}@example.test` : /url|website|link/.test(name) ? `https://example.test/${table}/${n}` : `${column.name}-${n}`;
125
+ const max = column.maxLength;
126
+ if (max !== undefined && s.length > max)
127
+ s = String(n).padStart(max, "0").slice(-max);
128
+ return s;
129
+ }
package/dist/gen.d.ts CHANGED
@@ -21,4 +21,5 @@ export declare function gen(root: string, opts: GenOptions): Promise<{
21
21
  written: string[];
22
22
  skipped: string[];
23
23
  count: number;
24
+ needsAuth: boolean;
24
25
  }>;
package/dist/gen.js CHANGED
@@ -58,7 +58,7 @@ export async function gen(root, opts) {
58
58
  await writeFile(file, header + stringify({ scenarios }, { lineWidth: 0, aliasDuplicateObjects: false }));
59
59
  written.push(rel);
60
60
  }
61
- return { written, skipped, count };
61
+ return { written, skipped, count, needsAuth: ops.some((o) => o.bearer) };
62
62
  }
63
63
  function scenarioFor(op, key, all) {
64
64
  const steps = [];
@@ -80,9 +80,11 @@ function scenarioFor(op, key, all) {
80
80
  // Two error responses need no arranging: a made-up id is not found, and an empty body is invalid.
81
81
  const notFound = status === 404 && Object.keys(op.pathParams).length > 0;
82
82
  const invalid = (status === 400 || status === 422) && isFilledObject(op.json);
83
- steps.push(requestStep(invalid ? { ...op, json: {} } : op, pathText, { status }));
83
+ // A protected operation answers 401 to a request without a token, whatever its state.
84
+ const unauthenticated = status === 401 && !!op.bearer;
85
+ steps.push(requestStep(invalid ? { ...op, json: {} } : op, pathText, { status }, undefined, unauthenticated));
84
86
  const name = `${op.method} ${op.template} → ${key}${op.summary ? ` (${op.summary})` : ""}`;
85
- const runnable = success ? !unresolved : notFound || invalid;
87
+ const runnable = success ? !unresolved : notFound || invalid || unauthenticated;
86
88
  return runnable ? { name, steps } : { name: `${name} — TODO: arrange the state that produces this`, skip: true, steps };
87
89
  }
88
90
  /**
@@ -99,8 +101,11 @@ function creatorFor(op, param, all) {
99
101
  const status = post.responses.filter((k) => /^2\d\d$/.test(k)).sort()[0];
100
102
  return field && status ? { op: post, field, status } : undefined;
101
103
  }
102
- function requestStep(op, pathText, expect, capture) {
104
+ function requestStep(op, pathText, expect, capture, anonymous = false) {
103
105
  const step = { request: `${op.method} ${pathText}` };
106
+ // Tokens come from the `auth` issuer; scopes the spec requires go into the `scope` claim.
107
+ if (op.bearer && !anonymous)
108
+ step.auth = op.bearer.scopes.length ? { scope: op.bearer.scopes.join(" ") } : true;
104
109
  if (Object.keys(op.query).length)
105
110
  step.query = Object.fromEntries(Object.entries(op.query).map(([k, v]) => [k, String(v)]));
106
111
  if (op.json !== undefined)
@@ -4,6 +4,7 @@ import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { promisify } from "node:util";
7
+ import { annotation, appendSummary, failureAnnotations, failureSummary, onGitHub, repoPath, yamlFailures } from "./ci.js";
7
8
  import { configureContainerRuntime } from "./container-runtime.js";
8
9
  import { engineFor } from "./drivers/index.js";
9
10
  import { coverageCacheFile } from "./gen.js";
@@ -21,7 +22,8 @@ export default async function setup(project) {
21
22
  adminUrl = opts.db.url;
22
23
  }
23
24
  else {
24
- configureContainerRuntime();
25
+ if (!engine.local)
26
+ configureContainerRuntime();
25
27
  const container = await engine.startContainer(opts.db.image, opts.db.reuse);
26
28
  adminUrl = container.url;
27
29
  if (!opts.db.reuse)
@@ -50,7 +52,9 @@ export default async function setup(project) {
50
52
  // Likewise for recordings made against real services, merged into the recordings files at the end.
51
53
  const recording = Object.values(opts.recordings).some((r) => r.record);
52
54
  const recordDir = recording ? await mkdtemp(path.join(os.tmpdir(), "slicetest-recordings-")) : undefined;
53
- project.provide("slicetestDb", { adminUrl, template, prefix, coverageDir, recordDir });
55
+ // On GitHub Actions, failed YAML steps are collected here and turned into annotations at the end.
56
+ const ciDir = onGitHub() ? await mkdtemp(path.join(os.tmpdir(), "slicetest-ci-")) : undefined;
57
+ project.provide("slicetestDb", { adminUrl, template, prefix, coverageDir, recordDir, ciDir });
54
58
  return async () => {
55
59
  try {
56
60
  if (!stopContainer) {
@@ -66,6 +70,8 @@ export default async function setup(project) {
66
70
  await reportCoverage(opts, coverageDir);
67
71
  if (recordDir)
68
72
  await saveRecordings(opts, recordDir);
73
+ if (ciDir)
74
+ await reportToGitHub(ciDir);
69
75
  }
70
76
  };
71
77
  }
@@ -85,10 +91,14 @@ async function reportCoverage(opts, dir) {
85
91
  const cache = coverageCacheFile(opts.root);
86
92
  await mkdir(path.dirname(cache), { recursive: true }).then(() => writeFile(cache, JSON.stringify([...hits]))).catch(() => { });
87
93
  console.log(`\n${report.text}\n`);
94
+ await appendSummary(report.markdown);
88
95
  const min = opts.openapi.minCoverage;
89
96
  if (min !== undefined && report.percent < min) {
90
97
  // 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`);
98
+ const message = `OpenAPI coverage ${report.percent}% is below openapi.minCoverage (${min}%)`;
99
+ console.error(`slicetest: ${message}\n`);
100
+ if (onGitHub())
101
+ console.log(annotation("error", message, { file: repoPath(path.resolve(opts.root, opts.openapi.app)), title: "slicetest: OpenAPI coverage" }));
92
102
  process.exitCode = 1;
93
103
  }
94
104
  }
@@ -96,6 +106,17 @@ async function reportCoverage(opts, dir) {
96
106
  await rm(dir, { recursive: true, force: true });
97
107
  }
98
108
  }
109
+ async function reportToGitHub(dir) {
110
+ try {
111
+ const failures = await yamlFailures(dir);
112
+ for (const line of failureAnnotations(failures))
113
+ console.log(line);
114
+ await appendSummary(failureSummary(failures));
115
+ }
116
+ finally {
117
+ await rm(dir, { recursive: true, force: true });
118
+ }
119
+ }
99
120
  async function saveRecordings(opts, dir) {
100
121
  try {
101
122
  const files = await readdir(dir);
package/dist/http.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type WebhookOptions } from "./webhook.js";
1
2
  export interface HttpResponse {
2
3
  method: string;
3
4
  url: string;
@@ -37,6 +38,18 @@ export declare class HttpClient {
37
38
  post(path: string, body?: unknown, opts?: RequestOptions): Promise<HttpResponse>;
38
39
  put(path: string, body?: unknown, opts?: RequestOptions): Promise<HttpResponse>;
39
40
  patch(path: string, body?: unknown, opts?: RequestOptions): Promise<HttpResponse>;
41
+ /**
42
+ * POST `payload` to the app as `provider` would deliver it, signed with `secret`:
43
+ * `http.webhook("/webhooks/stripe", event, { provider: "stripe", secret: "whsec_test" })`.
44
+ * `{ invalidSignature: true }` and `{ stale: true }` make deliveries the app must refuse.
45
+ */
46
+ webhook(path: string, payload: unknown, opts: WebhookOptions): Promise<HttpResponse>;
47
+ /**
48
+ * Run `send` `n` times at once and wait for every response, to provoke races
49
+ * (double bookings, lost updates). Pair with `toHaveStatuses({ 201: 1, 409: n - 1 })`
50
+ * and a check of the database. Requests are released together once all are prepared.
51
+ */
52
+ concurrently(n: number, send: (i: number) => Promise<HttpResponse>): Promise<HttpResponse[]>;
40
53
  /** Strings, URLSearchParams, FormData, Blob and byte arrays are sent as-is; anything else is sent as JSON. */
41
54
  request(method: string, path: string, body?: unknown, options?: RequestOptions): Promise<HttpResponse>;
42
55
  /** Call `fn` with every response this client (or one derived with `with()`) receives. */
package/dist/http.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { signWebhook, webhookBody } from "./webhook.js";
1
2
  /** Per-scenario state shared by a client and every client derived from it with `with()`. */
2
3
  class Session {
3
4
  cookies = new Map();
@@ -41,6 +42,29 @@ export class HttpClient {
41
42
  patch(path, body, opts) {
42
43
  return this.request("PATCH", path, body, opts);
43
44
  }
45
+ /**
46
+ * POST `payload` to the app as `provider` would deliver it, signed with `secret`:
47
+ * `http.webhook("/webhooks/stripe", event, { provider: "stripe", secret: "whsec_test" })`.
48
+ * `{ invalidSignature: true }` and `{ stale: true }` make deliveries the app must refuse.
49
+ */
50
+ webhook(path, payload, opts) {
51
+ const { body, type } = webhookBody(payload);
52
+ return this.request("POST", path, body, { headers: { "content-type": type, ...signWebhook(body, opts), ...opts.headers } });
53
+ }
54
+ /**
55
+ * Run `send` `n` times at once and wait for every response, to provoke races
56
+ * (double bookings, lost updates). Pair with `toHaveStatuses({ 201: 1, 409: n - 1 })`
57
+ * and a check of the database. Requests are released together once all are prepared.
58
+ */
59
+ async concurrently(n, send) {
60
+ if (!Number.isInteger(n) || n < 1)
61
+ throw new Error(`slicetest: concurrently() needs a positive number of requests, got ${n}`);
62
+ let release;
63
+ const gate = new Promise((r) => (release = r));
64
+ const all = Promise.all(Array.from({ length: n }, (_, i) => gate.then(() => send(i))));
65
+ release();
66
+ return all;
67
+ }
44
68
  /** Strings, URLSearchParams, FormData, Blob and byte arrays are sent as-is; anything else is sent as JSON. */
45
69
  async request(method, path, body, options = {}) {
46
70
  const opts = merge(this.defaults, options);