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.
Files changed (59) hide show
  1. package/README.md +118 -30
  2. package/dist/app.js +10 -2
  3. package/dist/auth.js +2 -1
  4. package/dist/cli-run.d.ts +40 -0
  5. package/dist/cli-run.js +65 -0
  6. package/dist/cli.js +20 -34
  7. package/dist/config.d.ts +33 -2
  8. package/dist/config.js +82 -8
  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 +1 -0
  24. package/dist/form.js +50 -6
  25. package/dist/har.js +2 -2
  26. package/dist/http.d.ts +9 -3
  27. package/dist/http.js +40 -5
  28. package/dist/init.js +11 -2
  29. package/dist/list.d.ts +1 -0
  30. package/dist/list.js +16 -3
  31. package/dist/mail.d.ts +1 -1
  32. package/dist/mail.js +7 -7
  33. package/dist/matchers.d.ts +4 -3
  34. package/dist/matchers.js +18 -6
  35. package/dist/openapi.d.ts +2 -0
  36. package/dist/openapi.js +133 -14
  37. package/dist/record-cli.js +2 -1
  38. package/dist/record.js +2 -1
  39. package/dist/recording.d.ts +4 -1
  40. package/dist/recording.js +14 -3
  41. package/dist/request-url.d.ts +1 -0
  42. package/dist/request-url.js +5 -0
  43. package/dist/runtime.d.ts +3 -3
  44. package/dist/runtime.js +132 -18
  45. package/dist/scenario.js +11 -2
  46. package/dist/stub.js +17 -6
  47. package/dist/vitest.d.ts +4 -0
  48. package/dist/vitest.js +6 -2
  49. package/dist/webhook.d.ts +3 -3
  50. package/dist/webhook.js +10 -2
  51. package/dist/yaml-runtime.d.ts +4 -4
  52. package/dist/yaml-runtime.js +96 -34
  53. package/dist/yaml.d.ts +4 -0
  54. package/dist/yaml.js +33 -2
  55. package/docs/guarantees.md +92 -0
  56. package/docs/state-isolation.md +94 -0
  57. package/package.json +4 -1
  58. package/schema/config.schema.json +675 -0
  59. 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 } = parseArgs({
74
- args: argv,
75
- allowPositionals: true,
76
- options: {
77
- config: { type: "string", short: "c" },
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 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 });
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 { startVitest, version } = await import("vitest/node");
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 { slicetest, YAML_SCENARIOS } = await import("./vitest.js");
200
- // Vitest 4 takes the mode ("test") first; 5 dropped it.
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: [slicetest(options)] });
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
- /** 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,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
- if (!menv || typeof menv !== "object" || Array.isArray(menv) || Object.values(menv).some((v) => typeof v !== "string"))
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
+ }
@@ -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);
@@ -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
  }