slicetest 0.9.0 → 0.10.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 +118 -30
- package/dist/app.js +10 -2
- package/dist/auth.js +2 -1
- package/dist/cli-run.d.ts +40 -0
- package/dist/cli-run.js +65 -0
- package/dist/cli.js +20 -34
- package/dist/config.d.ts +33 -2
- package/dist/config.js +82 -8
- package/dist/container-runtime.d.ts +4 -0
- package/dist/container-runtime.js +16 -0
- package/dist/containers.js +4 -3
- package/dist/db.d.ts +3 -1
- package/dist/db.js +45 -8
- package/dist/deadline.d.ts +7 -0
- package/dist/deadline.js +24 -0
- package/dist/doctor.js +27 -5
- package/dist/drivers/driver.d.ts +6 -0
- package/dist/drivers/mysql.js +5 -1
- package/dist/drivers/postgres.d.ts +2 -0
- package/dist/drivers/postgres.js +20 -1
- package/dist/exit.d.ts +1 -0
- package/dist/exit.js +28 -0
- package/dist/form.d.ts +1 -0
- package/dist/form.js +50 -6
- package/dist/har.js +2 -2
- package/dist/http.d.ts +9 -3
- package/dist/http.js +40 -5
- package/dist/init.js +11 -2
- package/dist/list.d.ts +1 -0
- package/dist/list.js +16 -3
- package/dist/mail.d.ts +1 -1
- package/dist/mail.js +7 -7
- package/dist/matchers.d.ts +4 -3
- package/dist/matchers.js +18 -6
- package/dist/openapi.d.ts +2 -0
- package/dist/openapi.js +133 -14
- package/dist/record-cli.js +2 -1
- package/dist/record.js +2 -1
- package/dist/recording.d.ts +4 -1
- package/dist/recording.js +14 -3
- package/dist/request-url.d.ts +1 -0
- package/dist/request-url.js +5 -0
- package/dist/runtime.d.ts +3 -3
- package/dist/runtime.js +132 -18
- package/dist/scenario.js +11 -2
- package/dist/stub.js +17 -6
- package/dist/vitest.d.ts +4 -0
- package/dist/vitest.js +6 -2
- package/dist/webhook.d.ts +3 -3
- package/dist/webhook.js +10 -2
- package/dist/yaml-runtime.d.ts +4 -4
- package/dist/yaml-runtime.js +96 -34
- package/dist/yaml.d.ts +4 -0
- package/dist/yaml.js +33 -2
- package/docs/guarantees.md +92 -0
- package/docs/state-isolation.md +94 -0
- package/package.json +4 -1
- package/schema/config.schema.json +675 -0
- package/schema/scenario.schema.json +27 -3
package/dist/cli.js
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
import { existsSync } from "node:fs";
|
|
8
8
|
import { readFile } from "node:fs/promises";
|
|
9
9
|
import path from "node:path";
|
|
10
|
-
import { parseArgs } from "node:util";
|
|
11
10
|
import { parse } from "yaml";
|
|
11
|
+
import { compatStart, packageVersion, parseCliArgs, runOptions } from "./cli-run.js";
|
|
12
12
|
import { CONFIG_NAMES } from "./config.js";
|
|
13
13
|
const HELP = `Usage: slicetest [filters...] [options]
|
|
14
14
|
slicetest init [--force]
|
|
@@ -51,15 +51,20 @@ Options:
|
|
|
51
51
|
github-actions (repeat for several)
|
|
52
52
|
--output-file <file> Where junit / json / tap reporters write
|
|
53
53
|
--shard <i/n> Run the i-th of n parts of the suite (split CI jobs)
|
|
54
|
+
--shuffle Run scenarios in a random order (the seed is printed), to find
|
|
55
|
+
ones that depend on another's leftovers
|
|
56
|
+
--seed <n> With --shuffle: repeat the order of that seed
|
|
57
|
+
--retry <n> Run a failing scenario up to n more times
|
|
54
58
|
--diagrams <dir> Write a Mermaid sequence diagram of every scenario to <dir>,
|
|
55
59
|
one Markdown page per scenario file
|
|
60
|
+
-v, --version Show the version
|
|
56
61
|
-h, --help Show this help
|
|
57
62
|
|
|
58
63
|
Config (paths are relative to the config file):
|
|
59
|
-
app: { command, env, cwd, ready: { path } | { log }, readyTimeout }
|
|
64
|
+
app: { command, env, cwd, ready: { path } | { log }, readyTimeout, restart, reset: { path }, idle: { path } }
|
|
60
65
|
db: { migrate: { atlas: { dir } } | { sql } | { command, inputs }, engine, seed, url, image, schemas, keep, ignoreChanges, reuse, queries }
|
|
61
66
|
stubs: [name | { name, openapi, autoReply, upstream, recordings }]
|
|
62
|
-
services: { name: { command, env, cwd, ready } }
|
|
67
|
+
services: { name: { command, env, cwd, ready, restart, reset: { path }, idle: { path } } }
|
|
63
68
|
containers: { name: { image, port, env, command, ready: { log }, reset } }
|
|
64
69
|
mail: true SMTP server at {{mail.host}} / {{mail.port}}
|
|
65
70
|
auth: true | { audience, claims } OpenID issuer at {{auth.issuer}} / {{auth.jwks}}
|
|
@@ -70,30 +75,11 @@ Config (paths are relative to the config file):
|
|
|
70
75
|
include: [globs] default ["**/*.scenario.{yaml,yml}"]
|
|
71
76
|
`;
|
|
72
77
|
export async function main(argv = process.argv.slice(2)) {
|
|
73
|
-
const { values, positionals } =
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
watch: { type: "boolean", short: "w" },
|
|
79
|
-
name: { type: "string", short: "t" },
|
|
80
|
-
tag: { type: "string", multiple: true },
|
|
81
|
-
help: { type: "boolean", short: "h" },
|
|
82
|
-
force: { type: "boolean" },
|
|
83
|
-
spec: { type: "string" },
|
|
84
|
-
out: { type: "string" },
|
|
85
|
-
uncovered: { type: "boolean" },
|
|
86
|
-
port: { type: "string" },
|
|
87
|
-
diagrams: { type: "string" },
|
|
88
|
-
stub: { type: "string" },
|
|
89
|
-
upstream: { type: "string" },
|
|
90
|
-
json: { type: "boolean" },
|
|
91
|
-
update: { type: "boolean", short: "u" },
|
|
92
|
-
reporter: { type: "string", multiple: true },
|
|
93
|
-
"output-file": { type: "string" },
|
|
94
|
-
shard: { type: "string" },
|
|
95
|
-
},
|
|
96
|
-
});
|
|
78
|
+
const { values, positionals } = parseCliArgs(argv);
|
|
79
|
+
if (values.version) {
|
|
80
|
+
process.stdout.write(`${await packageVersion()}\n`);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
97
83
|
if (values.help) {
|
|
98
84
|
process.stdout.write(HELP);
|
|
99
85
|
return;
|
|
@@ -168,7 +154,8 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
168
154
|
if (positionals[0] === "list") {
|
|
169
155
|
const root = configPath && existsSync(configPath) ? path.dirname(configPath) : process.cwd();
|
|
170
156
|
const { listScenarios, formatList } = await import("./list.js");
|
|
171
|
-
const
|
|
157
|
+
const config = configPath && existsSync(configPath) ? (parse(await readFile(configPath, "utf8")) ?? {}) : undefined;
|
|
158
|
+
const listed = await listScenarios(root, { filters: positionals.slice(1), name: values.name, tags: values.tag?.join(","), include: config?.include });
|
|
172
159
|
process.stdout.write(values.json ? `${JSON.stringify(listed, null, 2)}\n` : formatList(listed));
|
|
173
160
|
if (listed.errors.length)
|
|
174
161
|
process.exitCode = 1;
|
|
@@ -190,16 +177,15 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
190
177
|
process.env.SLICETEST_DIAGRAMS = path.resolve(values.diagrams);
|
|
191
178
|
if (values.tag?.length)
|
|
192
179
|
process.env.SLICETEST_TAGS = values.tag.join(",");
|
|
193
|
-
const
|
|
180
|
+
const vitestNode = await import("vitest/node");
|
|
181
|
+
const { version } = vitestNode;
|
|
194
182
|
if (Number.parseInt(version, 10) < 4) {
|
|
195
183
|
process.stderr.write(`slicetest: needs Vitest 4 or later, and this project has Vitest ${version}. Upgrade it (npm i -D vitest@latest), or run slicetest from a folder with its own package.json.\n`);
|
|
196
184
|
process.exitCode = 1;
|
|
197
185
|
return;
|
|
198
186
|
}
|
|
199
|
-
const {
|
|
200
|
-
|
|
201
|
-
const start = (Number.parseInt(version, 10) === 4 ? startVitest.bind(null, "test") : startVitest);
|
|
202
|
-
const { runOptions } = await import("./cli-run.js");
|
|
187
|
+
const { slicetestPlugin, YAML_SCENARIOS } = await import("./vitest.js");
|
|
188
|
+
const start = compatStart(vitestNode);
|
|
203
189
|
const run = runOptions(values);
|
|
204
190
|
const vitest = await start(positionals, {
|
|
205
191
|
config: false,
|
|
@@ -209,7 +195,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
209
195
|
run: !values.watch,
|
|
210
196
|
testNamePattern: values.name,
|
|
211
197
|
...run,
|
|
212
|
-
}, { plugins: [
|
|
198
|
+
}, { plugins: [slicetestPlugin(options, { include: include ?? [YAML_SCENARIOS] })] });
|
|
213
199
|
if (!values.watch)
|
|
214
200
|
await vitest?.close();
|
|
215
201
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -152,6 +152,29 @@ export interface AppOptions {
|
|
|
152
152
|
* turns off Vitest's per-file module isolation (`isolate: false`).
|
|
153
153
|
*/
|
|
154
154
|
scope?: "file" | "worker";
|
|
155
|
+
/**
|
|
156
|
+
* `"scenario"`: stop before the reset, start after. Not a cure for other processes, containers without `reset`
|
|
157
|
+
* or files. Default `"never"`: the process, and its caches and timers, outlive the scenario. Also on `services`.
|
|
158
|
+
*/
|
|
159
|
+
restart?: "never" | "scenario";
|
|
160
|
+
/**
|
|
161
|
+
* Endpoint that drops in-process state, called after the reset. Not for work already in flight: it can still
|
|
162
|
+
* write before the call, so use `idle` or `restart`. A status of 400+ fails the scenario.
|
|
163
|
+
*/
|
|
164
|
+
reset?: HookOptions;
|
|
165
|
+
/**
|
|
166
|
+
* 2xx once no background work is left. Not a fixed sleep: slicetest can't know when a job is done.
|
|
167
|
+
* Still busy after `timeout`: the scenario fails and the process is restarted.
|
|
168
|
+
*/
|
|
169
|
+
idle?: HookOptions;
|
|
170
|
+
}
|
|
171
|
+
/** Sent by slicetest itself; not the scenario's `http` client. */
|
|
172
|
+
export interface HookOptions {
|
|
173
|
+
path: string;
|
|
174
|
+
/** Default `POST` for `reset`, `GET` for `idle`. */
|
|
175
|
+
method?: string;
|
|
176
|
+
/** Milliseconds: how long `idle` is polled, or a `reset` request may take. Default 5000. */
|
|
177
|
+
timeout?: number;
|
|
155
178
|
}
|
|
156
179
|
export interface DbOptions {
|
|
157
180
|
/**
|
|
@@ -169,8 +192,8 @@ export interface DbOptions {
|
|
|
169
192
|
*/
|
|
170
193
|
url?: string;
|
|
171
194
|
migrate?: MigrateOptions;
|
|
172
|
-
/**
|
|
173
|
-
seed?: string;
|
|
195
|
+
/** Files (relative to the vitest root) applied after every reset: SQL, or YAML / JSON rows per table (`plans: [{ id: 1 }]`). */
|
|
196
|
+
seed?: string | string[];
|
|
174
197
|
/** Schemas whose tables are reset between scenarios. Default `["public"]`. */
|
|
175
198
|
schemas?: string[];
|
|
176
199
|
/** Extra tables kept across resets, in addition to known migration bookkeeping tables. */
|
|
@@ -263,4 +286,12 @@ export interface ResolvedOptions {
|
|
|
263
286
|
/** The config files `npx slicetest` (and `slicetest()` without options) look for, in order. */
|
|
264
287
|
export declare const CONFIG_NAMES: string[];
|
|
265
288
|
export declare function resolveOptions(opts: SlicetestOptions, root: string): ResolvedOptions;
|
|
289
|
+
export declare const TOP_LEVEL_KEYS: readonly ["app", "db", "stubs", "openapi", "http", "services", "containers", "mail", "offline", "strictStubs", "workers", "auth", "include"];
|
|
290
|
+
export declare const APP_KEYS: readonly ["command", "build", "cwd", "env", "ready", "readyTimeout", "scope", "restart", "reset", "idle", "baseEnv"];
|
|
291
|
+
export declare const STUB_KEYS: readonly ["name", "openapi", "autoReply", "upstream", "recordings", "hosts"];
|
|
292
|
+
export declare const CONTAINER_KEYS: readonly ["image", "port", "env", "command", "ready", "reset"];
|
|
293
|
+
export declare const DB_KEYS: readonly ["engine", "image", "url", "migrate", "seed", "schemas", "keep", "ignoreChanges", "reuse", "queries", "neon"];
|
|
294
|
+
export declare function editDistance(a: string, b: string): number;
|
|
295
|
+
/** `db.seed` as a list of files, in the order they run. */
|
|
296
|
+
export declare function seedFiles(seed: string | string[] | undefined): string[];
|
|
266
297
|
export {};
|
package/dist/config.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
export const CONFIG_NAMES = ["slicetest.config.yaml", "slicetest.config.yml", "slicetest.config.json"];
|
|
3
3
|
export function resolveOptions(opts, root) {
|
|
4
4
|
validate(opts);
|
|
5
|
+
opts = withStringEnv(opts);
|
|
5
6
|
return {
|
|
6
7
|
root,
|
|
7
8
|
app: { ...opts.app, ready: resolveReady(opts.app.ready ?? { path: "/" }) },
|
|
@@ -26,6 +27,18 @@ export function resolveOptions(opts, root) {
|
|
|
26
27
|
http: opts.http,
|
|
27
28
|
};
|
|
28
29
|
}
|
|
30
|
+
/** YAML reads `DEBUG: true` and `WORKERS: 2` as a boolean and a number; a process only takes strings. */
|
|
31
|
+
function withStringEnv(opts) {
|
|
32
|
+
const str = (p) => (p.env ? { ...p, env: Object.fromEntries(Object.entries(p.env).map(([k, v]) => [k, String(v)])) } : p);
|
|
33
|
+
const db = opts.db && opts.db.migrate && "command" in opts.db.migrate ? { ...opts.db, migrate: str(opts.db.migrate) } : opts.db;
|
|
34
|
+
return {
|
|
35
|
+
...opts,
|
|
36
|
+
app: str(opts.app),
|
|
37
|
+
...(opts.services ? { services: Object.fromEntries(Object.entries(opts.services).map(([n, s]) => [n, str(s)])) } : {}),
|
|
38
|
+
...(opts.containers ? { containers: Object.fromEntries(Object.entries(opts.containers).map(([n, c]) => [n, str(c)])) } : {}),
|
|
39
|
+
...(db !== undefined ? { db } : {}),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
29
42
|
function resolveRecordings(stubs) {
|
|
30
43
|
const withUpstream = stubs.filter((s) => typeof s === "object" && !!s.upstream);
|
|
31
44
|
const env = (process.env.SLICETEST_RECORD ?? "").trim();
|
|
@@ -59,12 +72,12 @@ function resolveDb(db) {
|
|
|
59
72
|
reuse: db.reuse ?? (!url && !process.env.CI),
|
|
60
73
|
};
|
|
61
74
|
}
|
|
62
|
-
const TOP_LEVEL_KEYS = ["app", "db", "stubs", "openapi", "http", "services", "containers", "mail", "offline", "strictStubs", "workers", "auth", "include"];
|
|
63
|
-
const APP_KEYS = ["command", "build", "cwd", "env", "ready", "readyTimeout", "scope", "baseEnv"];
|
|
64
|
-
const STUB_KEYS = ["name", "openapi", "autoReply", "upstream", "recordings", "hosts"];
|
|
65
|
-
const CONTAINER_KEYS = ["image", "port", "env", "command", "ready", "reset"];
|
|
66
|
-
const DB_KEYS = ["engine", "image", "url", "migrate", "seed", "schemas", "keep", "ignoreChanges", "reuse", "queries", "neon"];
|
|
67
|
-
function editDistance(a, b) {
|
|
75
|
+
export const TOP_LEVEL_KEYS = ["app", "db", "stubs", "openapi", "http", "services", "containers", "mail", "offline", "strictStubs", "workers", "auth", "include"];
|
|
76
|
+
export const APP_KEYS = ["command", "build", "cwd", "env", "ready", "readyTimeout", "scope", "restart", "reset", "idle", "baseEnv"];
|
|
77
|
+
export const STUB_KEYS = ["name", "openapi", "autoReply", "upstream", "recordings", "hosts"];
|
|
78
|
+
export const CONTAINER_KEYS = ["image", "port", "env", "command", "ready", "reset"];
|
|
79
|
+
export const DB_KEYS = ["engine", "image", "url", "migrate", "seed", "schemas", "keep", "ignoreChanges", "reuse", "queries", "neon"];
|
|
80
|
+
export function editDistance(a, b) {
|
|
68
81
|
const row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
69
82
|
for (let i = 1; i <= a.length; i++) {
|
|
70
83
|
let prev = row[0];
|
|
@@ -117,7 +130,39 @@ function validate(opts) {
|
|
|
117
130
|
if (ready && "path" in ready && !ready.path.startsWith("/"))
|
|
118
131
|
fail(`${where}.ready.path must start with "/", got "${ready.path}"`);
|
|
119
132
|
};
|
|
133
|
+
const checkEnv = (env, where) => {
|
|
134
|
+
if (env === undefined)
|
|
135
|
+
return;
|
|
136
|
+
if (!env || typeof env !== "object" || Array.isArray(env))
|
|
137
|
+
fail(`${where} maps variable names to values, e.g. { NODE_ENV: test }, got ${JSON.stringify(env)}`);
|
|
138
|
+
for (const [k, v] of Object.entries(env)) {
|
|
139
|
+
if (!["string", "number", "boolean"].includes(typeof v))
|
|
140
|
+
fail(`${where}.${k} must be a string, a number or true/false, got ${JSON.stringify(v)}`);
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
checkEnv(opts.app.env, "app.env");
|
|
144
|
+
for (const [name, s] of Object.entries(opts.services ?? {}))
|
|
145
|
+
checkEnv(s?.env, `services.${name}.env`);
|
|
146
|
+
for (const [name, c] of Object.entries(opts.containers ?? {}))
|
|
147
|
+
checkEnv(c?.env, `containers.${name}.env`);
|
|
120
148
|
checkReady(opts.app.ready, "app");
|
|
149
|
+
const checkTimeout = (v, where) => {
|
|
150
|
+
if (v !== undefined && !(typeof v === "number" && v > 0))
|
|
151
|
+
fail(`${where} must be a positive number of milliseconds, got ${JSON.stringify(v)}`);
|
|
152
|
+
};
|
|
153
|
+
checkTimeout(opts.app.readyTimeout, "app.readyTimeout");
|
|
154
|
+
if (opts.http !== undefined) {
|
|
155
|
+
if (!opts.http || typeof opts.http !== "object" || Array.isArray(opts.http))
|
|
156
|
+
fail(`http must be { headers, timeout, follow }, got ${JSON.stringify(opts.http)}`);
|
|
157
|
+
checkKeys(opts.http, ["headers", "query", "follow", "timeout"], "http.");
|
|
158
|
+
checkTimeout(opts.http.timeout, "http.timeout");
|
|
159
|
+
if (opts.http.follow !== undefined && typeof opts.http.follow !== "boolean")
|
|
160
|
+
fail(`http.follow must be true or false, got ${JSON.stringify(opts.http.follow)}`);
|
|
161
|
+
const headers = opts.http.headers;
|
|
162
|
+
if (headers !== undefined && (!headers || typeof headers !== "object" || Array.isArray(headers) || Object.values(headers).some((v) => typeof v !== "string"))) {
|
|
163
|
+
fail('http.headers maps header names to strings, e.g. { accept: "application/json" }');
|
|
164
|
+
}
|
|
165
|
+
}
|
|
121
166
|
if (opts.app.scope !== undefined && opts.app.scope !== "file" && opts.app.scope !== "worker")
|
|
122
167
|
fail(`app.scope must be "file" or "worker", got ${JSON.stringify(opts.app.scope)}`);
|
|
123
168
|
const checkBuild = (build, where) => {
|
|
@@ -125,13 +170,35 @@ function validate(opts) {
|
|
|
125
170
|
fail(`${where}.build must be a command, e.g. "npm run build"`);
|
|
126
171
|
};
|
|
127
172
|
checkBuild(opts.app.build, "app");
|
|
173
|
+
const checkLifecycle = (p, where) => {
|
|
174
|
+
if (p.restart !== undefined && p.restart !== "never" && p.restart !== "scenario")
|
|
175
|
+
fail(`${where}.restart must be "never" or "scenario", got ${JSON.stringify(p.restart)}`);
|
|
176
|
+
for (const key of ["reset", "idle"]) {
|
|
177
|
+
const hook = p[key];
|
|
178
|
+
if (hook === undefined)
|
|
179
|
+
continue;
|
|
180
|
+
if (!hook || typeof hook !== "object" || Array.isArray(hook))
|
|
181
|
+
fail(`${where}.${key} must be { path }, e.g. { path: "/__test/${key}" }`);
|
|
182
|
+
checkKeys(hook, ["path", "method", "timeout"], `${where}.${key}.`);
|
|
183
|
+
// Not just startsWith("/"): "//host/x" and "/\\host" resolve to another host.
|
|
184
|
+
if (typeof hook.path !== "string" || !/^\/(?![/\\])[^\s\\]*$/.test(hook.path))
|
|
185
|
+
fail(`${where}.${key}.path must be a path on the app starting with "/" (no "//", backslash or spaces), got ${JSON.stringify(hook.path)}`);
|
|
186
|
+
if (hook.method !== undefined && !(typeof hook.method === "string" && ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"].includes(hook.method.toUpperCase())))
|
|
187
|
+
fail(`${where}.${key}.method must be GET, POST, PUT, PATCH, DELETE or HEAD, got ${JSON.stringify(hook.method)}`);
|
|
188
|
+
if (hook.timeout !== undefined && !(Number.isInteger(hook.timeout) && hook.timeout >= 1 && hook.timeout <= 600_000))
|
|
189
|
+
fail(`${where}.${key}.timeout must be between 1 and 600000 milliseconds, got ${JSON.stringify(hook.timeout)}`);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
checkLifecycle(opts.app, "app");
|
|
128
193
|
for (const [name, s] of Object.entries(opts.services ?? {})) {
|
|
129
194
|
if (!/^[\w-]+$/.test(name))
|
|
130
195
|
fail(`service name "${name}" may only contain letters, digits, "_" and "-"`);
|
|
131
196
|
if (!s || typeof s.command !== "string" || !s.command.trim())
|
|
132
197
|
fail(`services.${name}.command is required`);
|
|
133
198
|
checkReady(s.ready, `services.${name}`);
|
|
199
|
+
checkTimeout(s.readyTimeout, `services.${name}.readyTimeout`);
|
|
134
200
|
checkBuild(s.build, `services.${name}`);
|
|
201
|
+
checkLifecycle(s, `services.${name}`);
|
|
135
202
|
}
|
|
136
203
|
for (const [name, c] of Object.entries(opts.containers ?? {})) {
|
|
137
204
|
if (!/^[\w-]+$/.test(name))
|
|
@@ -182,6 +249,10 @@ function validate(opts) {
|
|
|
182
249
|
fail("db.queries needs a database server (postgres or mysql): an SQLite app opens the file directly, so there is no connection to read");
|
|
183
250
|
if (engine === "sqlite" && dbOpts?.url)
|
|
184
251
|
fail("db.url doesn't apply to sqlite: slicetest creates the database files itself and passes them to the app as {{db.url}} / {{db.path}}");
|
|
252
|
+
const seed = dbOpts?.seed;
|
|
253
|
+
if (seed !== undefined && !(typeof seed === "string" ? seed : Array.isArray(seed) && seed.length > 0 && seed.every((f) => typeof f === "string" && f))) {
|
|
254
|
+
fail(`db.seed must be a file (SQL, YAML or JSON) or a list of them, e.g. seed.sql, got ${JSON.stringify(seed)}`);
|
|
255
|
+
}
|
|
185
256
|
const migrate = dbOpts?.migrate;
|
|
186
257
|
if (migrate) {
|
|
187
258
|
const keys = Object.keys(migrate).filter((k) => ["atlas", "sql", "command"].includes(k));
|
|
@@ -191,8 +262,7 @@ function validate(opts) {
|
|
|
191
262
|
if (menv !== undefined) {
|
|
192
263
|
if (!("command" in migrate))
|
|
193
264
|
fail("db.migrate.env is for a migration `command`; atlas and sql get the database URL themselves");
|
|
194
|
-
|
|
195
|
-
fail('db.migrate.env maps variable names to strings, e.g. { DB_HOST: "{{db.host}}" }');
|
|
265
|
+
checkEnv(menv, "db.migrate.env");
|
|
196
266
|
}
|
|
197
267
|
}
|
|
198
268
|
const oas = opts.openapi;
|
|
@@ -258,3 +328,7 @@ function stubName(s) {
|
|
|
258
328
|
function escapeRegExp(s) {
|
|
259
329
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
260
330
|
}
|
|
331
|
+
/** `db.seed` as a list of files, in the order they run. */
|
|
332
|
+
export function seedFiles(seed) {
|
|
333
|
+
return seed === undefined ? [] : Array.isArray(seed) ? seed : [seed];
|
|
334
|
+
}
|
|
@@ -6,3 +6,7 @@
|
|
|
6
6
|
export declare function configureContainerRuntime(platform?: NodeJS.Platform): void;
|
|
7
7
|
/** `\\.\pipe\podman-machine-default` → `npipe:////./pipe/podman-machine-default`. */
|
|
8
8
|
export declare function dockerHost(location: string, windows: boolean): string;
|
|
9
|
+
/** Testcontainers declares Node.js 22.22+; below that its HTTP library fails when it loads. */
|
|
10
|
+
export declare function nodeTooOldForContainers(version?: string): boolean;
|
|
11
|
+
/** Loads a Testcontainers module; on an old Node.js, says that is the reason instead of showing the library's TypeError. */
|
|
12
|
+
export declare function loadContainers<T>(load: () => Promise<T>, version?: string): Promise<T>;
|
|
@@ -29,3 +29,19 @@ export function configureContainerRuntime(platform = process.platform) {
|
|
|
29
29
|
export function dockerHost(location, windows) {
|
|
30
30
|
return windows ? `npipe://${location.replace(/\\/g, "/")}` : `unix://${location}`;
|
|
31
31
|
}
|
|
32
|
+
/** Testcontainers declares Node.js 22.22+; below that its HTTP library fails when it loads. */
|
|
33
|
+
export function nodeTooOldForContainers(version = process.versions.node) {
|
|
34
|
+
const [major = 0, minor = 0] = version.split(".").map(Number);
|
|
35
|
+
return major < 22 || (major === 22 && minor < 22);
|
|
36
|
+
}
|
|
37
|
+
/** Loads a Testcontainers module; on an old Node.js, says that is the reason instead of showing the library's TypeError. */
|
|
38
|
+
export async function loadContainers(load, version = process.versions.node) {
|
|
39
|
+
try {
|
|
40
|
+
return await load();
|
|
41
|
+
}
|
|
42
|
+
catch (e) {
|
|
43
|
+
if (!nodeTooOldForContainers(version))
|
|
44
|
+
throw e;
|
|
45
|
+
throw new Error(`slicetest: Node.js ${version} can't load the container library (${e.message}). Starting a database or a dependency in a container needs Node.js 22.22 or later: upgrade Node.js, or use a database server you run yourself (SLICETEST_DATABASE_URL or db.url; no containers are then started for it).`, { cause: e });
|
|
46
|
+
}
|
|
47
|
+
}
|
package/dist/containers.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
-
import { configureContainerRuntime } from "./container-runtime.js";
|
|
2
|
+
import { configureContainerRuntime, loadContainers } from "./container-runtime.js";
|
|
3
|
+
import { onProcessExit } from "./exit.js";
|
|
3
4
|
/**
|
|
4
5
|
* A container the app depends on (Redis, a search engine, an S3 emulator),
|
|
5
6
|
* started for one test file and emptied between scenarios with `reset`.
|
|
@@ -15,7 +16,7 @@ export class Dependency {
|
|
|
15
16
|
}
|
|
16
17
|
static async start(name, opts) {
|
|
17
18
|
configureContainerRuntime();
|
|
18
|
-
const { GenericContainer, Wait } = await import("testcontainers");
|
|
19
|
+
const { GenericContainer, Wait } = await loadContainers(() => import("testcontainers"));
|
|
19
20
|
let definition = new GenericContainer(opts.image).withExposedPorts(opts.port);
|
|
20
21
|
if (opts.env)
|
|
21
22
|
definition = definition.withEnvironment(opts.env);
|
|
@@ -72,7 +73,7 @@ export class Dependency {
|
|
|
72
73
|
}
|
|
73
74
|
}
|
|
74
75
|
const running = new Set();
|
|
75
|
-
|
|
76
|
+
onProcessExit(() => {
|
|
76
77
|
for (const d of running)
|
|
77
78
|
d.removeNow();
|
|
78
79
|
});
|
package/dist/db.d.ts
CHANGED
|
@@ -42,7 +42,7 @@ export declare class Db {
|
|
|
42
42
|
schemas: string[];
|
|
43
43
|
keep: string[];
|
|
44
44
|
ignoreChanges?: string[];
|
|
45
|
-
|
|
45
|
+
seedFiles?: string[];
|
|
46
46
|
}): Promise<Db>;
|
|
47
47
|
query<T extends Row = Row>(sql: string, params?: unknown[]): Promise<T[]>;
|
|
48
48
|
/** Rows of a table, optionally filtered, ordered by the first column unless `orderBy` is given. */
|
|
@@ -98,6 +98,8 @@ export declare class Db {
|
|
|
98
98
|
changesSinceStart(): Promise<Changes>;
|
|
99
99
|
close(): Promise<void>;
|
|
100
100
|
}
|
|
101
|
+
/** A data seed file: `table: [rows]`, in file order; each row a mapping of columns. */
|
|
102
|
+
export declare function seedRows(file: string, text: string): [string, Row[]][];
|
|
101
103
|
/**
|
|
102
104
|
* What `ignore` leaves out of one table: `"*"` for the whole table, else the columns.
|
|
103
105
|
* `updated_at` is that column in every table, `orders.synced_at` in one, `sessions.*` the whole table
|
package/dist/db.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parse as parseYaml } from "yaml";
|
|
2
4
|
import { Factory } from "./factory.js";
|
|
3
5
|
/** Tables that record applied migrations. Truncating them would make tools re-run migrations. */
|
|
4
6
|
const MIGRATION_TABLES = [
|
|
@@ -26,7 +28,8 @@ export class Db {
|
|
|
26
28
|
opts;
|
|
27
29
|
#driver;
|
|
28
30
|
#tables;
|
|
29
|
-
|
|
31
|
+
/** SQL scripts, or rows per table from a YAML / JSON file. */
|
|
32
|
+
#seeds = [];
|
|
30
33
|
/** Contents right after the reset (and seed); undefined means every table was empty. */
|
|
31
34
|
#start;
|
|
32
35
|
#checkpoint;
|
|
@@ -36,12 +39,14 @@ export class Db {
|
|
|
36
39
|
this.url = url;
|
|
37
40
|
this.opts = opts;
|
|
38
41
|
this.#driver = driver;
|
|
39
|
-
this.#factory = new Factory((table) => driver.describe(table), (table, row) =>
|
|
42
|
+
this.#factory = new Factory((table) => driver.describe(table), (table, row) => this.#insertRow(table, row));
|
|
40
43
|
}
|
|
41
44
|
static async connect(driver, url, opts) {
|
|
42
45
|
const db = new Db(driver, url, opts);
|
|
43
|
-
|
|
44
|
-
|
|
46
|
+
db.#seeds = await Promise.all((opts.seedFiles ?? []).map(async (file) => {
|
|
47
|
+
const text = await readFile(file, "utf8");
|
|
48
|
+
return /\.(ya?ml|json)$/i.test(file) ? seedRows(file, text) : text;
|
|
49
|
+
}));
|
|
45
50
|
return db;
|
|
46
51
|
}
|
|
47
52
|
async query(sql, params = []) {
|
|
@@ -81,9 +86,15 @@ export class Db {
|
|
|
81
86
|
const list = Array.isArray(rows) ? rows : [rows];
|
|
82
87
|
const out = [];
|
|
83
88
|
for (const row of list)
|
|
84
|
-
out.push(...(await this.#
|
|
89
|
+
out.push(...(await this.#insertRow(table, row)));
|
|
85
90
|
return out;
|
|
86
91
|
}
|
|
92
|
+
/** An explicit id would otherwise be handed out again by the sequence: the app's next insert would collide. */
|
|
93
|
+
async #insertRow(table, row) {
|
|
94
|
+
const stored = await this.#driver.insert(table, row);
|
|
95
|
+
await this.#driver.syncSequences?.(table, Object.keys(row));
|
|
96
|
+
return stored;
|
|
97
|
+
}
|
|
87
98
|
/**
|
|
88
99
|
* Insert a row that satisfies the schema, giving only the columns the test cares about.
|
|
89
100
|
* Required columns get a value of their type (enums and `CHECK (... IN (...))` their first
|
|
@@ -131,13 +142,21 @@ export class Db {
|
|
|
131
142
|
}
|
|
132
143
|
/** Empty every data table without dropping the app's connections, then re-apply the seed. */
|
|
133
144
|
async reset() {
|
|
134
|
-
|
|
145
|
+
// Listed again each time: a table the app or a scenario created since the last reset must be emptied too.
|
|
146
|
+
this.#tables = await this.#listTables();
|
|
135
147
|
await this.#driver.truncate(this.#tables);
|
|
136
148
|
this.#factory.reset();
|
|
137
149
|
this.#start = undefined;
|
|
138
150
|
this.#checkpoint = undefined;
|
|
139
|
-
if (this.#
|
|
140
|
-
|
|
151
|
+
if (this.#seeds.length) {
|
|
152
|
+
for (const seed of this.#seeds) {
|
|
153
|
+
if (typeof seed === "string")
|
|
154
|
+
await this.#driver.exec(seed);
|
|
155
|
+
else
|
|
156
|
+
for (const [table, rows] of seed)
|
|
157
|
+
for (const row of rows)
|
|
158
|
+
await this.#insertRow(table, row);
|
|
159
|
+
}
|
|
141
160
|
this.#start = await this.#snapshot();
|
|
142
161
|
}
|
|
143
162
|
}
|
|
@@ -201,6 +220,24 @@ export class Db {
|
|
|
201
220
|
await this.#driver.close();
|
|
202
221
|
}
|
|
203
222
|
}
|
|
223
|
+
/** A data seed file: `table: [rows]`, in file order; each row a mapping of columns. */
|
|
224
|
+
export function seedRows(file, text) {
|
|
225
|
+
let doc;
|
|
226
|
+
try {
|
|
227
|
+
doc = path.extname(file).toLowerCase() === ".json" ? JSON.parse(text) : parseYaml(text);
|
|
228
|
+
}
|
|
229
|
+
catch (e) {
|
|
230
|
+
throw new Error(`slicetest: seed ${file}: ${e.message}`);
|
|
231
|
+
}
|
|
232
|
+
if (!doc || typeof doc !== "object" || Array.isArray(doc))
|
|
233
|
+
throw new Error(`slicetest: seed ${file} must map tables to rows, e.g. plans: [{ id: 1, name: free }]`);
|
|
234
|
+
return Object.entries(doc).map(([table, rows]) => {
|
|
235
|
+
if (!Array.isArray(rows) || !rows.every((r) => r && typeof r === "object" && !Array.isArray(r))) {
|
|
236
|
+
throw new Error(`slicetest: seed ${file}: "${table}" must be a list of rows, e.g. ${table}: [{ id: 1, name: free }]`);
|
|
237
|
+
}
|
|
238
|
+
return [table, rows];
|
|
239
|
+
});
|
|
240
|
+
}
|
|
204
241
|
/**
|
|
205
242
|
* What `ignore` leaves out of one table: `"*"` for the whole table, else the columns.
|
|
206
243
|
* `updated_at` is that column in every table, `orders.synced_at` in one, `sessions.*` the whole table
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare function setScenarioDeadline(started: number, timeout: number | undefined): void;
|
|
2
|
+
export declare function clearScenarioDeadline(): void;
|
|
3
|
+
/** `ms`, or less when the scenario's timeout ends first; `note` says so for the error message. */
|
|
4
|
+
export declare function waitBudget(ms: number): {
|
|
5
|
+
ms: number;
|
|
6
|
+
note: string;
|
|
7
|
+
};
|
package/dist/deadline.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** When the running scenario's own timeout ends (epoch ms); shared across copies of the package. */
|
|
2
|
+
const KEY = Symbol.for("slicetest.deadline");
|
|
3
|
+
const slot = globalThis;
|
|
4
|
+
/** Kept short of the timeout, so the wait's own message (what it waited for) is what the user sees. */
|
|
5
|
+
const MARGIN = 300;
|
|
6
|
+
export function setScenarioDeadline(started, timeout) {
|
|
7
|
+
if (timeout)
|
|
8
|
+
slot[KEY] = { at: started + timeout, timeout };
|
|
9
|
+
else
|
|
10
|
+
delete slot[KEY];
|
|
11
|
+
}
|
|
12
|
+
export function clearScenarioDeadline() {
|
|
13
|
+
delete slot[KEY];
|
|
14
|
+
}
|
|
15
|
+
/** `ms`, or less when the scenario's timeout ends first; `note` says so for the error message. */
|
|
16
|
+
export function waitBudget(ms) {
|
|
17
|
+
const d = slot[KEY];
|
|
18
|
+
if (!d)
|
|
19
|
+
return { ms, note: "" };
|
|
20
|
+
const left = d.at - MARGIN - Date.now();
|
|
21
|
+
if (left >= ms)
|
|
22
|
+
return { ms, note: "" };
|
|
23
|
+
return { ms: Math.max(0, left), note: ` (the scenario's timeout of ${d.timeout}ms ends then; raise it with \`timeout:\`)` };
|
|
24
|
+
}
|
package/dist/doctor.js
CHANGED
|
@@ -5,15 +5,16 @@ import { createRequire } from "node:module";
|
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { promisify } from "node:util";
|
|
7
7
|
import { parse } from "yaml";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
8
|
+
import { seedRows } from "./db.js";
|
|
9
|
+
import { resolveOptions, seedFiles } from "./config.js";
|
|
10
|
+
import { configureContainerRuntime, loadContainers } from "./container-runtime.js";
|
|
10
11
|
import { engineFor } from "./drivers/index.js";
|
|
11
12
|
import { OpenApiSpec } from "./openapi.js";
|
|
12
13
|
const exec = promisify(execFile);
|
|
13
14
|
export const machine = {
|
|
14
15
|
async containerRuntime() {
|
|
15
16
|
configureContainerRuntime();
|
|
16
|
-
const { getContainerRuntimeClient } = await import("testcontainers");
|
|
17
|
+
const { getContainerRuntimeClient } = await loadContainers(() => import("testcontainers"));
|
|
17
18
|
const client = await withTimeout(getContainerRuntimeClient(), 15_000, "no answer from the container runtime");
|
|
18
19
|
const { containerRuntime: rt } = client.info;
|
|
19
20
|
return `${rt.operatingSystem} ${rt.serverVersion} at ${rt.host}`;
|
|
@@ -121,8 +122,29 @@ export async function doctor(configPath, probes = machine, env = process.env) {
|
|
|
121
122
|
else
|
|
122
123
|
for (const input of m.inputs ?? [])
|
|
123
124
|
await checkPath(add, "migration input", input, root, rel);
|
|
124
|
-
|
|
125
|
-
|
|
125
|
+
for (const file of seedFiles(opts.db.seed)) {
|
|
126
|
+
if (!/\.(ya?ml|json)$/i.test(file)) {
|
|
127
|
+
await checkPath(add, "seed", file, root, rel);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
// A data seed is read here, so a broken one is reported before any container starts.
|
|
131
|
+
let text;
|
|
132
|
+
try {
|
|
133
|
+
text = await readFile(path.resolve(root, file), "utf8");
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
await checkPath(add, "seed", file, root, rel);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
const tables = seedRows(file, text);
|
|
141
|
+
add("ok", `seed ${rel(file)} (${tables.map(([t, rows]) => `${t} ×${rows.length}`).join(", ") || "no tables"})`);
|
|
142
|
+
}
|
|
143
|
+
catch (e) {
|
|
144
|
+
const why = e.message.replace(/^slicetest: seed [^:]+: /, "");
|
|
145
|
+
add("fail", /must be a list|must map/.test(why) ? `seed ${rel(file)} isn't rows per table` : `seed ${rel(file)} isn't valid ${path.extname(file).slice(1).toUpperCase()}`, why);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
126
148
|
for (const [label, p] of [["app", opts.app], ...Object.entries(opts.services).map(([n, s]) => [`service ${n}`, s])]) {
|
|
127
149
|
if (p.cwd)
|
|
128
150
|
await checkPath(add, `${label} cwd`, p.cwd, root, rel);
|
package/dist/drivers/driver.d.ts
CHANGED
|
@@ -58,6 +58,12 @@ export interface Driver {
|
|
|
58
58
|
truncate(tables: Table[]): Promise<void>;
|
|
59
59
|
/** Insert one row and return it as stored (defaults and generated ids filled in). */
|
|
60
60
|
insert(table: string, row: Row): Promise<Row[]>;
|
|
61
|
+
/**
|
|
62
|
+
* Move `table`'s id sequences past the ids it holds, after rows were inserted with explicit ids
|
|
63
|
+
* (only those of `columns`, when given). Only Postgres needs it: MySQL's AUTO_INCREMENT and
|
|
64
|
+
* SQLite's rowid follow the largest id themselves.
|
|
65
|
+
*/
|
|
66
|
+
syncSequences?(table: string, columns?: string[]): Promise<void>;
|
|
61
67
|
/** Columns, foreign keys and checks of `table`. Throws when there is no such table. */
|
|
62
68
|
describe(table: string): Promise<TableShape>;
|
|
63
69
|
close(): Promise<void>;
|
package/dist/drivers/mysql.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import mysql from "mysql2/promise";
|
|
2
|
+
import { loadContainers } from "../container-runtime.js";
|
|
2
3
|
/**
|
|
3
4
|
* Makes rows look like the Postgres driver's, so the same scenarios pass on
|
|
4
5
|
* both: BOOLEAN (`TINYINT(1)`) columns come back as booleans, BIGINT ids as
|
|
@@ -290,7 +291,10 @@ export const mysqlEngine = {
|
|
|
290
291
|
name: "mysql",
|
|
291
292
|
defaultImage: "mysql:8.4",
|
|
292
293
|
async startContainer(image, reuse) {
|
|
293
|
-
const { MySqlContainer } = await import("@testcontainers/mysql").catch(() => {
|
|
294
|
+
const { MySqlContainer } = await loadContainers(() => import("@testcontainers/mysql")).catch((e) => {
|
|
295
|
+
// An old Node.js already has its own explanation; a missing package gets the install hint.
|
|
296
|
+
if (/^slicetest: Node\.js/.test(e.message))
|
|
297
|
+
throw e;
|
|
294
298
|
throw new Error("slicetest: starting a MySQL container needs the @testcontainers/mysql package: npm i -D @testcontainers/mysql (or set db.url)");
|
|
295
299
|
});
|
|
296
300
|
const definition = new MySqlContainer(image);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Driver, Engine, Row, Table, TableShape } from "./driver.js";
|
|
2
2
|
export declare class PostgresDriver implements Driver {
|
|
3
|
+
#private;
|
|
3
4
|
private readonly client;
|
|
4
5
|
private constructor();
|
|
5
6
|
static connect(url: string): Promise<PostgresDriver>;
|
|
@@ -14,6 +15,7 @@ export declare class PostgresDriver implements Driver {
|
|
|
14
15
|
listTables(schemas: string[], keep: string[]): Promise<Table[]>;
|
|
15
16
|
truncate(tables: Table[]): Promise<void>;
|
|
16
17
|
insert(table: string, row: Row): Promise<Row[]>;
|
|
18
|
+
syncSequences(table: string, columns?: string[]): Promise<void>;
|
|
17
19
|
describe(table: string): Promise<TableShape>;
|
|
18
20
|
close(): Promise<void>;
|
|
19
21
|
}
|