slicetest 0.3.0 → 0.5.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 (60) hide show
  1. package/README.md +211 -7
  2. package/dist/auth.d.ts +45 -0
  3. package/dist/auth.js +128 -0
  4. package/dist/ci.d.ts +29 -0
  5. package/dist/ci.js +57 -0
  6. package/dist/cli.js +31 -3
  7. package/dist/config.d.ts +25 -2
  8. package/dist/config.js +25 -4
  9. package/dist/db.d.ts +26 -0
  10. package/dist/db.js +50 -0
  11. package/dist/doctor.d.ts +22 -0
  12. package/dist/doctor.js +190 -0
  13. package/dist/drivers/driver.d.ts +29 -0
  14. package/dist/drivers/index.js +2 -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 +24 -0
  20. package/dist/drivers/sqlite.js +255 -0
  21. package/dist/factory.d.ts +21 -0
  22. package/dist/factory.js +129 -0
  23. package/dist/gen.d.ts +1 -0
  24. package/dist/gen.js +9 -4
  25. package/dist/global-setup.js +24 -3
  26. package/dist/http.d.ts +13 -0
  27. package/dist/http.js +24 -0
  28. package/dist/index.d.ts +5 -1
  29. package/dist/index.js +2 -0
  30. package/dist/init.js +123 -5
  31. package/dist/mail.d.ts +58 -0
  32. package/dist/mail.js +299 -0
  33. package/dist/matchers.d.ts +4 -0
  34. package/dist/matchers.js +33 -0
  35. package/dist/openapi.d.ts +9 -0
  36. package/dist/openapi.js +29 -1
  37. package/dist/provided.d.ts +1 -0
  38. package/dist/query-log.d.ts +49 -0
  39. package/dist/query-log.js +261 -0
  40. package/dist/record-cli.d.ts +6 -0
  41. package/dist/record-cli.js +93 -0
  42. package/dist/record-session.d.ts +1 -0
  43. package/dist/record-session.js +10 -0
  44. package/dist/record.d.ts +46 -0
  45. package/dist/record.js +201 -0
  46. package/dist/runtime.d.ts +10 -0
  47. package/dist/runtime.js +62 -3
  48. package/dist/stub.d.ts +32 -0
  49. package/dist/stub.js +87 -0
  50. package/dist/trace.d.ts +9 -1
  51. package/dist/trace.js +3 -1
  52. package/dist/vitest.js +5 -2
  53. package/dist/webhook.d.ts +40 -0
  54. package/dist/webhook.js +52 -0
  55. package/dist/yaml-runtime.d.ts +1 -0
  56. package/dist/yaml-runtime.js +92 -15
  57. package/dist/yaml.d.ts +59 -1
  58. package/dist/yaml.js +88 -5
  59. package/package.json +11 -4
  60. package/schema/scenario.schema.json +329 -1
