slicetest 0.4.0 → 0.6.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 (58) hide show
  1. package/README.md +293 -7
  2. package/dist/app.js +2 -2
  3. package/dist/auth.d.ts +55 -0
  4. package/dist/auth.js +140 -0
  5. package/dist/cli.d.ts +1 -1
  6. package/dist/cli.js +18 -6
  7. package/dist/config.d.ts +72 -5
  8. package/dist/config.js +74 -6
  9. package/dist/containers.d.ts +2 -0
  10. package/dist/containers.js +20 -1
  11. package/dist/db.d.ts +28 -0
  12. package/dist/db.js +68 -0
  13. package/dist/doctor.js +17 -5
  14. package/dist/drivers/driver.d.ts +27 -0
  15. package/dist/drivers/mysql.d.ts +2 -1
  16. package/dist/drivers/mysql.js +38 -0
  17. package/dist/drivers/postgres.d.ts +2 -1
  18. package/dist/drivers/postgres.js +28 -0
  19. package/dist/drivers/sqlite.d.ts +2 -1
  20. package/dist/drivers/sqlite.js +52 -0
  21. package/dist/factory.d.ts +21 -0
  22. package/dist/factory.js +129 -0
  23. package/dist/form.d.ts +45 -0
  24. package/dist/form.js +226 -0
  25. package/dist/gen.d.ts +1 -0
  26. package/dist/gen.js +9 -4
  27. package/dist/global-setup.d.ts +1 -0
  28. package/dist/global-setup.js +90 -34
  29. package/dist/http.d.ts +24 -1
  30. package/dist/http.js +86 -9
  31. package/dist/index.d.ts +6 -1
  32. package/dist/index.js +3 -0
  33. package/dist/init.js +245 -24
  34. package/dist/intercept.d.ts +37 -0
  35. package/dist/intercept.js +199 -0
  36. package/dist/neon.d.ts +14 -0
  37. package/dist/neon.js +113 -0
  38. package/dist/openapi.d.ts +10 -0
  39. package/dist/openapi.js +22 -0
  40. package/dist/query-log.d.ts +49 -0
  41. package/dist/query-log.js +261 -0
  42. package/dist/runtime.d.ts +18 -0
  43. package/dist/runtime.js +163 -23
  44. package/dist/setup-file.js +19 -2
  45. package/dist/stub.d.ts +46 -0
  46. package/dist/stub.js +109 -0
  47. package/dist/vitest.d.ts +11 -1
  48. package/dist/vitest.js +27 -2
  49. package/dist/webhook.d.ts +40 -0
  50. package/dist/webhook.js +52 -0
  51. package/dist/x509.d.ts +37 -0
  52. package/dist/x509.js +150 -0
  53. package/dist/yaml-runtime.js +76 -16
  54. package/dist/yaml.d.ts +67 -1
  55. package/dist/yaml.js +87 -3
  56. package/package.json +12 -4
  57. package/preload/node-proxy.cjs +19 -0
  58. package/schema/scenario.schema.json +320 -0
package/dist/cli.js CHANGED
@@ -9,7 +9,7 @@ import { readFile } from "node:fs/promises";
9
9
  import path from "node:path";
10
10
  import { parseArgs } from "node:util";
11
11
  import { parse } from "yaml";
