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.
Files changed (63) hide show
  1. package/README.md +131 -35
  2. package/dist/app.js +10 -2
  3. package/dist/auth.js +2 -1
  4. package/dist/cli-run.d.ts +53 -0
  5. package/dist/cli-run.js +80 -0
  6. package/dist/cli.js +27 -29
  7. package/dist/config.d.ts +33 -2
  8. package/dist/config.js +121 -2
  9. package/dist/container-runtime.d.ts +4 -0
  10. package/dist/container-runtime.js +16 -0
  11. package/dist/containers.js +4 -3
  12. package/dist/db.d.ts +3 -1
  13. package/dist/db.js +45 -8
  14. package/dist/deadline.d.ts +7 -0
  15. package/dist/deadline.js +24 -0
  16. package/dist/doctor.js +27 -5
  17. package/dist/drivers/driver.d.ts +6 -0
  18. package/dist/drivers/mysql.js +5 -1
  19. package/dist/drivers/postgres.d.ts +2 -0
  20. package/dist/drivers/postgres.js +20 -1
  21. package/dist/exit.d.ts +1 -0
  22. package/dist/exit.js +28 -0
  23. package/dist/form.d.ts +2 -0
  24. package/dist/form.js +62 -9
  25. package/dist/global-setup.js +3 -6
  26. package/dist/har.js +4 -4
  27. package/dist/http.d.ts +38 -4
  28. package/dist/http.js +135 -10
  29. package/dist/init.js +64 -10
  30. package/dist/list.d.ts +1 -0
  31. package/dist/list.js +16 -3
  32. package/dist/mail.d.ts +1 -1
  33. package/dist/mail.js +7 -7
  34. package/dist/matchers.d.ts +18 -6
  35. package/dist/matchers.js +75 -8
  36. package/dist/openapi.d.ts +2 -5
  37. package/dist/openapi.js +146 -20
  38. package/dist/record-cli.js +2 -1
  39. package/dist/record.js +2 -1
  40. package/dist/recording.d.ts +11 -1
  41. package/dist/recording.js +51 -6
  42. package/dist/request-url.d.ts +1 -0
  43. package/dist/request-url.js +5 -0
  44. package/dist/runtime.d.ts +3 -3
  45. package/dist/runtime.js +144 -18
  46. package/dist/scenario.js +11 -2
  47. package/dist/sql-migrations.d.ts +11 -0
  48. package/dist/sql-migrations.js +38 -0
  49. package/dist/stub.d.ts +10 -3
  50. package/dist/stub.js +64 -16
  51. package/dist/vitest.d.ts +4 -0
  52. package/dist/vitest.js +6 -2
  53. package/dist/webhook.d.ts +3 -3
  54. package/dist/webhook.js +10 -2
  55. package/dist/yaml-runtime.d.ts +4 -4
  56. package/dist/yaml-runtime.js +141 -38
  57. package/dist/yaml.d.ts +12 -2
  58. package/dist/yaml.js +47 -4
  59. package/docs/guarantees.md +92 -0
  60. package/docs/state-isolation.md +94 -0
  61. package/package.json +4 -1
  62. package/schema/config.schema.json +675 -0
  63. 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 } = parseArgs({
69
- args: argv,
70
- allowPositionals: true,
71
- options: {
72
- config: { type: "string", short: "c" },
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 listed = await listScenarios(root, { filters: positionals.slice(1), name: values.name, tags: values.tag?.join(",") });
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 { startVitest, version } = await import("vitest/node");
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 { slicetest, YAML_SCENARIOS } = await import("./vitest.js");
191
- // Vitest 4 takes the mode ("test") first; 5 dropped it.
192
- const start = (Number.parseInt(version, 10) === 4 ? startVitest.bind(null, "test") : startVitest);
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
- }, { plugins: [slicetest(options)] });
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
- /** SQL file (relative to the vitest root) run after every reset. */
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
- if (!menv || typeof menv !== "object" || Array.isArray(menv) || Object.values(menv).some((v) => typeof v !== "string"))
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
+ }
@@ -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
- process.once("exit", () => {
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
- seedFile?: string;
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
- #seed;
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) => driver.insert(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
- if (opts.seedFile)
44
- db.#seed = await readFile(opts.seedFile, "utf8");
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.#driver.insert(table, row)));
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
- this.#tables ??= await this.#listTables();
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.#seed) {
140
- await this.#driver.exec(this.#seed);
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
+ };
@@ -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 { resolveOptions } from "./config.js";
9
- import { configureContainerRuntime } from "./container-runtime.js";
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
- if (opts.db.seed)
125
- await checkPath(add, "seed", opts.db.seed, root, rel);
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);
@@ -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>;
@@ -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);