slicetest 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -11,21 +11,33 @@ import { parseArgs } from "node:util";
11
11
  import { parse } from "yaml";
12
12
  const CONFIG_NAMES = ["slicetest.config.yaml", "slicetest.config.yml", "slicetest.config.json"];
13
13
  const HELP = `Usage: slicetest [filters...] [options]
14
+ slicetest init [--force]
15
+ slicetest gen [--spec <file>] [--out <dir>] [--uncovered] [--force]
14
16
 
15
17
  Runs *.scenario.yaml files against your app, as configured in slicetest.config.yaml.
18
+ \`slicetest init\` looks at the project and writes a starting config and scenario.
19
+ \`slicetest gen\` writes scenario skeletons for the documented responses of the
20
+ app's OpenAPI spec; with --uncovered, only for those the last run didn't produce.
16
21
 
17
22
  Options:
18
23
  -c, --config <file> Config file (default: ${CONFIG_NAMES.join(" / ")} in the current directory)
19
24
  -w, --watch Re-run on changes
20
25
  -t, --name <pattern> Only run scenarios whose name matches
26
+ --spec <file> gen: OpenAPI file (default: \`openapi\` from the config)
27
+ --out <dir> gen: where to write scenarios (default: scenarios)
28
+ --uncovered gen: only responses the last run didn't cover
29
+ --force init, gen: overwrite existing files
21
30
  -h, --help Show this help
22
31
 
23
32
  Config (paths are relative to the config file):
24
- app: { command, env, cwd, ready: { path } | { log }, readyTimeout }
25
- db: { migrate: { atlas: { dir } } | { sql } | { command }, seed, url, image, schemas, keep }
26
- stubs: [names]
27
- http: { headers, query }
28
- include: [globs] default ["**/*.scenario.{yaml,yml}"]
33
+ app: { command, env, cwd, ready: { path } | { log }, readyTimeout }
34
+ db: { migrate: { atlas: { dir } } | { sql } | { command, inputs }, engine, seed, url, image, schemas, keep, reuse }
35
+ stubs: [name | { name, openapi, autoReply, upstream, recordings }]
36
+ services: { name: { command, env, cwd, ready } }
37
+ containers: { name: { image, port, env, command, ready: { log }, reset } }
38
+ openapi: file | { spec, minCoverage }
39
+ http: { headers, query }
40
+ include: [globs] default ["**/*.scenario.{yaml,yml}"]
29
41
  `;