12
- const CONFIG_NAMES = ["slicetest.config.yaml", "slicetest.config.yml", "slicetest.config.json"];
12
+ import { CONFIG_NAMES } from "./config.js";
13
13
  const HELP = `Usage: slicetest [filters...] [options]
14
14
  slicetest init [--force]
15
15
  slicetest gen [--spec <file>] [--out <dir>] [--uncovered] [--force]
@@ -39,11 +39,12 @@ Options:
39
39
 
40
40
  Config (paths are relative to the config file):
41
41
  app: { command, env, cwd, ready: { path } | { log }, readyTimeout }
42
- db: { migrate: { atlas: { dir } } | { sql } | { command, inputs }, engine, seed, url, image, schemas, keep, reuse }
42
+ db: { migrate: { atlas: { dir } } | { sql } | { command, inputs }, engine, seed, url, image, schemas, keep, reuse, queries }
43
43
  stubs: [name | { name, openapi, autoReply, upstream, recordings }]
44
44
  services: { name: { command, env, cwd, ready } }
45
45
  containers: { name: { image, port, env, command, ready: { log }, reset } }
46
46
  mail: true SMTP server at {{mail.host}} / {{mail.port}}
47
+ auth: true | { audience, claims } OpenID issuer at {{auth.issuer}} / {{auth.jwks}}
47
48
  openapi: file | { spec, minCoverage }
48
49
  http: { headers, query }
49
50
  include: [globs] default ["**/*.scenario.{yaml,yml}"]
@@ -86,17 +87,21 @@ export async function main(argv = process.argv.slice(2)) {
86
87
  return;
87
88
  }
88
89
  if (positionals[0] === "gen") {
89
- const configOpenapi = configPath && existsSync(configPath) ? (parse(await readFile(configPath, "utf8")) ?? {}).openapi : undefined;
90
+ const config = configPath && existsSync(configPath) ? (parse(await readFile(configPath, "utf8")) ?? {}) : undefined;
91
+ const configOpenapi = config?.openapi;
90
92
  const spec = values.spec ?? (typeof configOpenapi === "object" ? configOpenapi.spec : configOpenapi);
91
93
  if (!spec)
92
94
  throw new Error("slicetest gen: no OpenAPI spec. Pass --spec openapi.yaml, or set `openapi` in the config.");
93
95
  const root = values.spec || !configPath ? process.cwd() : path.dirname(configPath);
94
96
  const { gen } = await import("./gen.js");
95
- const { written, skipped, count } = await gen(root, { spec, out: values.out, uncovered: values.uncovered, force: values.force });
97
+ const { written, skipped, count, needsAuth } = await gen(root, { spec, out: values.out, uncovered: values.uncovered, force: values.force });
96
98
  const lines = [
97
99
  count === 0 ? "Every documented response is already covered; nothing to generate." : `${count} scenario(s) for the responses in ${spec}.`,
98
100
  ...written.map((f) => ` wrote ${f}`),
99
101
  ...skipped.map((f) => ` skipped ${f} (exists; --force to overwrite)`),
102
+ ...(needsAuth && count > 0 && !config?.auth
103
+ ? ["", "The spec requires bearer tokens: requests carry `auth:`. Add `auth: true` to the config and point the app's JWT settings at {{auth.issuer}} / {{auth.jwks}}."]
104
+ : []),
100
105
  ];
101
106
  process.stdout.write(`${lines.join("\n")}\n`);
102
107
  return;
@@ -112,9 +117,16 @@ export async function main(argv = process.argv.slice(2)) {
112
117
  await record(configPath, options, { out: values.out, port: values.port ? Number(values.port) : 0 });
113
118
  return;
114
119
  }
115
- const { startVitest } = await import("vitest/node");
120
+ const { startVitest, version } = await import("vitest/node");
121
+ if (Number.parseInt(version, 10) < 4) {
122
+ 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`);
123
+ process.exitCode = 1;
124
+ return;
125
+ }
116
126
  const { slicetest, YAML_SCENARIOS } = await import("./vitest.js");
