slicetest 0.3.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.
- package/README.md +102 -6
- package/dist/ci.d.ts +29 -0
- package/dist/ci.js +57 -0
- package/dist/cli.js +23 -0
- package/dist/config.d.ts +11 -2
- package/dist/config.js +9 -4
- package/dist/doctor.d.ts +22 -0
- package/dist/doctor.js +190 -0
- package/dist/drivers/driver.d.ts +2 -0
- package/dist/drivers/index.js +2 -0
- package/dist/drivers/sqlite.d.ts +23 -0
- package/dist/drivers/sqlite.js +203 -0
- package/dist/global-setup.js +24 -3
- package/dist/http.d.ts +6 -0
- package/dist/http.js +14 -0
- package/dist/index.d.ts +1 -0
- package/dist/init.js +80 -5
- package/dist/mail.d.ts +58 -0
- package/dist/mail.js +299 -0
- package/dist/matchers.d.ts +4 -0
- package/dist/matchers.js +33 -0
- package/dist/openapi.d.ts +1 -0
- package/dist/openapi.js +11 -1
- package/dist/provided.d.ts +1 -0
- package/dist/record-cli.d.ts +6 -0
- package/dist/record-cli.js +93 -0
- package/dist/record-session.d.ts +1 -0
- package/dist/record-session.js +10 -0
- package/dist/record.d.ts +46 -0
- package/dist/record.js +201 -0
- package/dist/runtime.d.ts +4 -0
- package/dist/runtime.js +24 -3
- package/dist/trace.d.ts +9 -1
- package/dist/trace.js +3 -1
- package/dist/vitest.js +5 -2
- package/dist/yaml-runtime.d.ts +1 -0
- package/dist/yaml-runtime.js +52 -13
- package/dist/yaml.d.ts +24 -1
- package/dist/yaml.js +34 -5
- package/package.json +4 -3
- package/schema/scenario.schema.json +149 -1
package/dist/drivers/driver.d.ts
CHANGED
|
@@ -54,6 +54,8 @@ export interface Engine {
|
|
|
54
54
|
name: string;
|
|
55
55
|
/** Container image used when no `db.url` is given. */
|
|
56
56
|
defaultImage: string;
|
|
57
|
+
/** Runs in-process (SQLite): no container runtime needed. */
|
|
58
|
+
local?: boolean;
|
|
57
59
|
startContainer(image: string, reuse: boolean): Promise<{
|
|
58
60
|
url: string;
|
|
59
61
|
stop(): Promise<unknown>;
|
package/dist/drivers/index.js
CHANGED
|
@@ -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/global-setup.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -37,6 +37,12 @@ export declare class HttpClient {
|
|
|
37
37
|
post(path: string, body?: unknown, opts?: RequestOptions): Promise<HttpResponse>;
|
|
38
38
|
put(path: string, body?: unknown, opts?: RequestOptions): Promise<HttpResponse>;
|
|
39
39
|
patch(path: string, body?: unknown, opts?: RequestOptions): Promise<HttpResponse>;
|
|
40
|
+
/**
|
|
41
|
+
* Run `send` `n` times at once and wait for every response, to provoke races
|
|
42
|
+
* (double bookings, lost updates). Pair with `toHaveStatuses({ 201: 1, 409: n - 1 })`
|
|
43
|
+
* and a check of the database. Requests are released together once all are prepared.
|
|
44
|
+
*/
|
|
45
|
+
concurrently(n: number, send: (i: number) => Promise<HttpResponse>): Promise<HttpResponse[]>;
|
|
40
46
|
/** Strings, URLSearchParams, FormData, Blob and byte arrays are sent as-is; anything else is sent as JSON. */
|
|
41
47
|
request(method: string, path: string, body?: unknown, options?: RequestOptions): Promise<HttpResponse>;
|
|
42
48
|
/** Call `fn` with every response this client (or one derived with `with()`) receives. */
|
package/dist/http.js
CHANGED
|
@@ -41,6 +41,20 @@ export class HttpClient {
|
|
|
41
41
|
patch(path, body, opts) {
|
|
42
42
|
return this.request("PATCH", path, body, opts);
|
|
43
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* Run `send` `n` times at once and wait for every response, to provoke races
|
|
46
|
+
* (double bookings, lost updates). Pair with `toHaveStatuses({ 201: 1, 409: n - 1 })`
|
|
47
|
+
* and a check of the database. Requests are released together once all are prepared.
|
|
48
|
+
*/
|
|
49
|
+
async concurrently(n, send) {
|
|
50
|
+
if (!Number.isInteger(n) || n < 1)
|
|
51
|
+
throw new Error(`slicetest: concurrently() needs a positive number of requests, got ${n}`);
|
|
52
|
+
let release;
|
|
53
|
+
const gate = new Promise((r) => (release = r));
|
|
54
|
+
const all = Promise.all(Array.from({ length: n }, (_, i) => gate.then(() => send(i))));
|
|
55
|
+
release();
|
|
56
|
+
return all;
|
|
57
|
+
}
|
|
44
58
|
/** Strings, URLSearchParams, FormData, Blob and byte arrays are sent as-is; anything else is sent as JSON. */
|
|
45
59
|
async request(method, path, body, options = {}) {
|
|
46
60
|
const opts = merge(this.defaults, options);
|
package/dist/index.d.ts
CHANGED
|
@@ -7,5 +7,6 @@ export type { Stub, RecordedCall, StubResponse, Responder, MatchOptions, RouteBu
|
|
|
7
7
|
export type { HttpClient, HttpResponse, RequestOptions } from "./http.js";
|
|
8
8
|
export type { App } from "./app.js";
|
|
9
9
|
export type { Dependency } from "./containers.js";
|
|
10
|
+
export type { Mailbox, Mail, MailFilter } from "./mail.js";
|
|
10
11
|
export type { SlicetestOptions, ContainerOptions } from "./config.js";
|
|
11
12
|
export type {} from "./matchers.js";
|
package/dist/init.js
CHANGED
|
@@ -93,8 +93,21 @@ export async function detect(root) {
|
|
|
93
93
|
notes.push("db: no migrations found; the database starts empty. Set db.migrate.");
|
|
94
94
|
}
|
|
95
95
|
// --- docker compose: the database image and other dependencies ---
|
|
96
|
-
const { db: composeDb, containers, appEnv } = await fromCompose(root, notes);
|
|
96
|
+
const { db: composeDb, containers, appEnv, mail: composeMail } = await fromCompose(root, notes);
|
|
97
97
|
Object.assign(env, appEnv);
|
|
98
|
+
let mail = composeMail;
|
|
99
|
+
if (!mail && (deps.nodemailer || /\b(flask-mail|fastapi-mail|django-anymail)\b/.test(python) || (has("manage.py") && /EMAIL_HOST/.test(await read(await djangoSettings(root)))))) {
|
|
100
|
+
mail = true;
|
|
101
|
+
Object.assign(env, { SMTP_HOST: "{{mail.host}}", SMTP_PORT: "{{mail.port}}" });
|
|
102
|
+
notes.push("mail: the app sends mail; slicetest catches it over SMTP (SMTP_HOST / SMTP_PORT, rename them to what your app reads)");
|
|
103
|
+
}
|
|
104
|
+
const sqlite = await detectSqlite(root, read, deps, python, gemfile);
|
|
105
|
+
if (sqlite && !composeDb.engine) {
|
|
106
|
+
composeDb.engine = "sqlite";
|
|
107
|
+
env.DATABASE_URL = sqlite.url;
|
|
108
|
+
notes.push(`db: SQLite (${sqlite.why}), no container needed. The app gets DATABASE_URL=${sqlite.url}; {{db.path}} is the plain file path`);
|
|
109
|
+
}
|
|
110
|
+
const stubs = await stubsFromEnvExample(root, read, env, notes);
|
|
98
111
|
const mysqlDeps = !!deps.mysql2 || !!deps.mysql || /\b(pymysql|mysqlclient|aiomysql)\b/.test(python) || /\bgem ['"]mysql2['"]/.test(gemfile);
|
|
99
112
|
if (!composeDb.engine && mysqlDeps) {
|
|
100
113
|
composeDb.engine = "mysql";
|
|
@@ -108,11 +121,64 @@ export async function detect(root) {
|
|
|
108
121
|
app: { command, env, ready: { path: "/" } },
|
|
109
122
|
...(migrate || Object.keys(composeDb).length ? { db: { ...composeDb, ...(migrate ? { migrate } : {}) } } : {}),
|
|
110
123
|
...(Object.keys(containers).length ? { containers } : {}),
|
|
111
|
-
|
|
124
|
+
...(mail ? { mail: true } : {}),
|
|
125
|
+
stubs,
|
|
112
126
|
...(openapi ? { openapi } : {}),
|
|
113
127
|
};
|
|
114
128
|
return { config, notes };
|
|
115
129
|
}
|
|
130
|
+
async function djangoSettings(root) {
|
|
131
|
+
for (const dir of await readdir(root, { withFileTypes: true }).catch(() => [])) {
|
|
132
|
+
if (dir.isDirectory() && existsSync(path.join(root, dir.name, "settings.py")))
|
|
133
|
+
return path.join(dir.name, "settings.py");
|
|
134
|
+
}
|
|
135
|
+
return "settings.py";
|
|
136
|
+
}
|
|
137
|
+
/** Where the project says it uses SQLite, and the URL form its framework reads. */
|
|
138
|
+
async function detectSqlite(root, read, deps, python, gemfile) {
|
|
139
|
+
if (/provider\s*=\s*"sqlite"/.test(await read("prisma/schema.prisma")))
|
|
140
|
+
return { why: "Prisma provider", url: "file:{{db.path}}" };
|
|
141
|
+
if (/adapter:\s*sqlite3/.test(await read("config/database.yml")))
|
|
142
|
+
return { why: "config/database.yml", url: "sqlite3:{{db.path}}" };
|
|
143
|
+
if (/\bgem ['"]sqlite3['"]/.test(gemfile) && !/\bgem ['"](pg|mysql2)['"]/.test(gemfile))
|
|
144
|
+
return { why: "sqlite3 gem", url: "sqlite3:{{db.path}}" };
|
|
145
|
+
if (existsSync(path.join(root, "manage.py")) && /django\.db\.backends\.sqlite3/.test(await read(await djangoSettings(root)))) {
|
|
146
|
+
return { why: "Django settings; make DATABASES read DATABASE_URL, e.g. with dj-database-url", url: "{{db.url}}" };
|
|
147
|
+
}
|
|
148
|
+
const pgOrMysql = deps.pg || deps.postgres || deps.mysql2 || /\b(psycopg|asyncpg|pymysql|mysqlclient)/.test(python);
|
|
149
|
+
if (!pgOrMysql && (deps["better-sqlite3"] || deps.sqlite3 || deps["@libsql/client"]))
|
|
150
|
+
return { why: "a SQLite driver is a dependency", url: "{{db.url}}" };
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
/** Hosts that are never third-party APIs to stub. */
|
|
154
|
+
const LOCAL_HOST = /^(localhost|127\.|0\.0\.0\.0|\[::1\]|host\.docker\.internal|[\w-]+$)/;
|
|
155
|
+
/**
|
|
156
|
+
* `.env.example` lines like `STRIPE_API_BASE=https://api.stripe.com` name the
|
|
157
|
+
* services the app calls: each becomes a stub recorded from that URL, and the
|
|
158
|
+
* variable points the app at the stub.
|
|
159
|
+
*/
|
|
160
|
+
async function stubsFromEnvExample(root, read, env, notes) {
|
|
161
|
+
const file = [".env.example", ".env.sample", ".env.template", ".env.dist"].find((f) => existsSync(path.join(root, f)));
|
|
162
|
+
if (!file)
|
|
163
|
+
return [];
|
|
164
|
+
const stubs = [];
|
|
165
|
+
for (const line of (await read(file)).split(/\r?\n/)) {
|
|
166
|
+
const m = /^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=\s*["']?(https?:\/\/[^\s"'#]+)/.exec(line);
|
|
167
|
+
if (!m || !/(URL|URI|ENDPOINT|HOST|BASE)$/.test(m[1]) || /DATABASE|REDIS|MONGO|AMQP|SMTP|MAIL|CALLBACK|REDIRECT|FRONTEND|PUBLIC|APP_URL|SITE_URL|WEBHOOK_URL$/.test(m[1]))
|
|
168
|
+
continue;
|
|
169
|
+
const url = new URL(m[2]);
|
|
170
|
+
if (LOCAL_HOST.test(url.hostname))
|
|
171
|
+
continue;
|
|
172
|
+
const base = url.hostname.split(".").filter((p) => !["api", "www", "com", "io", "net", "org", "co", "dev", "app"].includes(p))[0] ?? url.hostname;
|
|
173
|
+
let name = base.replace(/[^\w-]/g, "-").toLowerCase();
|
|
174
|
+
while (stubs.some((s) => s.name === name))
|
|
175
|
+
name += "2";
|
|
176
|
+
stubs.push({ name, upstream: url.origin });
|
|
177
|
+
env[m[1]] = `{{stub.${name}}}${url.pathname.replace(/\/$/, "")}`;
|
|
178
|
+
notes.push(`stubs.${name}: ${m[1]} in ${file} points at ${url.origin}. Record it once with SLICETEST_RECORD=${name}, or register routes in scenarios`);
|
|
179
|
+
}
|
|
180
|
+
return stubs;
|
|
181
|
+
}
|
|
116
182
|
const SCENARIO = `# yaml-language-server: $schema=https://unpkg.com/slicetest/schema/scenario.schema.json
|
|
117
183
|
# A first scenario. Run it with: npx slicetest
|
|
118
184
|
scenarios:
|
|
@@ -142,6 +208,8 @@ export async function init(root, { force = false } = {}) {
|
|
|
142
208
|
return { files: [configFile, scenarioFile].map((f) => path.relative(root, f)), notes };
|
|
143
209
|
}
|
|
144
210
|
const COMPOSE_FILES = ["compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml"];
|
|
211
|
+
/** Development mail servers; `mail: true` does their job in-process. */
|
|
212
|
+
const MAIL_CATCHERS = /(^|\/)(mailpit|mailhog|maildev|smtp4dev|greenmail[\w-]*|mailcatcher|inbucket)(:|$)/;
|
|
145
213
|
/** Known images: the port they listen on, how to empty them, and the variable apps usually read. */
|
|
146
214
|
const KNOWN = [
|
|
147
215
|
{ match: /(^|\/)(redis|redis-stack|keydb)(:|$)/, port: 6379, reset: ["redis-cli", "FLUSHALL"], env: (n) => ["REDIS_URL", `redis://{{container.${n}}}`] },
|
|
@@ -165,16 +233,17 @@ async function fromCompose(root, notes) {
|
|
|
165
233
|
const db = {};
|
|
166
234
|
const containers = {};
|
|
167
235
|
const appEnv = {};
|
|
236
|
+
let mail = false;
|
|
168
237
|
const file = COMPOSE_FILES.find((f) => existsSync(path.join(root, f)));
|
|
169
238
|
if (!file)
|
|
170
|
-
return { db, containers, appEnv };
|
|
239
|
+
return { db, containers, appEnv, mail };
|
|
171
240
|
let doc;
|
|
172
241
|
try {
|
|
173
242
|
doc = (parse(await readFile(path.join(root, file), "utf8")) ?? {});
|
|
174
243
|
}
|
|
175
244
|
catch (e) {
|
|
176
245
|
notes.push(`${file}: couldn't parse it (${e.message}); skipped`);
|
|
177
|
-
return { db, containers, appEnv };
|
|
246
|
+
return { db, containers, appEnv, mail };
|
|
178
247
|
}
|
|
179
248
|
for (const [name, svc] of Object.entries(doc.services ?? {})) {
|
|
180
249
|
const image = typeof svc?.image === "string" ? svc.image : undefined;
|
|
@@ -194,6 +263,12 @@ async function fromCompose(root, notes) {
|
|
|
194
263
|
notes.push(`db: MySQL image ${image} (${file} service "${name}"). Install mysql2 and @testcontainers/mysql next to slicetest.`);
|
|
195
264
|
continue;
|
|
196
265
|
}
|
|
266
|
+
if (MAIL_CATCHERS.test(image)) {
|
|
267
|
+
mail = true;
|
|
268
|
+
Object.assign(appEnv, { SMTP_HOST: "{{mail.host}}", SMTP_PORT: "{{mail.port}}" });
|
|
269
|
+
notes.push(`mail: ${image} (${file} service "${name}") is replaced by slicetest's own SMTP server, passed to the app as SMTP_HOST / SMTP_PORT. Rename them to what your app reads`);
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
197
272
|
const known = KNOWN.find((k) => k.match.test(image));
|
|
198
273
|
const port = known?.port ?? containerPort(svc.ports?.[0] ?? svc.expose?.[0]);
|
|
199
274
|
if (!port) {
|
|
@@ -214,7 +289,7 @@ async function fromCompose(root, notes) {
|
|
|
214
289
|
appEnv[key] = value;
|
|
215
290
|
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
291
|
}
|
|
217
|
-
return { db, containers, appEnv };
|
|
292
|
+
return { db, containers, appEnv, mail };
|
|
218
293
|
}
|
|
219
294
|
/** `"6379:6379"`, `"127.0.0.1:5432:5432/tcp"`, `9000`, `{ target: 6379 }` → the port inside the container. */
|
|
220
295
|
export function containerPort(spec) {
|
package/dist/mail.d.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An SMTP server that accepts every message and keeps it, so scenarios can
|
|
3
|
+
* check the mail an app sends (sign-up confirmations, password resets,
|
|
4
|
+
* receipts) without a real mail server. Apps in any language reach it at
|
|
5
|
+
* `{{mail.host}}:{{mail.port}}` with no TLS; any credentials are accepted.
|
|
6
|
+
*/
|
|
7
|
+
export interface Mail {
|
|
8
|
+
from: string;
|
|
9
|
+
/** Envelope recipients (RCPT TO), which include Cc and Bcc. */
|
|
10
|
+
to: string[];
|
|
11
|
+
subject: string;
|
|
12
|
+
/** The text/plain part, decoded. */
|
|
13
|
+
text: string;
|
|
14
|
+
/** The text/html part, decoded, if there is one. */
|
|
15
|
+
html?: string;
|
|
16
|
+
/** Header names are lower-case. Repeated headers are joined with ", ". */
|
|
17
|
+
headers: Record<string, string>;
|
|
18
|
+
/** Every http(s) URL in the text and HTML parts, in order, without duplicates. */
|
|
19
|
+
links: string[];
|
|
20
|
+
raw: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Which messages to look at. Strings match the address exactly (`to`, `from`,
|
|
24
|
+
* case-insensitive) or as a substring (`subject`, `text`, `html`); RegExps test the value.
|
|
25
|
+
*/
|
|
26
|
+
export interface MailFilter {
|
|
27
|
+
to?: string | RegExp;
|
|
28
|
+
from?: string | RegExp;
|
|
29
|
+
subject?: string | RegExp;
|
|
30
|
+
text?: string | RegExp;
|
|
31
|
+
html?: string | RegExp;
|
|
32
|
+
}
|
|
33
|
+
export declare class Mailbox {
|
|
34
|
+
#private;
|
|
35
|
+
private readonly server;
|
|
36
|
+
private constructor();
|
|
37
|
+
static start(): Promise<Mailbox>;
|
|
38
|
+
get host(): string;
|
|
39
|
+
get port(): number;
|
|
40
|
+
/** `smtp://127.0.0.1:<port>`, for libraries configured with a URL. */
|
|
41
|
+
get url(): string;
|
|
42
|
+
/** Messages received during this scenario, oldest first. */
|
|
43
|
+
messages(filter?: MailFilter): Mail[];
|
|
44
|
+
/** The most recent message matching `filter`, or undefined. */
|
|
45
|
+
last(filter?: MailFilter): Mail | undefined;
|
|
46
|
+
/**
|
|
47
|
+
* The first message matching `filter`, waiting up to `within` ms for the app
|
|
48
|
+
* to send it. Fails with the messages that did arrive.
|
|
49
|
+
*/
|
|
50
|
+
waitFor(filter?: MailFilter, { within }?: {
|
|
51
|
+
within?: number | undefined;
|
|
52
|
+
}): Promise<Mail>;
|
|
53
|
+
/** One line per message received this scenario, for failure output. */
|
|
54
|
+
describe(): string;
|
|
55
|
+
reset(): void;
|
|
56
|
+
close(): Promise<void>;
|
|
57
|
+
}
|
|
58
|
+
export declare function parseMail(raw: string, from: string, to: string[]): Mail;
|