package/dist/cli.js CHANGED
@@ -13,11 +13,17 @@ const CONFIG_NAMES = ["slicetest.config.yaml", "slicetest.config.yml", "slicetes
13
13
  const HELP = `Usage: slicetest [filters...] [options]
14
14
  slicetest init [--force]
15
15
  slicetest gen [--spec <file>] [--out <dir>] [--uncovered] [--force]
16
+ slicetest doctor [--config <file>]
17
+ slicetest record [--out <file>] [--port <n>]
16
18
 
17
19
  Runs *.scenario.yaml files against your app, as configured in slicetest.config.yaml.
18
20
  \`slicetest init\` looks at the project and writes a starting config and scenario.
19
21
  \`slicetest gen\` writes scenario skeletons for the documented responses of the
20
22
  app's OpenAPI spec; with --uncovered, only for those the last run didn't produce.
23
+ \`slicetest doctor\` checks the config, the container runtime or database server,
24
+ migrations, commands and spec files, and says what to fix.
25
+ \`slicetest record\` starts everything and a proxy in front of the app: use the app
26
+ through it (a browser, curl), press Enter, and get the session as a YAML scenario.
21
27
 
22
28
  Options:
23
29
  -c, --config <file> Config file (default: ${CONFIG_NAMES.join(" / ")} in the current directory)
@@ -25,16 +31,20 @@ Options:
25
31
  -t, --name <pattern> Only run scenarios whose name matches
26
32
  --spec <file> gen: OpenAPI file (default: \`openapi\` from the config)
27
33
  --out <dir> gen: where to write scenarios (default: scenarios)
34
+ record: the scenario file (default: scenarios/recorded-<time>.scenario.yaml)
35
+ --port <n> record: the proxy's port (default: any free port)
28
36
  --uncovered gen: only responses the last run didn't cover
29
37
  --force init, gen: overwrite existing files
30
38
  -h, --help Show this help
31
39
 
32
40
  Config (paths are relative to the config file):
33
41
  app: { command, env, cwd, ready: { path } | { log }, readyTimeout }
34
- 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 }
35
43
  stubs: [name | { name, openapi, autoReply, upstream, recordings }]
36
44
  services: { name: { command, env, cwd, ready } }
37
45
  containers: { name: { image, port, env, command, ready: { log }, reset } }
46
+ mail: true SMTP server at {{mail.host}} / {{mail.port}}
47
+ auth: true | { audience, claims } OpenID issuer at {{auth.issuer}} / {{auth.jwks}}
38
48
  openapi: file | { spec, minCoverage }
39
49
  http: { headers, query }
40
50
  include: [globs] default ["**/*.scenario.{yaml,yml}"]
@@ -52,6 +62,7 @@ export async function main(argv = process.argv.slice(2)) {
52
62
  spec: { type: "string" },
53
63
  out: { type: "string" },
54
64
  uncovered: { type: "boolean" },
65
+ port: { type: "string" },
55
66
  },
56
67
  });
57
68
  if (values.help) {
@@ -67,18 +78,30 @@ export async function main(argv = process.argv.slice(2)) {
67
78
  const configPath = values.config
68
79
  ? path.resolve(values.config)
69
80
  : CONFIG_NAMES.map((n) => path.resolve(n)).find((p) => existsSync(p));
81
+ if (positionals[0] === "doctor") {
82
+ const { doctor, formatChecks } = await import("./doctor.js");
83
+ const checks = await doctor(configPath);
84
+ process.stdout.write(formatChecks(checks));
85
+ if (checks.some((c) => c.status === "fail"))
86
+ process.exitCode = 1;
87
+ return;
88
+ }
70
89
  if (positionals[0] === "gen") {
71
- 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;
72
92
  const spec = values.spec ?? (typeof configOpenapi === "object" ? configOpenapi.spec : configOpenapi);
73
93
  if (!spec)
74
94
  throw new Error("slicetest gen: no OpenAPI spec. Pass --spec openapi.yaml, or set `openapi` in the config.");
75
95
  const root = values.spec || !configPath ? process.cwd() : path.dirname(configPath);
76
96
  const { gen } = await import("./gen.js");
77
- 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 });
78
98
  const lines = [
79
99
  count === 0 ? "Every documented response is already covered; nothing to generate." : `${count} scenario(s) for the responses in ${spec}.`,
80
100
  ...written.map((f) => ` wrote ${f}`),
81
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
+ : []),
82
105
  ];
83
106
  process.stdout.write(`${lines.join("\n")}\n`);
84
107
  return;
@@ -89,6 +112,11 @@ export async function main(argv = process.argv.slice(2)) {
89
112
  return;
90
113
  }
91
114
  const { include, ...options } = (parse(await readFile(configPath, "utf8")) ?? {});
115
+ if (positionals[0] === "record") {
116
+ const { record } = await import("./record-cli.js");
117
+ await record(configPath, options, { out: values.out, port: values.port ? Number(values.port) : 0 });
118
+ return;
119
+ }
92
120
  const { startVitest } = await import("vitest/node");
93
121
  const { slicetest, YAML_SCENARIOS } = await import("./vitest.js");
94
122
  const vitest = await startVitest(positionals, {
package/dist/config.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { AuthOptions } from "./auth.js";
1
2
  import type { RequestOptions } from "./http.js";
2
3
  export interface SlicetestOptions {
3
4
  app: AppOptions;
@@ -37,6 +38,18 @@ export interface SlicetestOptions {
37
38
  * `{{container.<name>.port}}`. `reset` runs inside it before every scenario.
38
39
  */
39
40
  containers?: Record<string, ContainerOptions>;
41
+ /**
42
+ * Catch the mail the app sends. An SMTP server (no TLS, any credentials)
43
+ * listens at `{{mail.host}}` / `{{mail.port}}` (`{{mail.url}}` is `smtp://host:port`);
44
+ * scenarios read what arrived with `mail.messages()` / `mail.waitFor()`.
45
+ */
46
+ mail?: boolean;
47
+ /**
48
+ * An OpenID Connect issuer for apps that verify JWTs. The app gets
49
+ * `{{auth.issuer}}`, `{{auth.jwks}}` and `{{auth.audience}}`; scenarios mint
50
+ * tokens with `auth.token({ sub, roles })`. `true`, or `{ audience, claims }`.
51
+ */
52
+ auth?: boolean | AuthOptions;
40
53
  }