117
- const vitest = await startVitest(positionals, {
127
+ // Vitest 4 takes the mode ("test") first; 5 dropped it.
128
+ const start = (Number.parseInt(version, 10) === 4 ? startVitest.bind(null, "test") : startVitest);
129
+ const vitest = await start(positionals, {
118
130
  config: false,
119
131
  root: path.dirname(configPath),
120
132
  include: include ?? [YAML_SCENARIOS],
package/dist/config.d.ts CHANGED
@@ -1,7 +1,9 @@
1
+ import type { AuthOptions } from "./auth.js";
1
2
  import type { RequestOptions } from "./http.js";
2
3
  export interface SlicetestOptions {
3
4
  app: AppOptions;
4
- db?: DbOptions;
5
+ /** The database, or `false` for an app without one: no container, no resets, no `{{db.*}}`. */
6
+ db?: DbOptions | false;
5
7
  /**
6
8
  * Outbound HTTP services to stub. Each gets its own server, referenced as `{{stub.<name>}}` in `app.env`.
7
9
  * With `{ name, openapi }`, the app's calls to it and the stub's replies are checked against that service's spec.
@@ -17,7 +19,8 @@ export interface SlicetestOptions {
17
19
  * a lower coverage fails the run.
18
20
  */
19
21
  openapi?: string | {
20
- spec: string;
22
+ spec?: string;
23
+ fromApp?: string;
21
24
  minCoverage?: number;
22
25
  };
23
26
  /** Defaults for every request made with `http`, e.g. `{ headers: { accept: "application/json" } }`. */
@@ -43,6 +46,24 @@ export interface SlicetestOptions {
43
46
  * scenarios read what arrived with `mail.messages()` / `mail.waitFor()`.
44
47
  */
45
48
  mail?: boolean;
49
+ /**
50
+ * Keep the app off the network: its HTTP(S) calls may only reach localhost and the
51
+ * hosts of stubs with `hosts`. A call anywhere else is refused and fails the
52
+ * scenario, naming the host, so a forgotten stub can't reach a real service.
53
+ */
54
+ offline?: boolean;
55
+ /**
56
+ * Most Vitest workers to run test files in (Vitest's `maxWorkers`). Each worker has
57
+ * its own app and database; with `app.scope: "worker"`, fewer workers means fewer app
58
+ * starts, which is what makes a slow-starting app fast to test.
59
+ */
60
+ workers?: number;
61
+ /**
62
+ * An OpenID Connect issuer for apps that verify JWTs. The app gets
63
+ * `{{auth.issuer}}`, `{{auth.jwks}}` and `{{auth.audience}}`; scenarios mint
64
+ * tokens with `auth.token({ sub, roles })`. `true`, or `{ audience, claims }`.
65
+ */
66
+ auth?: boolean | AuthOptions;
46
67
  }
47
68
  export interface ContainerOptions {
48
69
  image: string;
@@ -72,6 +93,12 @@ export interface StubOptions {
72
93
  upstream?: string;
73
94
  /** Recordings file, relative to the root. Default `recordings/<name>.yaml`. */
74
95
  recordings?: string;
96
+ /**
97
+ * Hosts the app calls directly, e.g. `["api.github.com"]`: their HTTP and HTTPS
98
+ * traffic is answered by this stub, for apps whose URLs can't be set from the
99
+ * environment. The app is started with proxy variables and a test CA it trusts.
100
+ */
101
+ hosts?: string[];
75
102
  }
76
103
  export interface ServiceOptions extends Omit<AppOptions, "ready"> {
77
104
  /** Default: no wait (for workers that don't listen). `{ path }` polls the service's own port. */
@@ -84,12 +111,20 @@ type ResolvedReady = {
84
111
  flags: string;
85
112
  };
86
113
  /** A process to start, with `ready` made JSON-serializable. The app always has `ready`; services may not. */
87
- export type ResolvedProcess = Omit<AppOptions, "ready"> & {
114
+ export type ResolvedProcess = Omit<AppOptions, "ready" | "scope"> & {
88
115
  ready?: ResolvedReady;
116
+ /** Set by slicetest (proxy variables for intercepted hosts); `env` overrides it. */
117
+ baseEnv?: Record<string, string>;
89
118
  };
90
119
  export interface AppOptions {
91
120
  /** Command that starts the app, run through the shell. */
92
121
  command: string;
122
+ /**
123
+ * Command run once per run, before any worker starts the process, e.g.
124
+ * `npm run build` for an app started with `next start`. Runs in `cwd` while
125
+ * the database starts. Not repeated on re-runs in watch mode.
126
+ */
127
+ build?: string;
93
128
  cwd?: string;
94
129
  /**
95
130
  * Environment passed to the app. Values may reference
@@ -105,6 +140,13 @@ export interface AppOptions {
105
140
  };
106
141
  /** Milliseconds to wait for readiness. Default 30000. */
107
142
  readyTimeout?: number;
143
+ /**
144
+ * `"file"` (default) starts the app, stubs and services for each test file.
145
+ * `"worker"` starts them once per Vitest worker and keeps them for every file that
146
+ * worker runs, for apps that take seconds to start (a JVM, a large framework). It
147
+ * turns off Vitest's per-file module isolation (`isolate: false`).
148
+ */
149
+ scope?: "file" | "worker";
108
150
  }
109
151
  export interface DbOptions {
110
152
  /**
@@ -135,6 +177,18 @@ export interface DbOptions {
135
177
  * Default: on, except when `CI` is set or `url` is given.
136
178
  */
137
179
  reuse?: boolean;
180
+ /**
181
+ * Record the SQL the app runs: `{{db.url}}` points the app at a proxy that
182
+ * reads the wire protocol (Postgres and MySQL), so `db.queries()` lists every
183
+ * statement, whatever the app's language or driver. Off by default.
184
+ */
185
+ queries?: boolean;
186
+ /**
187
+ * For apps on Neon's serverless driver over HTTP (`neon()` from @neondatabase/serverless,
188
+ * drizzle-orm/neon-http): `{{db.url}}` becomes a Neon-style connection string, and
189
+ * slicetest answers the driver's HTTP queries from the test database. Postgres only.
190
+ */
191
+ neon?: boolean;
138
192
  }
139
193
  export type MigrateOptions = {
140
194
  atlas: {
@@ -154,17 +208,26 @@ export type MigrateOptions = {
154
208
  /** Normalized shape passed from the plugin to globalSetup and workers. Must stay JSON-serializable. */
155
209
  export interface ResolvedOptions {
156
210
  root: string;
157
- app: Omit<AppOptions, "ready"> & {
211
+ app: ResolvedProcess & {
158
212
  ready: ResolvedReady;
213
+ scope?: "file" | "worker";
159
214
  };
160
215
  services: Record<string, ResolvedProcess>;
161
216
  containers: Record<string, ContainerOptions>;
162
217
  mail: boolean;
163
- db: Required<Pick<DbOptions, "engine" | "image" | "schemas" | "keep" | "reuse">> & Omit<DbOptions, "engine" | "image" | "schemas" | "keep" | "reuse">;
218
+ offline: boolean;
219
+ workers?: number;
220
+ auth: AuthOptions | false;
221
+ db: Required<Pick<DbOptions, "engine" | "image" | "schemas" | "keep" | "reuse">> & Omit<DbOptions, "engine" | "image" | "schemas" | "keep" | "reuse"> & {
222
+ /** `db: false`: the app has no database. */
223
+ none?: boolean;
224
+ };
164
225
  stubs: string[];
165
226
  /** Spec files, resolved against the root: the app's, and per stub name. */
227
+ /** `fromApp`: a path the running app serves its own spec at (springdoc, FastAPI, NestJS). */
166
228
  openapi: {
167
229
  app?: string;
230
+ fromApp?: string;
168
231
  minCoverage?: number;
169
232
  stubs: Record<string, string>;
170
233
  autoReply: string[];
@@ -175,7 +238,11 @@ export interface ResolvedOptions {
175
238
  upstream: string;
176
239
  record: boolean;
177
240
  }>;
241
+ /** Intercepted host (lower case) → the stub that answers it. */
242
+ intercept: Record<string, string>;
178
243
  http?: RequestOptions;
179
244
  }
245
+ /** The config files `npx slicetest` (and `slicetest()` without options) look for, in order. */
246
+ export declare const CONFIG_NAMES: string[];
180
247
  export declare function resolveOptions(opts: SlicetestOptions, root: string): ResolvedOptions;
181
248
  export {};
package/dist/config.js CHANGED
@@ -1,3 +1,5 @@
1
+ /** The config files `npx slicetest` (and `slicetest()` without options) look for, in order. */
2
+ export const CONFIG_NAMES = ["slicetest.config.yaml", "slicetest.config.yml", "slicetest.config.json"];
1
3
  export function resolveOptions(opts, root) {
2
4
  validate(opts);
3
5
  return {
@@ -6,15 +8,20 @@ export function resolveOptions(opts, root) {
6
8
  services: Object.fromEntries(Object.entries(opts.services ?? {}).map(([name, s]) => [name, { ...s, ready: s.ready && resolveReady(s.ready) }])),
7
9
  containers: opts.containers ?? {},
8
10
  mail: opts.mail ?? false,
9
- db: resolveDb(opts.db ?? {}),
11
+ offline: opts.offline ?? false,
12
+ workers: opts.workers,
13
+ auth: opts.auth === true ? {} : (opts.auth ?? false),
14
+ db: opts.db === false ? { ...resolveDb({}), none: true } : resolveDb(opts.db ?? {}),
10
15
  stubs: (opts.stubs ?? []).map(stubName),
11
16
  openapi: {
12
17
  app: typeof opts.openapi === "object" ? opts.openapi.spec : opts.openapi,
18
+ fromApp: typeof opts.openapi === "object" ? opts.openapi.fromApp : undefined,
13
19
  minCoverage: typeof opts.openapi === "object" ? opts.openapi.minCoverage : undefined,
14
20
  stubs: Object.fromEntries((opts.stubs ?? []).flatMap((s) => (typeof s === "object" && s.openapi ? [[s.name, s.openapi]] : []))),
15
21
  autoReply: (opts.stubs ?? []).flatMap((s) => (typeof s === "object" && s.autoReply ? [s.name] : [])),
16
22
  },
17
23
  recordings: resolveRecordings(opts.stubs ?? []),
24
+ intercept: Object.fromEntries((opts.stubs ?? []).flatMap((s) => (typeof s === "object" ? (s.hosts ?? []).map((h) => [h.toLowerCase(), s.name]) : []))),
18
25
  http: opts.http,
19
26
  };
20
27
  }
@@ -65,12 +72,20 @@ function validate(opts) {
65
72
  fail(`${where}.ready.path must start with "/", got "${ready.path}"`);
66
73
  };
67
74
  checkReady(opts.app.ready, "app");
75
+ if (opts.app.scope !== undefined && opts.app.scope !== "file" && opts.app.scope !== "worker")
76
+ fail(`app.scope must be "file" or "worker", got ${JSON.stringify(opts.app.scope)}`);
77
+ const checkBuild = (build, where) => {
78
+ if (build !== undefined && (typeof build !== "string" || !build.trim()))
79
+ fail(`${where}.build must be a command, e.g. "npm run build"`);
80
+ };
81
+ checkBuild(opts.app.build, "app");
68
82
  for (const [name, s] of Object.entries(opts.services ?? {})) {
69
83
  if (!/^[\w-]+$/.test(name))
70
84
  fail(`service name "${name}" may only contain letters, digits, "_" and "-"`);
71
85
  if (!s || typeof s.command !== "string" || !s.command.trim())
72
86
  fail(`services.${name}.command is required`);
73
87
  checkReady(s.ready, `services.${name}`);
88
+ checkBuild(s.build, `services.${name}`);
74
89
  }
75
90
  for (const [name, c] of Object.entries(opts.containers ?? {})) {
76
91
  if (!/^[\w-]+$/.test(name))
@@ -85,14 +100,38 @@ function validate(opts) {
85
100
  fail(`containers.${name}.${key} must be a list of strings, e.g. ["redis-cli", "FLUSHALL"]`);
86
101
  }
87
102
  }
103
+ if (opts.workers !== undefined && !(Number.isInteger(opts.workers) && opts.workers >= 1))
104
+ fail(`workers must be a positive whole number, got ${JSON.stringify(opts.workers)}`);
105
+ if (opts.offline !== undefined && typeof opts.offline !== "boolean")
106
+ fail(`offline must be true or false, got ${JSON.stringify(opts.offline)}`);
88
107
  if (opts.mail !== undefined && typeof opts.mail !== "boolean")
89
108
  fail(`mail must be true or false, got ${JSON.stringify(opts.mail)}`);
90
- const engine = opts.db?.engine;
109
+ if (opts.auth !== undefined && typeof opts.auth !== "boolean") {
110
+ if (!opts.auth || typeof opts.auth !== "object" || Array.isArray(opts.auth))
111
+ fail(`auth must be true or { audience, claims }, got ${JSON.stringify(opts.auth)}`);
112
+ for (const key of Object.keys(opts.auth))
113
+ if (key !== "audience" && key !== "claims")
114
+ fail(`unknown key auth.${key} (expected audience, claims)`);
115
+ if (opts.auth.audience !== undefined && typeof opts.auth.audience !== "string")
116
+ fail("auth.audience must be a string");
117
+ if (opts.auth.claims !== undefined && (!opts.auth.claims || typeof opts.auth.claims !== "object" || Array.isArray(opts.auth.claims)))
118
+ fail("auth.claims must be a mapping of claim names to values");
119
+ }
120
+ const dbOpts = opts.db === false ? undefined : opts.db;
121
+ const engine = dbOpts?.engine;
91
122
  if (engine !== undefined && engine !== "postgres" && engine !== "mysql" && engine !== "sqlite")
92
123
  fail(`db.engine must be "postgres", "mysql" or "sqlite", got ${JSON.stringify(engine)}`);
93
- if (engine === "sqlite" && opts.db?.url)
124
+ if (dbOpts?.queries !== undefined && typeof dbOpts.queries !== "boolean")
125
+ fail(`db.queries must be true or false, got ${JSON.stringify(dbOpts.queries)}`);
126
+ if (dbOpts?.neon !== undefined && typeof dbOpts.neon !== "boolean")
127
+ fail(`db.neon must be true or false, got ${JSON.stringify(dbOpts.neon)}`);
128
+ if (dbOpts?.neon && (engine === "mysql" || engine === "sqlite"))
129
+ fail("db.neon is Neon's protocol for Postgres; it doesn't apply to " + engine);
130
+ if (engine === "sqlite" && dbOpts?.queries)
131
+ fail("db.queries needs a database server (postgres or mysql): an SQLite app opens the file directly, so there is no connection to read");
132
+ if (engine === "sqlite" && dbOpts?.url)
94
133
  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}}");
95
- const migrate = opts.db?.migrate;
134
+ const migrate = dbOpts?.migrate;
96
135
  if (migrate) {
97
136
  const keys = Object.keys(migrate).filter((k) => ["atlas", "sql", "command"].includes(k));
98
137
  if (keys.length !== 1)
@@ -101,8 +140,15 @@ function validate(opts) {
101
140
  const oas = opts.openapi;
102
141
  if (oas !== undefined) {
103
142
  const spec = typeof oas === "object" && oas ? oas.spec : oas;
104
- if (typeof spec !== "string" || !spec)
105
- fail('openapi must be the path of an OpenAPI file, or { spec, minCoverage }');
143
+ const fromApp = typeof oas === "object" && oas ? oas.fromApp : undefined;
144
+ if (fromApp !== undefined) {
145
+ if (spec !== undefined)
146
+ fail("openapi takes either spec (a file) or fromApp (a path the app serves its spec at), not both");
147
+ if (typeof fromApp !== "string" || !fromApp.startsWith("/"))
148
+ fail(`openapi.fromApp must be a path on the app such as "/v3/api-docs", got ${JSON.stringify(fromApp)}`);
149
+ }
150
+ else if (typeof spec !== "string" || !spec)
151
+ fail('openapi must be the path of an OpenAPI file, or { spec, minCoverage }, or { fromApp: "/v3/api-docs" }');
106
152
  const min = typeof oas === "object" ? oas.minCoverage : undefined;
107
153
  if (min !== undefined && !(typeof min === "number" && min >= 0 && min <= 100))
108
154
  fail("openapi.minCoverage must be a percentage between 0 and 100");
@@ -116,6 +162,28 @@ function validate(opts) {
116
162
  fail(`stub "${s.name}": upstream must be an http(s) URL, got ${JSON.stringify(s.upstream)}`);
117
163
  if (typeof s === "object" && s.recordings !== undefined && !s.upstream)
118
164
  fail(`stub "${s.name}": recordings needs an upstream to record from`);
165
+ if (typeof s === "object" && s.hosts !== undefined) {
166
+ if (!Array.isArray(s.hosts) || s.hosts.length === 0)
167
+ fail(`stub "${s.name}": hosts must be a list of host names, e.g. ["api.github.com"]`);
168
+ for (const h of s.hosts) {
169
+ if (typeof h !== "string" || !/^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$|^[a-z0-9-]+$/i.test(h)) {
170
+ fail(`stub "${s.name}": hosts takes host names (or *.domain for every subdomain), no scheme, port or path; got ${JSON.stringify(h)}`);
171
+ }
172
+ if (["localhost", "127.0.0.1"].includes(h.toLowerCase()))
173
+ fail(`stub "${s.name}": ${h} can't be intercepted; point the app at {{stub.${s.name}}} instead`);
174
+ }
175
+ }
176
+ }
177
+ const seen = new Map();
178
+ for (const s of opts.stubs ?? []) {
179
+ if (typeof s !== "object")
180
+ continue;
181
+ for (const h of s.hosts ?? []) {
182
+ const other = seen.get(h.toLowerCase());
183
+ if (other)
184
+ fail(`host ${h} is intercepted by both stub "${other}" and stub "${s.name}"`);
185
+ seen.set(h.toLowerCase(), s.name);
186
+ }
119
187
  }
120
188
  const stubs = (opts.stubs ?? []).map(stubName);
121
189
  for (const name of stubs) {
@@ -19,4 +19,6 @@ export declare class Dependency {
19
19
  /** Start of a scenario: run the `reset` command, if any. */
20
20
  reset(): Promise<void>;
21
21
  stop(): Promise<void>;
22
+ /** Synchronous removal for a worker exiting without tearing down (Ryuk is often off with Podman). */
23
+ removeNow(): void;
22
24
  }
@@ -1,3 +1,4 @@
1
+ import { execFileSync } from "node:child_process";
1
2
  import { configureContainerRuntime } from "./container-runtime.js";
2
3
  /**
3
4
  * A container the app depends on (Redis, a search engine, an S3 emulator),
@@ -23,7 +24,9 @@ export class Dependency {
23
24
  if (opts.ready)
24
25
  definition = definition.withWaitStrategy(Wait.forLogMessage(opts.ready.log));
25
26
  try {
26
- return new Dependency(name, await definition.start(), opts);
27
+ const dependency = new Dependency(name, await definition.start(), opts);
28
+ running.add(dependency);
29
+ return dependency;
27
30
  }
28
31
  catch (e) {
29
32
  throw new Error(`slicetest: container "${name}" (${opts.image}) didn't start: ${e.message}`);
@@ -54,6 +57,22 @@ export class Dependency {
54
57
  await this.exec(this.opts.reset);
55
58
  }
56
59
  async stop() {
60
+ running.delete(this);
57
61
  await this.container.stop();
58
62
  }
63
+ /** Synchronous removal for a worker exiting without tearing down (Ryuk is often off with Podman). */
64
+ removeNow() {
65
+ for (const cli of ["docker", "podman"]) {
66
+ try {
67
+ execFileSync(cli, ["rm", "-f", this.container.getId()], { stdio: "ignore", timeout: 10_000, windowsHide: true });
68
+ return;
69
+ }
70
+ catch { }
71
+ }
72
+ }
59
73
  }
74
+ const running = new Set();
75
+ process.once("exit", () => {
76
+ for (const d of running)
77
+ d.removeNow();
78
+ });
package/dist/db.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Driver, Row } from "./drivers/driver.js";
2
+ import type { QueryList, QueryLog } from "./query-log.js";
2
3
  export type { Row } from "./drivers/driver.js";
3
4
  /**
4
5
  * Column filters. `null` means IS NULL and an array means IN (...);
@@ -48,6 +49,31 @@ export declare class Db {
48
49
  count(table: string, where?: Where): Promise<number>;
49
50
  /** Insert rows and return them as stored (with defaults and generated ids). */
50
51
  insert<T extends Row = Row>(table: string, rows: Row | Row[]): Promise<T[]>;
52
+ /**
53
+ * Insert a row that satisfies the schema, giving only the columns the test cares about.
54
+ * Required columns get a value of their type (enums and `CHECK (... IN (...))` their first
55
+ * allowed value) and required foreign keys a parent row made the same way.
56
+ *
57
+ * ```ts
58
+ * const order = await db.make("orders", { status: "paid" }); // also creates the customer it needs
59
+ * ```
60
+ */
61
+ make<T extends Row = Row>(table: string, overrides?: Row): Promise<T>;
62
+ /** `count` rows made like `make()`; `overrides` may depend on the index. */
63
+ makeMany<T extends Row = Row>(table: string, count: number, overrides?: Row | ((i: number) => Row)): Promise<T[]>;
64
+ /** @internal Set by the runtime when `db.queries` is on. */
65
+ attachQueryLog(log: QueryLog): void;
66
+ /**
67
+ * SQL the app ran during this scenario, or only while `fn` ran. Needs `db: { queries: true }`.
68
+ * The test's own `db` calls aren't included.
69
+ *
70
+ * ```ts
71
+ * const queries = await db.queries(() => http.get("/posts"));
72
+ * expect(queries.repeated()).toEqual([]); // no statement shape ran 3+ times: no N+1
73
+ * expect(queries.length).toBeLessThanOrEqual(3);
74
+ * ```
75
+ */
76
+ queries(fn?: () => unknown): Promise<QueryList>;
51
77
  /** Empty every data table without dropping the app's connections, then re-apply the seed. */
52
78
  reset(): Promise<void>;
53
79
  /**
@@ -69,3 +95,5 @@ export declare class Db {
69
95
  }
70
96
  /** Short summary of `changes()` for failure output. */
71
97
  export declare function formatChanges(changes: Changes, maxRows?: number): string;
98
+ /** The `db` of an app without a database (`db: false`): resetting is a no-op, anything else explains. */
99
+ export declare function noDatabase(): Db;
package/dist/db.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { readFile } from "node:fs/promises";
2
+ import { Factory } from "./factory.js";
2
3
  /** Tables that record applied migrations. Truncating them would make tools re-run migrations. */
3
4
  const MIGRATION_TABLES = [
4
5
  "atlas_schema_revisions",
@@ -29,10 +30,13 @@ export class Db {
29
30
  /** Contents right after the reset (and seed); undefined means every table was empty. */
30
31
  #start;
31
32
  #checkpoint;
33
+ #factory;
34
+ #queryLog;
32
35
  constructor(driver, url, opts) {
33
36
  this.url = url;
34
37
  this.opts = opts;
35
38
  this.#driver = driver;
39
+ this.#factory = new Factory((table) => driver.describe(table), (table, row) => driver.insert(table, row));
36
40
  }
37
41
  static async connect(driver, url, opts) {
38
42
  const db = new Db(driver, url, opts);
@@ -80,10 +84,56 @@ export class Db {
80
84
  out.push(...(await this.#driver.insert(table, row)));
81
85
  return out;
82
86
  }
87
+ /**
88
+ * Insert a row that satisfies the schema, giving only the columns the test cares about.
89
+ * Required columns get a value of their type (enums and `CHECK (... IN (...))` their first
90
+ * allowed value) and required foreign keys a parent row made the same way.
91
+ *
92
+ * ```ts
93
+ * const order = await db.make("orders", { status: "paid" }); // also creates the customer it needs
94
+ * ```
95
+ */
96
+ async make(table, overrides = {}) {
97
+ return (await this.#factory.make(table, overrides));
98
+ }
99
+ /** `count` rows made like `make()`; `overrides` may depend on the index. */
100
+ async makeMany(table, count, overrides = {}) {
101
+ const out = [];
102
+ for (let i = 0; i < count; i++)
103
+ out.push(await this.make(table, typeof overrides === "function" ? overrides(i) : overrides));
104
+ return out;
105
+ }
106
+ /** @internal Set by the runtime when `db.queries` is on. */
107
+ attachQueryLog(log) {
108
+ this.#queryLog = log;
109
+ }
110
+ /**
111
+ * SQL the app ran during this scenario, or only while `fn` ran. Needs `db: { queries: true }`.
112
+ * The test's own `db` calls aren't included.
113
+ *
114
+ * ```ts
115
+ * const queries = await db.queries(() => http.get("/posts"));
116
+ * expect(queries.repeated()).toEqual([]); // no statement shape ran 3+ times: no N+1
117
+ * expect(queries.length).toBeLessThanOrEqual(3);
118
+ * ```
119
+ */
120
+ async queries(fn) {
121
+ const log = this.#queryLog;
122
+ if (!log)
123
+ throw new Error("slicetest: db.queries() needs `db: { queries: true }` in the config (Postgres or MySQL); the app then connects through a proxy that records its SQL.");
124
+ if (!fn)
125
+ return log.queries();
126
+ const mark = log.mark();
127
+ await fn();
128
+ // Let statements already on the wire arrive.
129
+ await new Promise((r) => setTimeout(r, 10));
130
+ return log.queries(mark);
131
+ }
83
132
  /** Empty every data table without dropping the app's connections, then re-apply the seed. */
84
133
  async reset() {
85
134
  this.#tables ??= await this.#listTables();
86
135
  await this.#driver.truncate(this.#tables);
136
+ this.#factory.reset();
87
137
  this.#start = undefined;
88
138
  this.#checkpoint = undefined;
89
139
  if (this.#seed) {
@@ -232,3 +282,21 @@ export function formatChanges(changes, maxRows = 3) {
232
282
  function truncate(s, max = 200) {
233
283
  return s.length > max ? `${s.slice(0, max)}…` : s;
234
284
  }
285
+ /** The `db` of an app without a database (`db: false`): resetting is a no-op, anything else explains. */
286
+ export function noDatabase() {
287
+ const off = () => {
288
+ throw new Error("slicetest: the app has no database (db: false), so db.* isn't available. Configure `db` to use it.");
289
+ };
290
+ return new Proxy({}, {
291
+ get(_, prop) {
292
+ if (prop === "reset" || prop === "close")
293
+ return async () => { };
294
+ if (prop === "changesSinceStart")
295
+ return async () => ({});
296
+ if (prop === "then")
297
+ return undefined;
298
+ // The real methods are async, so this rejects like they would.
299
+ return async () => off();
300
+ },
301
+ });
302
+ }
package/dist/doctor.js CHANGED
@@ -67,7 +67,12 @@ export async function doctor(configPath, probes = machine, env = process.env) {
67
67
  }
68
68
  // Database: a server that's there already, or a container runtime to start one in.
69
69
  const url = opts.db.engine === "sqlite" ? undefined : (opts.db.url ?? env.SLICETEST_DATABASE_URL);
70
- if (opts.db.engine === "sqlite") {
70
+ if (opts.db.none) {
71
+ add("ok", "no database (db: false)");
72
+ if (Object.keys(opts.containers).length)
73
+ await checkContainerRuntime(add, probes, `runs ${Object.values(opts.containers).map((c) => c.image).join(", ")}`);
74
+ }
75
+ else if (opts.db.engine === "sqlite") {
71
76
  const [maj = 0, min = 0] = process.versions.node.split(".").map(Number);
72
77
  if (maj > 22 || (maj === 22 && min >= 5))
73
78
  add("ok", "sqlite (node:sqlite, no server needed)");
@@ -97,7 +102,10 @@ export async function doctor(configPath, probes = machine, env = process.env) {
97
102
  }
98
103
  }
99
104
  const m = opts.db.migrate;
100
- if (!m)
105
+ if (opts.db.none) {
106
+ // Nothing to migrate.
107
+ }
108
+ else if (!m)
101
109
  add("warn", "db.migrate not set", "scenarios run against an empty database unless the app creates its own tables");
102
110
  else if ("atlas" in m) {
103
111
  await checkPath(add, "migrations", m.atlas.dir.replace(/^file:\/\//, ""), root, rel);
@@ -118,12 +126,16 @@ export async function doctor(configPath, probes = machine, env = process.env) {
118
126
  for (const [label, p] of [["app", opts.app], ...Object.entries(opts.services).map(([n, s]) => [`service ${n}`, s])]) {
119
127
  if (p.cwd)
120
128
  await checkPath(add, `${label} cwd`, p.cwd, root, rel);
121
- const program = firstWord(p.command);
122
- if (program && !/^[.\\/]|\{\{|\$/.test(program)) {
129
+ const seen = new Set();
130
+ for (const command of [p.build, p.command]) {
131
+ const program = command && firstWord(command);
132
+ if (!program || /^[.\\/]|\{\{|\$/.test(program) || seen.has(program))
133
+ continue;
134
+ seen.add(program);
123
135
  if (await onPath(program, env))
124
136
  add("ok", `${label}: ${program} found`);
125
137
  else
126
- add("warn", `${label}: ${program} not found on PATH`, `\`${p.command}\` may fail to start. Fine if a shell alias or a relative script provides it`);
138
+ add("warn", `${label}: ${program} not found on PATH`, `\`${command}\` may fail to start. Fine if a shell alias or a relative script provides it`);
127
139
  }
128
140
  }
129
141
  const specs = [...(opts.openapi.app ? [["app OpenAPI", opts.openapi.app]] : []), ...Object.entries(opts.openapi.stubs).map(([n, f]) => [`stub ${n} OpenAPI`, f])];
@@ -13,6 +13,31 @@ export interface Table {
13
13
  /** Primary-key columns, in order; empty when there is none. */
14
14
  key: string[];
15
15
  }
16
+ export interface Column {
17
+ name: string;
18
+ /** The declared type as the engine reports it, e.g. `character varying(20)`, `int`, `TEXT`. */
19
+ type: string;
20
+ nullable: boolean;
21
+ /** Has a default, is generated, an identity or auto-increment column: inserts may leave it out. */
22
+ hasDefault: boolean;
23
+ maxLength?: number;
24
+ /** Labels of an enum type. */
25
+ values?: string[];
26
+ }
27
+ export interface ForeignKey {
28
+ columns: string[];
29
+ /** Referenced table, named like `Table.name`. */
30
+ table: string;
31
+ /** Referenced columns; empty means the referenced table's primary key (SQLite). */
32
+ references: string[];
33
+ }
34
+ /** What `db.make()` needs to build a valid row. */
35
+ export interface TableShape {
36
+ columns: Column[];
37
+ foreignKeys: ForeignKey[];
38
+ /** Bodies of CHECK constraints, as the engine prints them. */
39
+ checks: string[];
40
+ }
16
41
  export interface Driver {
17
42
  query<T extends Row = Row>(sql: string, params?: unknown[]): Promise<T[]>;
18
43
  /** Several parameterless SELECTs in one round trip. */
@@ -33,6 +58,8 @@ export interface Driver {
33
58
  truncate(tables: Table[]): Promise<void>;
34
59
  /** Insert one row and return it as stored (defaults and generated ids filled in). */
35
60
  insert(table: string, row: Row): Promise<Row[]>;
61
+ /** Columns, foreign keys and checks of `table`. Throws when there is no such table. */
62
+ describe(table: string): Promise<TableShape>;
36
63
  close(): Promise<void>;
37
64
  }
38
65
  /** Server-level operations: the databases slicetest creates for templates and workers. */
@@ -1,4 +1,4 @@
1
- import type { Driver, Engine, Row, Table } from "./driver.js";
1
+ import type { Driver, Engine, Row, Table, TableShape } from "./driver.js";
2
2
  export declare class MysqlDriver implements Driver {
3
3
  #private;
4
4
  private readonly conn;
@@ -24,6 +24,7 @@ export declare class MysqlDriver implements Driver {
24
24
  truncate(tables: Table[]): Promise<void>;
25
25
  /** MySQL has no RETURNING: insert, then read the row back by its primary key. */
26
26
  insert(table: string, row: Row): Promise<Row[]>;
27
+ describe(table: string): Promise<TableShape>;
27
28
  close(): Promise<void>;
28
29
  }
29
30
  export declare const mysqlEngine: Engine;