slicetest 0.8.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 +131 -35
- package/dist/app.js +10 -2
- package/dist/auth.js +2 -1
- package/dist/cli-run.d.ts +53 -0
- package/dist/cli-run.js +80 -0
- package/dist/cli.js +27 -29
- package/dist/config.d.ts +33 -2
- package/dist/config.js +121 -2
- 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 +2 -0
- package/dist/form.js +62 -9
- package/dist/global-setup.js +3 -6
- package/dist/har.js +4 -4
- package/dist/http.d.ts +38 -4
- package/dist/http.js +135 -10
- package/dist/init.js +64 -10
- 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 +18 -6
- package/dist/matchers.js +75 -8
- package/dist/openapi.d.ts +2 -5
- package/dist/openapi.js +146 -20
- package/dist/record-cli.js +2 -1
- package/dist/record.js +2 -1
- package/dist/recording.d.ts +11 -1
- package/dist/recording.js +51 -6
- 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 +144 -18
- package/dist/scenario.js +11 -2
- package/dist/sql-migrations.d.ts +11 -0
- package/dist/sql-migrations.js +38 -0
- package/dist/stub.d.ts +10 -3
- package/dist/stub.js +64 -16
- 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 +141 -38
- package/dist/yaml.d.ts +12 -2
- package/dist/yaml.js +47 -4
- 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 +97 -7
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]
|
|
@@ -46,15 +46,25 @@ Options:
|
|
|
46
46
|
--upstream <url> import: the real service's base URL for --stub
|
|
47
47
|
--uncovered gen: only responses the last run didn't cover
|
|
48
48
|
--force init, gen: overwrite existing files
|
|
49
|
+
-u, --update Rewrite snapshots (\`snapshot: true\` steps) that no longer match
|
|
50
|
+
--reporter <name> Vitest reporter: default, verbose, dot, junit, json, tap,
|
|
51
|
+
github-actions (repeat for several)
|
|
52
|
+
--output-file <file> Where junit / json / tap reporters write
|
|
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
|
|
49
58
|
--diagrams <dir> Write a Mermaid sequence diagram of every scenario to <dir>,
|
|
50
59
|
one Markdown page per scenario file
|
|
60
|
+
-v, --version Show the version
|
|
51
61
|
-h, --help Show this help
|
|
52
62
|
|
|
53
63
|
Config (paths are relative to the config file):
|
|
54
|
-
app: { command, env, cwd, ready: { path } | { log }, readyTimeout }
|
|
64
|
+
app: { command, env, cwd, ready: { path } | { log }, readyTimeout, restart, reset: { path }, idle: { path } }
|
|
55
65
|
db: { migrate: { atlas: { dir } } | { sql } | { command, inputs }, engine, seed, url, image, schemas, keep, ignoreChanges, reuse, queries }
|
|
56
66
|
stubs: [name | { name, openapi, autoReply, upstream, recordings }]
|
|
57
|
-
services: { name: { command, env, cwd, ready } }
|
|
67
|
+
services: { name: { command, env, cwd, ready, restart, reset: { path }, idle: { path } } }
|
|
58
68
|
containers: { name: { image, port, env, command, ready: { log }, reset } }
|
|
59
69
|
mail: true SMTP server at {{mail.host}} / {{mail.port}}
|
|
60
70
|
auth: true | { audience, claims } OpenID issuer at {{auth.issuer}} / {{auth.jwks}}
|
|
@@ -65,26 +75,11 @@ Config (paths are relative to the config file):
|
|
|
65
75
|
include: [globs] default ["**/*.scenario.{yaml,yml}"]
|
|
66
76
|
`;
|
|
67
77
|
export async function main(argv = process.argv.slice(2)) {
|
|
68
|
-
const { values, positionals } =
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
watch: { type: "boolean", short: "w" },
|
|
74
|
-
name: { type: "string", short: "t" },
|
|
75
|
-
tag: { type: "string", multiple: true },
|
|
76
|
-
help: { type: "boolean", short: "h" },
|
|
77
|
-
force: { type: "boolean" },
|
|
78
|
-
spec: { type: "string" },
|
|
79
|
-
out: { type: "string" },
|
|
80
|
-
uncovered: { type: "boolean" },
|
|
81
|
-
port: { type: "string" },
|
|
82
|
-
diagrams: { type: "string" },
|
|
83
|
-
stub: { type: "string" },
|
|
84
|
-
upstream: { type: "string" },
|
|
85
|
-
json: { type: "boolean" },
|
|
86
|
-
},
|
|
87
|
-
});
|
|
78
|
+
const { values, positionals } = parseCliArgs(argv);
|
|
79
|
+
if (values.version) {
|
|
80
|
+
process.stdout.write(`${await packageVersion()}\n`);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
88
83
|
if (values.help) {
|
|
89
84
|
process.stdout.write(HELP);
|
|
90
85
|
return;
|
|
@@ -159,7 +154,8 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
159
154
|
if (positionals[0] === "list") {
|
|
160
155
|
const root = configPath && existsSync(configPath) ? path.dirname(configPath) : process.cwd();
|
|
161
156
|
const { listScenarios, formatList } = await import("./list.js");
|
|
162
|
-
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 });
|
|
163
159
|
process.stdout.write(values.json ? `${JSON.stringify(listed, null, 2)}\n` : formatList(listed));
|
|
164
160
|
if (listed.errors.length)
|
|
165
161
|
process.exitCode = 1;
|
|
@@ -181,15 +177,16 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
181
177
|
process.env.SLICETEST_DIAGRAMS = path.resolve(values.diagrams);
|
|
182
178
|
if (values.tag?.length)
|
|
183
179
|
process.env.SLICETEST_TAGS = values.tag.join(",");
|
|
184
|
-
const
|
|
180
|
+
const vitestNode = await import("vitest/node");
|
|
181
|
+
const { version } = vitestNode;
|
|
185
182
|
if (Number.parseInt(version, 10) < 4) {
|
|
186
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`);
|
|
187
184
|
process.exitCode = 1;
|
|
188
185
|
return;
|
|
189
186
|
}
|
|
190
|
-
const {
|
|
191
|
-
|
|
192
|
-
const
|
|
187
|
+
const { slicetestPlugin, YAML_SCENARIOS } = await import("./vitest.js");
|
|
188
|
+
const start = compatStart(vitestNode);
|
|
189
|
+
const run = runOptions(values);
|
|
193
190
|
const vitest = await start(positionals, {
|
|
194
191
|
config: false,
|
|
195
192
|
root: path.dirname(configPath),
|
|
@@ -197,7 +194,8 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
197
194
|
watch: !!values.watch,
|
|
198
195
|
run: !values.watch,
|
|
199
196
|
testNamePattern: values.name,
|
|
200
|
-
|
|
197
|
+
...run,
|
|
198
|
+
}, { plugins: [slicetestPlugin(options, { include: include ?? [YAML_SCENARIOS] })] });
|
|
201
199
|
if (!values.watch)
|
|
202
200
|
await vitest?.close();
|
|
203
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,10 +72,55 @@ function resolveDb(db) {
|
|
|
59
72
|
reuse: db.reuse ?? (!url && !process.env.CI),
|
|
60
73
|
};
|
|
61
74
|
}
|
|
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) {
|
|
81
|
+
const row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
82
|
+
for (let i = 1; i <= a.length; i++) {
|
|
83
|
+
let prev = row[0];
|
|
84
|
+
row[0] = i;
|
|
85
|
+
for (let j = 1; j <= b.length; j++) {
|
|
86
|
+
const tmp = row[j];
|
|
87
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
88
|
+
prev = tmp;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return row[b.length];
|
|
92
|
+
}
|
|
62
93
|
function validate(opts) {
|
|
63
94
|
const fail = (msg) => {
|
|
64
95
|
throw new Error(`slicetest: invalid config: ${msg}`);
|
|
65
96
|
};
|
|
97
|
+
// A misspelt key (`stub:`, `strictstubs:`) would otherwise be ignored without a word; YAML configs have no type checker.
|
|
98
|
+
const checkKeys = (value, allowed, where) => {
|
|
99
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
100
|
+
return;
|
|
101
|
+
for (const key of Object.keys(value)) {
|
|
102
|
+
if (allowed.includes(key) || key === "$schema")
|
|
103
|
+
continue;
|
|
104
|
+
// Close in spelling, or sharing the first four letters (`servers` / `services`, `migrations` / `migrate`).
|
|
105
|
+
const near = allowed
|
|
106
|
+
.map((k) => ({ k, d: editDistance(k.toLowerCase(), key.toLowerCase()) }))
|
|
107
|
+
.filter(({ k, d }) => d <= Math.max(2, Math.floor(key.length / 3)) || (key.length >= 4 && k.toLowerCase().startsWith(key.slice(0, 4).toLowerCase())))
|
|
108
|
+
.sort((a, b) => a.d - b.d)[0];
|
|
109
|
+
const hint = near ? `; did you mean "${near.k}"?` : ` (expected ${allowed.join(", ")})`;
|
|
110
|
+
fail(`unknown key ${where}${key}${hint}`);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
checkKeys(opts, TOP_LEVEL_KEYS, "");
|
|
114
|
+
checkKeys(opts?.app, APP_KEYS, "app.");
|
|
115
|
+
if (opts?.db)
|
|
116
|
+
checkKeys(opts.db, DB_KEYS, "db.");
|
|
117
|
+
for (const stub of opts?.stubs ?? [])
|
|
118
|
+
if (typeof stub === "object" && stub)
|
|
119
|
+
checkKeys(stub, STUB_KEYS, `stubs.${stub.name ?? "?"}.`);
|
|
120
|
+
for (const [name, service] of Object.entries(opts?.services ?? {}))
|
|
121
|
+
checkKeys(service, APP_KEYS, `services.${name}.`);
|
|
122
|
+
for (const [name, container] of Object.entries(opts?.containers ?? {}))
|
|
123
|
+
checkKeys(container, CONTAINER_KEYS, `containers.${name}.`);
|
|
66
124
|
if (!opts?.app || typeof opts.app.command !== "string" || !opts.app.command.trim()) {
|
|
67
125
|
fail("app.command is required, e.g. { app: { command: \"node server.js\" } }");
|
|
68
126
|
}
|
|
@@ -72,7 +130,39 @@ function validate(opts) {
|
|
|
72
130
|
if (ready && "path" in ready && !ready.path.startsWith("/"))
|
|
73
131
|
fail(`${where}.ready.path must start with "/", got "${ready.path}"`);
|
|
74
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`);
|
|
75
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
|
+
}
|
|
76
166
|
if (opts.app.scope !== undefined && opts.app.scope !== "file" && opts.app.scope !== "worker")
|
|
77
167
|
fail(`app.scope must be "file" or "worker", got ${JSON.stringify(opts.app.scope)}`);
|
|
78
168
|
const checkBuild = (build, where) => {
|
|
@@ -80,13 +170,35 @@ function validate(opts) {
|
|
|
80
170
|
fail(`${where}.build must be a command, e.g. "npm run build"`);
|
|
81
171
|
};
|
|
82
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");
|
|
83
193
|
for (const [name, s] of Object.entries(opts.services ?? {})) {
|
|
84
194
|
if (!/^[\w-]+$/.test(name))
|
|
85
195
|
fail(`service name "${name}" may only contain letters, digits, "_" and "-"`);
|
|
86
196
|
if (!s || typeof s.command !== "string" || !s.command.trim())
|
|
87
197
|
fail(`services.${name}.command is required`);
|
|
88
198
|
checkReady(s.ready, `services.${name}`);
|
|
199
|
+
checkTimeout(s.readyTimeout, `services.${name}.readyTimeout`);
|
|
89
200
|
checkBuild(s.build, `services.${name}`);
|
|
201
|
+
checkLifecycle(s, `services.${name}`);
|
|
90
202
|
}
|
|
91
203
|
for (const [name, c] of Object.entries(opts.containers ?? {})) {
|
|
92
204
|
if (!/^[\w-]+$/.test(name))
|
|
@@ -137,6 +249,10 @@ function validate(opts) {
|
|
|
137
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");
|
|
138
250
|
if (engine === "sqlite" && dbOpts?.url)
|
|
139
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
|
+
}
|
|
140
256
|
const migrate = dbOpts?.migrate;
|
|
141
257
|
if (migrate) {
|
|
142
258
|
const keys = Object.keys(migrate).filter((k) => ["atlas", "sql", "command"].includes(k));
|
|
@@ -146,8 +262,7 @@ function validate(opts) {
|
|
|
146
262
|
if (menv !== undefined) {
|
|
147
263
|
if (!("command" in migrate))
|
|
148
264
|
fail("db.migrate.env is for a migration `command`; atlas and sql get the database URL themselves");
|
|
149
|
-
|
|
150
|
-
fail('db.migrate.env maps variable names to strings, e.g. { DB_HOST: "{{db.host}}" }');
|
|
265
|
+
checkEnv(menv, "db.migrate.env");
|
|
151
266
|
}
|
|
152
267
|
}
|
|
153
268
|
const oas = opts.openapi;
|
|
@@ -213,3 +328,7 @@ function stubName(s) {
|
|
|
213
328
|
function escapeRegExp(s) {
|
|
214
329
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
215
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);
|