41
54
  export interface ContainerOptions {
42
55
  image: string;
@@ -102,10 +115,12 @@ export interface AppOptions {
102
115
  }
103
116
  export interface DbOptions {
104
117
  /**
105
- * `postgres` (default) or `mysql`. Inferred from `url` when it starts with `mysql://`.
118
+ * `postgres` (default), `mysql` or `sqlite`. Inferred from `url` when it starts with `mysql://`.
106
119
  * MySQL needs the `mysql2` package, and `@testcontainers/mysql` unless `url` is given.
120
+ * SQLite needs Node.js 22.5+ and nothing else: no server, no container. The app gets
121
+ * `{{db.url}}` as `sqlite:///path/to/file.db` and `{{db.path}}` as the file path.
107
122
  */
108
- engine?: "postgres" | "mysql";
123
+ engine?: "postgres" | "mysql" | "sqlite";
109
124
  /** Image used when no `url` is given. Default `postgres:17-alpine`, or `mysql:8.4` for MySQL. */
110
125
  image?: string;
111
126
  /**
@@ -127,6 +142,12 @@ export interface DbOptions {
127
142
  * Default: on, except when `CI` is set or `url` is given.
128
143
  */
129
144
  reuse?: boolean;
145
+ /**
146
+ * Record the SQL the app runs: `{{db.url}}` points the app at a proxy that
147
+ * reads the wire protocol (Postgres and MySQL), so `db.queries()` lists every
148
+ * statement, whatever the app's language or driver. Off by default.
149
+ */
150
+ queries?: boolean;
130
151
  }
131
152
  export type MigrateOptions = {
132
153
  atlas: {
@@ -151,6 +172,8 @@ export interface ResolvedOptions {
151
172
  };
152
173
  services: Record<string, ResolvedProcess>;
153
174
  containers: Record<string, ContainerOptions>;
175
+ mail: boolean;
176
+ auth: AuthOptions | false;
154
177
  db: Required<Pick<DbOptions, "engine" | "image" | "schemas" | "keep" | "reuse">> & Omit<DbOptions, "engine" | "image" | "schemas" | "keep" | "reuse">;
155
178
  stubs: string[];
156
179
  /** Spec files, resolved against the root: the app's, and per stub name. */
package/dist/config.js CHANGED
@@ -5,6 +5,8 @@ export function resolveOptions(opts, root) {
5
5
  app: { ...opts.app, ready: resolveReady(opts.app.ready ?? { path: "/" }) },
6
6
  services: Object.fromEntries(Object.entries(opts.services ?? {}).map(([name, s]) => [name, { ...s, ready: s.ready && resolveReady(s.ready) }])),
7
7
  containers: opts.containers ?? {},
8
+ mail: opts.mail ?? false,
9
+ auth: opts.auth === true ? {} : (opts.auth ?? false),
8
10
  db: resolveDb(opts.db ?? {}),
9
11
  stubs: (opts.stubs ?? []).map(stubName),
10
12
  openapi: {
@@ -39,9 +41,9 @@ function resolveDb(db) {
39
41
  const env = process.env.SLICETEST_DATABASE_URL || undefined;
40
42
  const engine = db.engine ?? (isMysql(db.url ?? env ?? "") ? "mysql" : "postgres");
41
43
  // The environment variable names one server for the whole CI job; a project on the other engine starts its own.
42
- const url = db.url ?? (env && isMysql(env) === (engine === "mysql") ? env : undefined);
44
+ const url = engine === "sqlite" ? undefined : (db.url ?? (env && isMysql(env) === (engine === "mysql") ? env : undefined));
43
45
  return {
44
- image: engine === "mysql" ? "mysql:8.4" : "postgres:17-alpine",
46
+ image: engine === "mysql" ? "mysql:8.4" : engine === "sqlite" ? "" : "postgres:17-alpine",
45
47
  schemas: ["public"],
46
48
  keep: [],
47
49
  ...db,
@@ -84,9 +86,28 @@ function validate(opts) {
84
86
  fail(`containers.${name}.${key} must be a list of strings, e.g. ["redis-cli", "FLUSHALL"]`);
85
87
  }
86
88
  }
89
+ if (opts.mail !== undefined && typeof opts.mail !== "boolean")
90
+ fail(`mail must be true or false, got ${JSON.stringify(opts.mail)}`);
91
+ if (opts.auth !== undefined && typeof opts.auth !== "boolean") {
92
+ if (!opts.auth || typeof opts.auth !== "object" || Array.isArray(opts.auth))
93
+ fail(`auth must be true or { audience, claims }, got ${JSON.stringify(opts.auth)}`);
94
+ for (const key of Object.keys(opts.auth))
95
+ if (key !== "audience" && key !== "claims")
96
+ fail(`unknown key auth.${key} (expected audience, claims)`);
97
+ if (opts.auth.audience !== undefined && typeof opts.auth.audience !== "string")
98
+ fail("auth.audience must be a string");
99
+ if (opts.auth.claims !== undefined && (!opts.auth.claims || typeof opts.auth.claims !== "object" || Array.isArray(opts.auth.claims)))
100
+ fail("auth.claims must be a mapping of claim names to values");
101
+ }
87
102
  const engine = opts.db?.engine;
88
- if (engine !== undefined && engine !== "postgres" && engine !== "mysql")
89
- fail(`db.engine must be "postgres" or "mysql", got ${JSON.stringify(engine)}`);
103
+ if (engine !== undefined && engine !== "postgres" && engine !== "mysql" && engine !== "sqlite")
104
+ fail(`db.engine must be "postgres", "mysql" or "sqlite", got ${JSON.stringify(engine)}`);
105
+ if (opts.db?.queries !== undefined && typeof opts.db.queries !== "boolean")
106
+ fail(`db.queries must be true or false, got ${JSON.stringify(opts.db.queries)}`);
107
+ if (engine === "sqlite" && opts.db?.queries)
108
+ fail("db.queries needs a database server (postgres or mysql): an SQLite app opens the file directly, so there is no connection to read");
109
+ if (engine === "sqlite" && opts.db?.url)
110
+ 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}}");
90
111
  const migrate = opts.db?.migrate;
91
112
  if (migrate) {
92
113
  const keys = Object.keys(migrate).filter((k) => ["atlas", "sql", "command"].includes(k));
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
  /**
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) {
@@ -0,0 +1,22 @@
1
+ import { type ResolvedOptions } from "./config.js";
2
+ /**
3
+ * `slicetest doctor`: everything a run needs, checked up front, each problem
4
+ * with what to do about it. Meant for a first setup and for CI logs, where a
5
+ * container runtime that isn't there otherwise shows up as a timeout.
6
+ */
7
+ export interface Check {
8
+ status: "ok" | "warn" | "fail";
9
+ label: string;
10
+ /** What was found, or what to do. */
11
+ detail?: string;
12
+ }
13
+ /** Things that touch the machine, replaceable in tests. */
14
+ export interface Probes {
15
+ containerRuntime(): Promise<string>;
16
+ database(opts: ResolvedOptions, url: string): Promise<void>;
17
+ command(name: string, args: string[]): Promise<string>;
18
+ resolvePackage(name: string, from: string): boolean;
19
+ }
20
+ export declare const machine: Probes;
21
+ export declare function doctor(configPath: string | undefined, probes?: Probes, env?: NodeJS.ProcessEnv): Promise<Check[]>;
22
+ export declare function formatChecks(checks: Check[]): string;
package/dist/doctor.js ADDED
@@ -0,0 +1,190 @@
1
+ import { execFile } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { readFile, stat } from "node:fs/promises";
4
+ import { createRequire } from "node:module";
5
+ import path from "node:path";
6
+ import { promisify } from "node:util";
7
+ import { parse } from "yaml";
8
+ import { resolveOptions } from "./config.js";
9
+ import { configureContainerRuntime } from "./container-runtime.js";
10
+ import { engineFor } from "./drivers/index.js";
11
+ import { OpenApiSpec } from "./openapi.js";
12
+ const exec = promisify(execFile);
13
+ export const machine = {
14
+ async containerRuntime() {
15
+ configureContainerRuntime();
16
+ const { getContainerRuntimeClient } = await import("testcontainers");
17
+ const client = await withTimeout(getContainerRuntimeClient(), 15_000, "no answer from the container runtime");
18
+ const { containerRuntime: rt } = client.info;
19
+ return `${rt.operatingSystem} ${rt.serverVersion} at ${rt.host}`;
20
+ },
21
+ async database(opts, url) {
22
+ const admin = await withTimeout((await engineFor(opts)).admin(url), 10_000, "connection timed out");
23
+ await admin.close();
24
+ },
25
+ async command(name, args) {
26
+ const { stdout, stderr } = await exec(name, args, { windowsHide: true, timeout: 10_000, shell: process.platform === "win32" });
27
+ return (stdout || stderr).trim().split(/\r?\n/)[0];
28
+ },
29
+ resolvePackage(name, from) {
30
+ try {
31
+ createRequire(path.join(from, "noop.js")).resolve(`${name}/package.json`);
32
+ return true;
33
+ }
34
+ catch {
35
+ try {
36
+ createRequire(import.meta.url).resolve(name);
37
+ return true;
38
+ }
39
+ catch {
40
+ return false;
41
+ }
42
+ }
43
+ },
44
+ };
45
+ export async function doctor(configPath, probes = machine, env = process.env) {
46
+ const checks = [];
47
+ const add = (status, label, detail) => checks.push({ status, label, ...(detail ? { detail } : {}) });
48
+ const major = Number(process.versions.node.split(".")[0]);
49
+ add(major >= 20 ? "ok" : "fail", `Node.js ${process.versions.node}`, major >= 20 ? undefined : "slicetest needs Node.js 20 or later");
50
+ if (!configPath || !existsSync(configPath)) {
51
+ add("warn", "no slicetest.config.yaml", "checked the machine only. `npx slicetest init` writes a config; with the Vitest plugin, pass the same options to --config as YAML to check them");
52
+ await checkContainerRuntime(add, probes);
53
+ return checks;
54
+ }
55
+ const root = path.dirname(configPath);
56
+ const rel = (p) => path.relative(process.cwd(), path.resolve(root, p)) || ".";
57
+ let opts;
58
+ try {
59
+ const raw = (parse(await readFile(configPath, "utf8")) ?? {});
60
+ delete raw.include;
61
+ opts = resolveOptions(raw, root);
62
+ add("ok", `config ${rel(configPath)}`);
63
+ }
64
+ catch (e) {
65
+ add("fail", `config ${rel(configPath)}`, e.message.replace(/^slicetest: /, ""));
66
+ return checks;
67
+ }
68
+ // Database: a server that's there already, or a container runtime to start one in.
69
+ const url = opts.db.engine === "sqlite" ? undefined : (opts.db.url ?? env.SLICETEST_DATABASE_URL);
70
+ if (opts.db.engine === "sqlite") {
71
+ const [maj = 0, min = 0] = process.versions.node.split(".").map(Number);
72
+ if (maj > 22 || (maj === 22 && min >= 5))
73
+ add("ok", "sqlite (node:sqlite, no server needed)");
74
+ else
75
+ add("fail", "sqlite needs Node.js 22.5 or later", `this is ${process.versions.node}; slicetest uses the built-in node:sqlite`);
76
+ if (Object.keys(opts.containers).length)
77
+ await checkContainerRuntime(add, probes, `runs ${Object.values(opts.containers).map((c) => c.image).join(", ")}`);
78
+ }
79
+ else if (url) {
80
+ try {
81
+ await probes.database(opts, url);
82
+ add("ok", `${opts.db.engine} at ${redact(url)}`);
83
+ }
84
+ catch (e) {
85
+ add("fail", `${opts.db.engine} at ${redact(url)}`, `can't connect: ${e.message}. It must be a superuser (root) connection that can create databases`);
86
+ }
87
+ }
88
+ else {
89
+ await checkContainerRuntime(add, probes, `runs ${[opts.db.image, ...Object.values(opts.containers).map((c) => c.image)].join(", ")}`);
90
+ }
91
+ if (opts.db.engine === "mysql") {
92
+ for (const pkg of ["mysql2", ...(url ? [] : ["@testcontainers/mysql"])]) {
93
+ if (probes.resolvePackage(pkg, root))
94
+ add("ok", `${pkg} installed`);
95
+ else
96
+ add("fail", `${pkg} not installed`, `db.engine "mysql" needs it: npm i -D ${pkg}`);
97
+ }
98
+ }
99
+ const m = opts.db.migrate;
100
+ if (!m)
101
+ add("warn", "db.migrate not set", "scenarios run against an empty database unless the app creates its own tables");
102
+ else if ("atlas" in m) {
103
+ await checkPath(add, "migrations", m.atlas.dir.replace(/^file:\/\//, ""), root, rel);
104
+ try {
105
+ add("ok", `atlas CLI (${await probes.command("atlas", ["version"])})`);
106
+ }
107
+ catch {
108
+ add("fail", "atlas CLI not found", "db.migrate.atlas runs `atlas migrate apply`. Install it: https://atlasgo.io/getting-started");
109
+ }
110
+ }
111
+ else if ("sql" in m)
112
+ await checkPath(add, "migrations", m.sql, root, rel);
113
+ else
114
+ for (const input of m.inputs ?? [])
115
+ await checkPath(add, "migration input", input, root, rel);
116
+ if (opts.db.seed)
117
+ await checkPath(add, "seed", opts.db.seed, root, rel);
118
+ for (const [label, p] of [["app", opts.app], ...Object.entries(opts.services).map(([n, s]) => [`service ${n}`, s])]) {
119
+ if (p.cwd)
120
+ await checkPath(add, `${label} cwd`, p.cwd, root, rel);
121
+ const program = firstWord(p.command);
122
+ if (program && !/^[.\\/]|\{\{|\$/.test(program)) {
123
+ if (await onPath(program, env))
124
+ add("ok", `${label}: ${program} found`);
125
+ 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`);
127
+ }
128
+ }
129
+ const specs = [...(opts.openapi.app ? [["app OpenAPI", opts.openapi.app]] : []), ...Object.entries(opts.openapi.stubs).map(([n, f]) => [`stub ${n} OpenAPI`, f])];
130
+ for (const [label, file] of specs) {
131
+ try {
132
+ const spec = await OpenApiSpec.load(path.resolve(root, file), file);
133
+ add("ok", `${label} ${rel(file)}`, `${spec.operations().length} operation(s)`);
134
+ }
135
+ catch (e) {
136
+ add("fail", `${label} ${rel(file)}`, e.message.replace(/^slicetest: /, ""));
137
+ }
138
+ }
139
+ for (const [name, r] of Object.entries(opts.recordings)) {
140
+ if (existsSync(r.file))
141
+ add("ok", `stub ${name}: recordings ${rel(r.file)}`);
142
+ else
143
+ add("warn", `stub ${name}: no recordings yet (${rel(r.file)})`, `run once with SLICETEST_RECORD=${name} to record ${r.upstream}`);
144
+ }
145
+ return checks;
146
+ }
147
+ async function checkContainerRuntime(add, probes, forWhat) {
148
+ try {
149
+ add("ok", `container runtime: ${await probes.containerRuntime()}`, forWhat);
150
+ }
151
+ catch (e) {
152
+ add("fail", "no container runtime", `${e.message.split("\n")[0]}. Start Docker or a Podman machine (\`podman machine start\`), or set SLICETEST_DATABASE_URL / db.url to an existing database server`);
153
+ }
154
+ }
155
+ async function checkPath(add, label, p, root, rel) {
156
+ try {
157
+ await stat(path.resolve(root, p));
158
+ add("ok", `${label} ${rel(p)}`);
159
+ }
160
+ catch {
161
+ add("fail", `${label} ${rel(p)} not found`, `paths in the config are relative to the config file's directory (${rel(".")})`);
162
+ }
163
+ }
164
+ function firstWord(command) {
165
+ return /^\s*(?:"([^"]+)"|(\S+))/.exec(command)?.slice(1).find(Boolean);
166
+ }
167
+ async function onPath(program, env) {
168
+ const exts = process.platform === "win32" ? (env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""];
169
+ for (const dir of (env.PATH ?? env.Path ?? "").split(path.delimiter).filter(Boolean)) {
170
+ for (const ext of exts) {
171
+ if (existsSync(path.join(dir, program + ext)) || existsSync(path.join(dir, program)))
172
+ return true;
173
+ }
174
+ }
175
+ return false;
176
+ }
177
+ function redact(url) {
178
+ return url.replace(/\/\/([^:/@]+):[^@]*@/, "//$1:***@");
179
+ }
180
+ function withTimeout(p, ms, message) {
181
+ return Promise.race([p, new Promise((_, reject) => setTimeout(() => reject(new Error(message)), ms).unref())]);
182
+ }
183
+ export function formatChecks(checks) {
184
+ const icon = { ok: "✓", warn: "!", fail: "✗" };
185
+ const lines = checks.map((c) => ` ${icon[c.status]} ${c.label}${c.detail ? `\n ${c.detail}` : ""}`);
186
+ const fails = checks.filter((c) => c.status === "fail").length;
187
+ const warns = checks.filter((c) => c.status === "warn").length;
188
+ const summary = fails ? `${fails} problem(s) to fix before running.` : warns ? `Ready to run, with ${warns} warning(s).` : "Ready to run.";
189
+ return `${lines.join("\n")}\n\n${summary}\n`;
190
+ }
@@ -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. */
@@ -54,6 +81,8 @@ export interface Engine {
54
81
  name: string;
55
82
  /** Container image used when no `db.url` is given. */
56
83
  defaultImage: string;
84
+ /** Runs in-process (SQLite): no container runtime needed. */
85
+ local?: boolean;
57
86
  startContainer(image: string, reuse: boolean): Promise<{
58
87
  url: string;
59
88
  stop(): Promise<unknown>;
@@ -10,5 +10,7 @@ export async function engineFor(opts) {
10
10
  });
11
11
  return mod.mysqlEngine;
12
12
  }
13
+ if (opts.db.engine === "sqlite")
14
+ return (await import("./sqlite.js")).sqliteEngine;
13
15
  return postgres;
14
16
  }
@@ -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;
@@ -134,6 +134,44 @@ export class MysqlDriver {
134
134
  const conds = Object.keys(where).map((k) => `${this.column(k)} = ?`);
135
135
  return this.query(`SELECT * FROM ${this.ident(table)} WHERE ${conds.join(" AND ")}`, Object.values(where));
136
136
  }
137
+ async describe(table) {
138
+ const [schema, name] = table.includes(".") ? table.split(".", 2) : [null, table];
139
+ const columns = await this.query(`SELECT COLUMN_NAME AS name, COLUMN_TYPE AS type, IS_NULLABLE AS nullable, COLUMN_DEFAULT AS dflt, EXTRA AS extra,
140
+ CHARACTER_MAXIMUM_LENGTH AS max
141
+ FROM information_schema.COLUMNS
142
+ WHERE TABLE_SCHEMA = COALESCE(?, DATABASE()) AND TABLE_NAME = ?
143
+ ORDER BY ORDINAL_POSITION`, [schema, name]);
144
+ if (columns.length === 0)
145
+ throw new Error(`slicetest: there is no table "${table}"`);
146
+ const keys = await this.query(`SELECT CONSTRAINT_NAME AS id, COLUMN_NAME AS \`column\`, REFERENCED_TABLE_SCHEMA AS \`schema\`,
147
+ REFERENCED_TABLE_NAME AS \`table\`, REFERENCED_COLUMN_NAME AS ref, DATABASE() AS current
148
+ FROM information_schema.KEY_COLUMN_USAGE
149
+ WHERE TABLE_SCHEMA = COALESCE(?, DATABASE()) AND TABLE_NAME = ? AND REFERENCED_TABLE_NAME IS NOT NULL
150
+ ORDER BY CONSTRAINT_NAME, ORDINAL_POSITION`, [schema, name]);
151
+ const foreignKeys = new Map();
152
+ for (const k of keys) {
153
+ const fk = foreignKeys.get(k.id) ?? { columns: [], table: k.schema === k.current ? k.table : `${k.schema}.${k.table}`, references: [] };
154
+ fk.columns.push(k.column);
155
+ fk.references.push(k.ref);
156
+ foreignKeys.set(k.id, fk);
157
+ }
158
+ const checks = await this.query(`SELECT c.CHECK_CLAUSE AS def
159
+ FROM information_schema.CHECK_CONSTRAINTS c
160
+ JOIN information_schema.TABLE_CONSTRAINTS t ON t.CONSTRAINT_SCHEMA = c.CONSTRAINT_SCHEMA AND t.CONSTRAINT_NAME = c.CONSTRAINT_NAME
161
+ WHERE t.TABLE_SCHEMA = COALESCE(?, DATABASE()) AND t.TABLE_NAME = ?`, [schema, name]);
162
+ return {
163
+ columns: columns.map((c) => ({
164
+ name: c.name,
165
+ type: c.type,
166
+ nullable: c.nullable === "YES",
167
+ hasDefault: c.dflt !== null || /auto_increment|generated/i.test(c.extra),
168
+ maxLength: c.max === null ? undefined : Number(c.max),
169
+ values: /^enum\(/i.test(c.type) ? [...c.type.matchAll(/'((?:[^']|'')*)'/g)].map((m) => m[1].replace(/''/g, "'")) : undefined,
170
+ })),
171
+ foreignKeys: [...foreignKeys.values()],
172
+ checks: checks.map((c) => c.def),
173
+ };
174
+ }
137
175
  async #primaryKey(table) {
138
176
  let key = this.#keys.get(table);
139
177
  if (!key) {