30
42
  export async function main(argv = process.argv.slice(2)) {
31
43
  const { values, positionals } = parseArgs({
@@ -36,17 +48,43 @@ export async function main(argv = process.argv.slice(2)) {
36
48
  watch: { type: "boolean", short: "w" },
37
49
  name: { type: "string", short: "t" },
38
50
  help: { type: "boolean", short: "h" },
51
+ force: { type: "boolean" },
52
+ spec: { type: "string" },
53
+ out: { type: "string" },
54
+ uncovered: { type: "boolean" },
39
55
  },
40
56
  });
41
57
  if (values.help) {
42
58
  process.stdout.write(HELP);
43
59
  return;
44
60
  }
61
+ if (positionals[0] === "init") {
62
+ const { init } = await import("./init.js");
63
+ const { files, notes } = await init(process.cwd(), { force: values.force });
64
+ process.stdout.write(`Created ${files.join(" and ")}.\n\nWhat was detected (check these in the config):\n${notes.map((n) => ` - ${n}`).join("\n")}\n\nNext: npx slicetest\n`);
65
+ return;
66
+ }
45
67
  const configPath = values.config
46
68
  ? path.resolve(values.config)
47
69
  : CONFIG_NAMES.map((n) => path.resolve(n)).find((p) => existsSync(p));
70
+ if (positionals[0] === "gen") {
71
+ const configOpenapi = configPath && existsSync(configPath) ? (parse(await readFile(configPath, "utf8")) ?? {}).openapi : undefined;
72
+ const spec = values.spec ?? (typeof configOpenapi === "object" ? configOpenapi.spec : configOpenapi);
73
+ if (!spec)
74
+ throw new Error("slicetest gen: no OpenAPI spec. Pass --spec openapi.yaml, or set `openapi` in the config.");
75
+ const root = values.spec || !configPath ? process.cwd() : path.dirname(configPath);
76
+ const { gen } = await import("./gen.js");
77
+ const { written, skipped, count } = await gen(root, { spec, out: values.out, uncovered: values.uncovered, force: values.force });
78
+ const lines = [
79
+ count === 0 ? "Every documented response is already covered; nothing to generate." : `${count} scenario(s) for the responses in ${spec}.`,
80
+ ...written.map((f) => ` wrote ${f}`),
81
+ ...skipped.map((f) => ` skipped ${f} (exists; --force to overwrite)`),
82
+ ];
83
+ process.stdout.write(`${lines.join("\n")}\n`);
84
+ return;
85
+ }
48
86
  if (!configPath || !existsSync(configPath)) {
49
- process.stderr.write(`slicetest: no config found. Create ${CONFIG_NAMES[0]} (see --help).\n`);
87
+ process.stderr.write(`slicetest: no config found. Run "npx slicetest init" to create ${CONFIG_NAMES[0]} (see --help).\n`);
50
88
  process.exitCode = 1;
51
89
  return;
52
90
  }
package/dist/config.d.ts CHANGED
@@ -2,11 +2,85 @@ import type { RequestOptions } from "./http.js";
2
2
  export interface SlicetestOptions {
3
3
  app: AppOptions;
4
4
  db?: DbOptions;
5
- /** Names of outbound HTTP services to stub. Each gets its own server, referenced as `{{stub.<name>}}` in `app.env`. */
6
- stubs?: string[];
5
+ /**
6
+ * Outbound HTTP services to stub. Each gets its own server, referenced as `{{stub.<name>}}` in `app.env`.
7
+ * With `{ name, openapi }`, the app's calls to it and the stub's replies are checked against that service's spec.
8
+ * With `autoReply: true` as well, calls no route matches are answered from the spec (its examples, or values
9
+ * built from its schemas) instead of failing, so you only register the routes a scenario cares about.
10
+ */
11
+ stubs?: (string | StubOptions)[];
12
+ /**
13
+ * The app's own OpenAPI 3 spec (YAML or JSON, relative to the root). Every
14
+ * response the app gives during a scenario must be documented and match its
15
+ * schema, or the scenario fails. At the end of the run, slicetest prints which
16
+ * documented responses the scenarios produced; with `minCoverage` (percent),
17
+ * a lower coverage fails the run.
18
+ */
19
+ openapi?: string | {
20
+ spec: string;
21
+ minCoverage?: number;
22
+ };
7
23
  /** Defaults for every request made with `http`, e.g. `{ headers: { accept: "application/json" } }`. */
8
24
  http?: RequestOptions;
25
+ /**
26
+ * Other processes the app needs: a queue worker, another microservice, a
27
+ * mock written in another language. They start before the app, in order, and
28
+ * are watched like the app: a crash fails the scenario and they're restarted.
29
+ * Their URL is `{{service.<name>}}` and their port `{{service.<name>.port}}`,
30
+ * usable in `app.env` and in the env of services declared after them.
31
+ */
32
+ services?: Record<string, ServiceOptions>;
33
+ /**
34
+ * Containers the app depends on besides the database: Redis, Elasticsearch,
35
+ * MinIO, LocalStack. Each test file gets its own, reachable at
36
+ * `{{container.<name>}}` (`host:port`), `{{container.<name>.host}}` and
37
+ * `{{container.<name>.port}}`. `reset` runs inside it before every scenario.
38
+ */
39
+ containers?: Record<string, ContainerOptions>;
9
40
  }
41
+ export interface ContainerOptions {
42
+ image: string;
43
+ /** The port the service listens on inside the container. */
44
+ port: number;
45
+ env?: Record<string, string>;
46
+ command?: string[];
47
+ /** Wait for this log line instead of the port accepting connections. */
48
+ ready?: {
49
+ log: string;
50
+ };
51
+ /** Command run inside the container before each scenario, e.g. `["redis-cli", "FLUSHALL"]`. */
52
+ reset?: string[];
53
+ }
54
+ export interface StubOptions {
55
+ name: string;
56
+ /** The provider's OpenAPI spec: the app's calls and the stub's replies are checked against it. */
57
+ openapi?: string;
58
+ /** Answer calls no route matches from the spec's examples or schemas. Needs `openapi`. */
59
+ autoReply?: boolean;
60
+ /**
61
+ * The real service's base URL, e.g. `https://api.github.com`. Calls no route
62
+ * matches are answered from `recordings`; run with `SLICETEST_RECORD=<name>`
63
+ * (or `=1` for every stub) to forward the ones without a recording to this
64
+ * URL and record the answers.
65
+ */
66
+ upstream?: string;
67
+ /** Recordings file, relative to the root. Default `recordings/<name>.yaml`. */
68
+ recordings?: string;
69
+ }
70
+ export interface ServiceOptions extends Omit<AppOptions, "ready"> {
71
+ /** Default: no wait (for workers that don't listen). `{ path }` polls the service's own port. */
72
+ ready?: AppOptions["ready"];
73
+ }
74
+ type ResolvedReady = {
75
+ path: string;
76
+ } | {
77
+ log: string;
78
+ flags: string;
79
+ };
80
+ /** A process to start, with `ready` made JSON-serializable. The app always has `ready`; services may not. */
81
+ export type ResolvedProcess = Omit<AppOptions, "ready"> & {
82
+ ready?: ResolvedReady;
83
+ };
10
84
  export interface AppOptions {
11
85
  /** Command that starts the app, run through the shell. */
12
86
  command: string;
@@ -27,10 +101,15 @@ export interface AppOptions {
27
101
  readyTimeout?: number;
28
102
  }
29
103
  export interface DbOptions {
30
- /** Postgres image used when no `url` is given. Default `postgres:17-alpine`. */
104
+ /**
105
+ * `postgres` (default) or `mysql`. Inferred from `url` when it starts with `mysql://`.
106
+ * MySQL needs the `mysql2` package, and `@testcontainers/mysql` unless `url` is given.
107
+ */
108
+ engine?: "postgres" | "mysql";
109
+ /** Image used when no `url` is given. Default `postgres:17-alpine`, or `mysql:8.4` for MySQL. */
31
110
  image?: string;
32
111
  /**
33
- * Use an existing Postgres server instead of starting a container. Must point at a superuser-capable database.
112
+ * Use an existing server instead of starting a container. Must point at a superuser (root) connection.
34
113
  * Defaults to the `SLICETEST_DATABASE_URL` environment variable, which is handy in CI.
35
114
  */
36
115
  url?: string;
@@ -41,6 +120,13 @@ export interface DbOptions {
41
120
  schemas?: string[];
42
121
  /** Extra tables kept across resets, in addition to known migration bookkeeping tables. */
43
122
  keep?: string[];
123
+ /**
124
+ * Keep the Postgres container running between runs and cache the migrated
125
+ * template by the contents of the migrations, so a run with unchanged
126
+ * migrations skips both container start-up and migrating.
127
+ * Default: on, except when `CI` is set or `url` is given.
128
+ */
129
+ reuse?: boolean;
44
130
  }
45
131
  export type MigrateOptions = {
46
132
  atlas: {
@@ -50,20 +136,37 @@ export type MigrateOptions = {
50
136
  sql: string;
51
137
  } | {
52
138
  command: string;
139
+ /**
140
+ * Files or directories the command reads (e.g. `["prisma/migrations"]`).
141
+ * With `reuse`, the migrated template is cached until one of them changes;
142
+ * without `inputs`, the command runs on every run.
143
+ */
144
+ inputs?: string[];
53
145
  };
54
146
  /** Normalized shape passed from the plugin to globalSetup and workers. Must stay JSON-serializable. */
55
147
  export interface ResolvedOptions {
56
148
  root: string;
57
149
  app: Omit<AppOptions, "ready"> & {
58
- ready: {
59
- path: string;
60
- } | {
61
- log: string;
62
- flags: string;
63
- };
150
+ ready: ResolvedReady;
64
151
  };
65
- db: Required<Pick<DbOptions, "image" | "schemas" | "keep">> & Omit<DbOptions, "image" | "schemas" | "keep">;
152
+ services: Record<string, ResolvedProcess>;
153
+ containers: Record<string, ContainerOptions>;
154
+ db: Required<Pick<DbOptions, "engine" | "image" | "schemas" | "keep" | "reuse">> & Omit<DbOptions, "engine" | "image" | "schemas" | "keep" | "reuse">;
66
155
  stubs: string[];
156
+ /** Spec files, resolved against the root: the app's, and per stub name. */
157
+ openapi: {
158
+ app?: string;
159
+ minCoverage?: number;
160
+ stubs: Record<string, string>;
161
+ autoReply: string[];
162
+ };
163
+ /** Stubs backed by recordings of a real service; `record` when SLICETEST_RECORD selects them. */
164
+ recordings: Record<string, {
165
+ file: string;
166
+ upstream: string;
167
+ record: boolean;
168
+ }>;
67
169
  http?: RequestOptions;
68
170
  }
69
171
  export declare function resolveOptions(opts: SlicetestOptions, root: string): ResolvedOptions;
172
+ export {};
package/dist/config.js CHANGED
@@ -1,27 +1,55 @@
1
1
  export function resolveOptions(opts, root) {
2
2
  validate(opts);
3
- const ready = opts.app.ready ?? { path: "/" };
4
3
  return {
5
4
  root,
6
- app: {
7
- ...opts.app,
8
- ready: "log" in ready
9
- ? typeof ready.log === "string"
10
- ? { log: escapeRegExp(ready.log), flags: "" }
11
- : { log: ready.log.source, flags: ready.log.flags }
12
- : ready,
5
+ app: { ...opts.app, ready: resolveReady(opts.app.ready ?? { path: "/" }) },
6
+ services: Object.fromEntries(Object.entries(opts.services ?? {}).map(([name, s]) => [name, { ...s, ready: s.ready && resolveReady(s.ready) }])),
7
+ containers: opts.containers ?? {},
8
+ db: resolveDb(opts.db ?? {}),
9
+ stubs: (opts.stubs ?? []).map(stubName),
10
+ openapi: {
11
+ app: typeof opts.openapi === "object" ? opts.openapi.spec : opts.openapi,
12
+ minCoverage: typeof opts.openapi === "object" ? opts.openapi.minCoverage : undefined,
13
+ stubs: Object.fromEntries((opts.stubs ?? []).flatMap((s) => (typeof s === "object" && s.openapi ? [[s.name, s.openapi]] : []))),
14
+ autoReply: (opts.stubs ?? []).flatMap((s) => (typeof s === "object" && s.autoReply ? [s.name] : [])),
13
15
  },
14
- db: {
15
- image: "postgres:17-alpine",
16
- schemas: ["public"],
17
- keep: [],
18
- ...opts.db,
19
- url: opts.db?.url ?? (process.env.SLICETEST_DATABASE_URL || undefined),
20
- },
21
- stubs: opts.stubs ?? [],
16
+ recordings: resolveRecordings(opts.stubs ?? []),
22
17
  http: opts.http,
23
18
  };
24
19
  }
20
+ function resolveRecordings(stubs) {
21
+ const withUpstream = stubs.filter((s) => typeof s === "object" && !!s.upstream);
22
+ const env = (process.env.SLICETEST_RECORD ?? "").trim();
23
+ const all = ["1", "true", "all", "*"].includes(env.toLowerCase());
24
+ const names = all || !env ? [] : env.split(",").map((n) => n.trim()).filter(Boolean);
25
+ for (const n of names) {
26
+ if (!withUpstream.some((s) => s.name === n)) {
27
+ throw new Error(`slicetest: SLICETEST_RECORD names "${n}", but no stub of that name has an upstream. Stubs with one: ${withUpstream.map((s) => s.name).join(", ") || "(none)"}`);
28
+ }
29
+ }
30
+ return Object.fromEntries(withUpstream.map((s) => [s.name, { file: s.recordings ?? `recordings/${s.name}.yaml`, upstream: s.upstream, record: all || names.includes(s.name) }]));
31
+ }
32
+ function resolveReady(ready) {
33
+ if (!("log" in ready))
34
+ return ready;
35
+ return typeof ready.log === "string" ? { log: escapeRegExp(ready.log), flags: "" } : { log: ready.log.source, flags: ready.log.flags };
36
+ }
37
+ function resolveDb(db) {
38
+ const isMysql = (u) => /^mysql:/i.test(u);
39
+ const env = process.env.SLICETEST_DATABASE_URL || undefined;
40
+ const engine = db.engine ?? (isMysql(db.url ?? env ?? "") ? "mysql" : "postgres");
41
+ // 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);
43
+ return {
44
+ image: engine === "mysql" ? "mysql:8.4" : "postgres:17-alpine",
45
+ schemas: ["public"],
46
+ keep: [],
47
+ ...db,
48
+ engine,
49
+ url,
50
+ reuse: db.reuse ?? (!url && !process.env.CI),
51
+ };
52
+ }
25
53
  function validate(opts) {
26
54
  const fail = (msg) => {
27
55
  throw new Error(`slicetest: invalid config: ${msg}`);
@@ -29,18 +57,62 @@ function validate(opts) {
29
57
  if (!opts?.app || typeof opts.app.command !== "string" || !opts.app.command.trim()) {
30
58
  fail("app.command is required, e.g. { app: { command: \"node server.js\" } }");
31
59
  }
32
- const ready = opts.app.ready;
33
- if (ready !== undefined && !("path" in ready) && !("log" in ready))
34
- fail("app.ready must be { path } or { log }");
35
- if (ready && "path" in ready && !ready.path.startsWith("/"))
36
- fail(`app.ready.path must start with "/", got "${ready.path}"`);
60
+ const checkReady = (ready, where) => {
61
+ if (ready !== undefined && !("path" in ready) && !("log" in ready))
62
+ fail(`${where}.ready must be { path } or { log }`);
63
+ if (ready && "path" in ready && !ready.path.startsWith("/"))
64
+ fail(`${where}.ready.path must start with "/", got "${ready.path}"`);
65
+ };
66
+ checkReady(opts.app.ready, "app");
67
+ for (const [name, s] of Object.entries(opts.services ?? {})) {
68
+ if (!/^[\w-]+$/.test(name))
69
+ fail(`service name "${name}" may only contain letters, digits, "_" and "-"`);
70
+ if (!s || typeof s.command !== "string" || !s.command.trim())
71
+ fail(`services.${name}.command is required`);
72
+ checkReady(s.ready, `services.${name}`);
73
+ }
74
+ for (const [name, c] of Object.entries(opts.containers ?? {})) {
75
+ if (!/^[\w-]+$/.test(name))
76
+ fail(`container name "${name}" may only contain letters, digits, "_" and "-"`);
77
+ if (!c || typeof c.image !== "string" || !c.image)
78
+ fail(`containers.${name}.image is required, e.g. "redis:7-alpine"`);
79
+ if (!Number.isInteger(c.port) || c.port <= 0)
80
+ fail(`containers.${name}.port must be the port the service listens on inside the container, e.g. 6379`);
81
+ for (const key of ["command", "reset"]) {
82
+ const v = c[key];
83
+ if (v !== undefined && !(Array.isArray(v) && v.length > 0 && v.every((x) => typeof x === "string")))
84
+ fail(`containers.${name}.${key} must be a list of strings, e.g. ["redis-cli", "FLUSHALL"]`);
85
+ }
86
+ }
87
+ 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)}`);
37
90
  const migrate = opts.db?.migrate;
38
91
  if (migrate) {
39
92
  const keys = Object.keys(migrate).filter((k) => ["atlas", "sql", "command"].includes(k));
40
93
  if (keys.length !== 1)
41
94
  fail(`db.migrate takes exactly one of atlas / sql / command, got ${keys.join(", ") || "none"}`);
42
95
  }
43
- const stubs = opts.stubs ?? [];
96
+ const oas = opts.openapi;
97
+ if (oas !== undefined) {
98
+ const spec = typeof oas === "object" && oas ? oas.spec : oas;
99
+ if (typeof spec !== "string" || !spec)
100
+ fail('openapi must be the path of an OpenAPI file, or { spec, minCoverage }');
101
+ const min = typeof oas === "object" ? oas.minCoverage : undefined;
102
+ if (min !== undefined && !(typeof min === "number" && min >= 0 && min <= 100))
103
+ fail("openapi.minCoverage must be a percentage between 0 and 100");
104
+ }
105
+ for (const s of opts.stubs ?? []) {
106
+ if (typeof s !== "string" && (!s || typeof s.name !== "string"))
107
+ fail(`each stub must be a name or { name, openapi }, got ${JSON.stringify(s)}`);
108
+ if (typeof s === "object" && s.autoReply && !s.openapi)
109
+ fail(`stub "${s.name}": autoReply needs an openapi spec to answer from`);
110
+ if (typeof s === "object" && s.upstream !== undefined && !/^https?:\/\/[^/]/.test(s.upstream))
111
+ fail(`stub "${s.name}": upstream must be an http(s) URL, got ${JSON.stringify(s.upstream)}`);
112
+ if (typeof s === "object" && s.recordings !== undefined && !s.upstream)
113
+ fail(`stub "${s.name}": recordings needs an upstream to record from`);
114
+ }
115
+ const stubs = (opts.stubs ?? []).map(stubName);
44
116
  for (const name of stubs) {
45
117
  if (!/^[\w-]+$/.test(name))
46
118
  fail(`stub name "${name}" may only contain letters, digits, "_" and "-"`);
@@ -49,6 +121,9 @@ function validate(opts) {
49
121
  if (dup)
50
122
  fail(`stub "${dup}" is declared twice`);
51
123
  }
124
+ function stubName(s) {
125
+ return typeof s === "string" ? s : s.name;
126
+ }
52
127
  function escapeRegExp(s) {
53
128
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
54
129
  }
@@ -0,0 +1,22 @@
1
+ import type { ContainerOptions } from "./config.js";
2
+ /**
3
+ * A container the app depends on (Redis, a search engine, an S3 emulator),
4
+ * started for one test file and emptied between scenarios with `reset`.
5
+ */
6
+ export declare class Dependency {
7
+ readonly name: string;
8
+ private readonly container;
9
+ private readonly opts;
10
+ private constructor();
11
+ static start(name: string, opts: ContainerOptions): Promise<Dependency>;
12
+ get host(): string;
13
+ /** The host port mapped to the container's `port`. */
14
+ get port(): number;
15
+ /** `host:port`, to put after a scheme: `redis://{{container.cache}}`. */
16
+ get address(): string;
17
+ /** Run a command inside the container; throws with its output unless it exits with 0. */
18
+ exec(command: string[]): Promise<string>;
19
+ /** Start of a scenario: run the `reset` command, if any. */
20
+ reset(): Promise<void>;
21
+ stop(): Promise<void>;
22
+ }
@@ -0,0 +1,59 @@
1
+ import { configureContainerRuntime } from "./container-runtime.js";
2
+ /**
3
+ * A container the app depends on (Redis, a search engine, an S3 emulator),
4
+ * started for one test file and emptied between scenarios with `reset`.
5
+ */
6
+ export class Dependency {
7
+ name;
8
+ container;
9
+ opts;
10
+ constructor(name, container, opts) {
11
+ this.name = name;
12
+ this.container = container;
13
+ this.opts = opts;
14
+ }
15
+ static async start(name, opts) {
16
+ configureContainerRuntime();
17
+ const { GenericContainer, Wait } = await import("testcontainers");
18
+ let definition = new GenericContainer(opts.image).withExposedPorts(opts.port);
19
+ if (opts.env)
20
+ definition = definition.withEnvironment(opts.env);
21
+ if (opts.command)
22
+ definition = definition.withCommand(opts.command);
23
+ if (opts.ready)
24
+ definition = definition.withWaitStrategy(Wait.forLogMessage(opts.ready.log));
25
+ try {
26
+ return new Dependency(name, await definition.start(), opts);
27
+ }
28
+ catch (e) {
29
+ throw new Error(`slicetest: container "${name}" (${opts.image}) didn't start: ${e.message}`);
30
+ }
31
+ }
32
+ get host() {
33
+ return this.container.getHost();
34
+ }
35
+ /** The host port mapped to the container's `port`. */
36
+ get port() {
37
+ return this.container.getMappedPort(this.opts.port);
38
+ }
39
+ /** `host:port`, to put after a scheme: `redis://{{container.cache}}`. */
40
+ get address() {
41
+ return `${this.host}:${this.port}`;
42
+ }
43
+ /** Run a command inside the container; throws with its output unless it exits with 0. */
44
+ async exec(command) {
45
+ const res = await this.container.exec(command);
46
+ if (res.exitCode !== 0) {
47
+ throw new Error(`slicetest: \`${command.join(" ")}\` in container "${this.name}" exited with ${res.exitCode}:\n${res.output.trim()}`);
48
+ }
49
+ return res.stdout;
50
+ }
51
+ /** Start of a scenario: run the `reset` command, if any. */
52
+ async reset() {
53
+ if (this.opts.reset)
54
+ await this.exec(this.opts.reset);
55
+ }
56
+ async stop() {
57
+ await this.container.stop();
58
+ }
59
+ }
package/dist/db.d.ts CHANGED
@@ -1,7 +1,5 @@
1
- export declare function withDatabase(url: string, database: string): string;
2
- export interface Row {
3
- [column: string]: unknown;
4
- }
1
+ import type { Driver, Row } from "./drivers/driver.js";
2
+ export type { Row } from "./drivers/driver.js";
5
3
  /**
6
4
  * Column filters. `null` means IS NULL and an array means IN (...);
7
5
  * every other value is compared with `=`.
@@ -12,13 +10,30 @@ export interface RowsOptions {
12
10
  orderBy?: string | string[];
13
11
  limit?: number;
14
12
  }
13
+ /** Rows added, changed and removed in one table. */
14
+ export interface TableChanges<T extends Row = Row> {
15
+ inserted: T[];
16
+ /**
17
+ * Matched by primary key (`key`); `changed` lists the columns that differ.
18
+ * Tables without a primary key only report inserts and deletes.
19
+ */
20
+ updated: {
21
+ key: Row;
22
+ before: T;
23
+ after: T;
24
+ changed: string[];
25
+ }[];
26
+ deleted: T[];
27
+ }
28
+ /** Changed tables only, keyed by table name (`schema.table` outside `public`). */
29
+ export type Changes = Record<string, TableChanges>;
15
30
  /** Test-side handle to the database the app under test is using. */
16
31
  export declare class Db {
17
32
  #private;
18
33
  readonly url: string;
19
34
  private readonly opts;
20
35
  private constructor();
21
- static connect(url: string, opts: {
36
+ static connect(driver: Driver, url: string, opts: {
22
37
  schemas: string[];
23
38
  keep: string[];
24
39
  seedFile?: string;
@@ -35,5 +50,22 @@ export declare class Db {
35
50
  insert<T extends Row = Row>(table: string, rows: Row | Row[]): Promise<T[]>;
36
51
  /** Empty every data table without dropping the app's connections, then re-apply the seed. */
37
52
  reset(): Promise<void>;
53
+ /**
54
+ * What changed in the database since the scenario started (after the seed),
55
+ * or since the last `checkpoint()`: inserted, updated and deleted rows per table.
56
+ *
57
+ * ```ts
58
+ * await db.checkpoint(); // ignore the rows the test arranged
59
+ * await http.post("/polls", { ... });
60
+ * expect(await db.changes()).toEqual({ polls: { inserted: [expect.objectContaining({ title: "x" })], updated: [], deleted: [] } });
61
+ * ```
62
+ */
63
+ changes(): Promise<Changes>;
64
+ /** Make `changes()` report only what happens from now on. */
65
+ checkpoint(): Promise<void>;
66
+ /** Changes since the scenario started, regardless of checkpoints. Used for failure output. */
67
+ changesSinceStart(): Promise<Changes>;
38
68
  close(): Promise<void>;
39
69
  }
70
+ /** Short summary of `changes()` for failure output. */
71
+ export declare function formatChanges(changes: Changes, maxRows?: number): string;