slicetest 0.2.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/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  Tests that sit between unit tests and end-to-end tests, for apps written in any language or framework.
6
6
 
7
- slicetest starts your app as a real process, points it at a real Postgres and at stub servers for the services it calls, and lets you check all three sides in one scenario:
7
+ slicetest starts your app as a real process, points it at a real Postgres (or MySQL) and at stub servers for the services it calls, and lets you check all three sides in one scenario:
8
8
 
9
9
  ```ts
10
10
  import { expect } from "vitest";
@@ -38,14 +38,17 @@ npx slicetest init # detects your stack, writes slicetest.config.yaml and a fi
38
38
  npx slicetest # starts Postgres, migrates, starts your app, runs scenarios/*.scenario.yaml
39
39
  ```
40
40
 
41
- `init` recognises Node (`npm start`), Django, FastAPI, Flask, Rails, Go and Rust apps; Atlas, Prisma, Alembic, Django, Rails, Drizzle, Knex and plain SQL migrations; and an `openapi.yaml`. It lists every guess as a comment in the config so you know what to check.
41
+ `init` recognises Node (`npm start`), Django, FastAPI, Flask, Rails, Go and Rust apps; Atlas, Prisma, Alembic, Django, Rails, Drizzle, Knex and plain SQL migrations; and an `openapi.yaml`. If there's a `compose.yaml` / `docker-compose.yml`, its database service sets `db.image` (and `db.engine: mysql` for MySQL or MariaDB), and Redis, Valkey, Mongo, Elasticsearch, MinIO, RabbitMQ and other services with a port become [`containers`](#containers-redis-search-s3-and-other-dependencies), with a reset command where one is known and the usual variable (`REDIS_URL`, `S3_ENDPOINT`, …) passed to the app. It lists every guess as a comment in the config so you know what to check.
42
42
 
43
43
  ## What you get that's hard to find elsewhere
44
44
 
45
45
  - **One scenario, three boundaries.** Assert on the HTTP response, the rows in the real database and the calls to third-party APIs in the same test, in any language the app is written in.
46
46
  - **`db.changes()`**: a diff of every row the scenario inserted, updated or deleted. `toEqual` on it catches writes you didn't expect.
47
47
  - **Stubs that can't lie.** Give a stub the provider's OpenAPI spec, and a canned reply the real service would never send fails the test.
48
- - **OpenAPI coverage** of your own API, per operation and status, across all scenarios.
48
+ - **Whole-scenario snapshots.** `expect(await trace()).toMatchSnapshot()` pins the responses, the outbound calls and the database changes in one reviewable file, with dates and UUIDs masked.
49
+ - **Record the real service once, replay forever.** Point a stub at the real API with `SLICETEST_RECORD=1`, commit the YAML it writes, and later runs are offline and deterministic.
50
+ - **OpenAPI coverage** of your own API, per operation and status, across all scenarios, and `slicetest gen --uncovered` to scaffold scenarios for what's missing.
51
+ - **Postgres or MySQL**, with the same scenarios and the same row types on both, plus Redis, MinIO or any other `containers` reset between scenarios.
49
52
  - **Fast resets.** `TRUNCATE` between scenarios (about 1.5 ms) with the app still running, and a cached migrated template, so the second run skips container start-up and migrations.
50
53
 
51
54
  ## Install
@@ -56,6 +59,16 @@ npm i -D slicetest vitest
56
59
 
57
60
  You also need Docker or Podman. slicetest finds a running Podman machine on its own (on Windows too). Alternatively, pass `db.url` or set `SLICETEST_DATABASE_URL` to use an existing Postgres server, for example a CI service container.
58
61
 
62
+ ### MySQL
63
+
64
+ Set `db: { engine: "mysql" }` (or give a `mysql://` URL) and install the driver:
65
+
66
+ ```sh
67
+ npm i -D mysql2 @testcontainers/mysql # the second is only needed without db.url
68
+ ```
69
+
70
+ Everything works the same: `mysql:8.4` in a container, a migrated template cloned per worker (tables, foreign keys, views and triggers; stored routines are not copied), a `TRUNCATE` reset that only touches tables that were written to, and `db.*` helpers whose rows look like Postgres's (`BOOLEAN` as `true`/`false`, `BIGINT` ids as numbers, `DATETIME` in UTC). Only the SQL you write yourself differs: `?` placeholders in `db.query` and YAML `sql` steps. `db.schemas` defaults to the database in the URL. `SLICETEST_DATABASE_URL` is only used by projects on the same engine as its scheme, so a CI job can provide one Postgres server while a MySQL project starts its own container.
71
+
59
72
  Works on macOS, Linux and Windows. On Windows the app's process tree is stopped with `taskkill /T`, and `app.command` / `db.migrate.command` run through `cmd.exe`.
60
73
 
61
74
  ## Configure
@@ -246,6 +259,24 @@ To fail the run below a threshold, use `openapi: { spec: "openapi.yaml", minCove
246
259
 
247
260
  The example apps in `examples/` run every scenario against `examples/openapi.yaml` with `minCoverage: 100`, and their Slack calls against `examples/slack.openapi.yaml`.
248
261
 
262
+ ### Recording a real service
263
+
264
+ A stub can also answer from recordings of the real service, the way VCR or Polly do, except that it works for an app in any language because the stub is a server. Give it the real base URL:
265
+
266
+ ```ts
267
+ stubs: [{ name: "github", upstream: "https://api.github.com" }],
268
+ ```
269
+
270
+ Record once, with real credentials in the app's environment:
271
+
272
+ ```sh
273
+ SLICETEST_RECORD=github npx vitest # or SLICETEST_RECORD=1 for every stub with an upstream
274
+ ```
275
+
276
+ Calls no route matches are forwarded to `upstream` (under its path prefix, headers included) and the answers are written to `recordings/github.yaml` (`recordings:` changes the path). Later runs replay them without touching the network. A request is identified by method, path, query and body (JSON key order doesn't matter); identical requests replay their recordings in the order they were made. Only `content-type`, `location`, `retry-after`, `link` and `etag` response headers are kept, and request headers are never stored, so tokens stay out of the file; bodies are stored as sent, so review the file before committing it.
277
+
278
+ Precedence is: registered route, then recording, then `autoReply`, then a 501 that says how to record the call. Replayed calls have `call.fallback === true`, and are checked against the provider's spec when the stub has one. To refresh recordings, delete the file (or the entries) and record again.
279
+
249
280
  ### Matchers
250
281
 
251
282
  Registered automatically:
@@ -291,6 +322,22 @@ scenario("uploading an image queues a thumbnail job", async ({ http, db, service
291
322
 
292
323
  `waitForLog(pattern, timeout = 5000)` resolves with the matching line and also works on `app`. In YAML: `- log: thumbnail \d+ done` with `from: worker` and `within: <ms>`.
293
324
 
325
+ ### Containers: Redis, search, S3 and other dependencies
326
+
327
+ Anything else the app talks to can run as a container next to the database. Each test file gets its own, and `reset` runs inside it before every scenario, like the database's `TRUNCATE`:
328
+
329
+ ```ts
330
+ slicetest({
331
+ containers: {
332
+ cache: { image: "redis:7-alpine", port: 6379, reset: ["redis-cli", "FLUSHALL"] },
333
+ s3: { image: "minio/minio", port: 9000, command: ["server", "/data"], env: { MINIO_ROOT_USER: "test", MINIO_ROOT_PASSWORD: "testtest" } },
334
+ },
335
+ app: { command: "node server.js", env: { REDIS_URL: "redis://{{container.cache}}", S3_ENDPOINT: "http://{{container.s3}}" } },
336
+ });
337
+ ```
338
+
339
+ `{{container.<name>}}` is `host:port`; `.host` and `.port` are there too. The container is ready when its port accepts connections, or when it prints `ready: { log }`. In a scenario, `container("cache").exec(["redis-cli", "GET", "hits"])` runs a command inside it and returns its stdout.
340
+
294
341
  ### Asynchronous side effects
295
342
 
296
343
  If the app does work in the background (a job queue, a fire-and-forget webhook), wait for the effect with Vitest's own helpers. slicetest doesn't need its own:
@@ -302,10 +349,28 @@ await expect.poll(() => db.count("jobs", { status: "done" })).toBe(1);
302
349
 
303
350
  In YAML, add `within: <ms>` to a `db`, `sql`, `received` or `changes` step.
304
351
 
352
+ ### Snapshot the whole scenario: `trace()`
353
+
354
+ Because every scenario starts from the same database, ids and rows come out the same on every run. `trace()` returns everything the scenario did at the three boundaries: requests to the app with their responses, calls to each stub with the replies' statuses, and the database changes. Snapshot it, and a change in any of them shows up in review:
355
+
356
+ ```ts
357
+ scenario("voting flow", async ({ http, stub, trace }) => {
358
+ stub("slack").on("POST", "/hook").reply(200, "ok");
359
+ const { json } = await http.post("/polls", { title: "Tea or coffee", a: "tea", b: "coffee" });
360
+ await http.post(`/polls/${json.id}/votes`, { choice: "b" });
361
+
362
+ expect(await trace()).toMatchSnapshot();
363
+ });
364
+ ```
365
+
366
+ Dates (`Date` values and ISO strings) become `[date]` and UUIDs `[uuid]`. Mask more with `trace({ keys: ["token"], patterns: [/^tok_/] })`, or use `mask(value, opts)` from `slicetest` on anything else. Update snapshots with `vitest -u`. In YAML, the step is `snapshot: true` (with `mask: [token]`).
367
+
368
+ The example apps share one snapshot file: the Node and the Python implementation must produce the same trace, byte for byte.
369
+
305
370
  ### Scenarios
306
371
 
307
372
  ```ts
308
- scenario("name", async ({ http, db, stub, app }) => { ... }, timeoutMs?);
373
+ scenario("name", async ({ http, db, stub, app, service, container, trace }) => { ... }, timeoutMs?);
309
374
  scenario.only / scenario.skip / scenario.todo
310
375
  scenario.each([{ choice: "a", status: 204 }, { choice: "x", status: 400 }])(
311
376
  "voting $choice returns $status",
@@ -324,15 +389,17 @@ Scenarios in one file share an app and a database, so they always run one at a t
324
389
  | `app.cwd` | vitest root | |
325
390
  | `app.ready` | `{ path: "/" }` | Poll a path until it answers below 500, or `{ log: "listening" \| /regex/ }`. |
326
391
  | `app.readyTimeout` | `30000` | |
392
+ | `db.engine` | `postgres`, or `mysql` for a `mysql://` URL | `postgres` or `mysql` (see [MySQL](#mysql)). |
327
393
  | `db.migrate` | none | `{ atlas: { dir } }`, `{ sql: "file-or-dir" }` or `{ command, inputs? }` (gets `DATABASE_URL`). |
328
394
  | `db.seed` | none | SQL file re-run after every reset. |
329
395
  | `db.schemas` | `["public"]` | Schemas whose tables are reset. |
330
396
  | `db.keep` | `[]` | Extra tables (`name` or `schema.name`) never truncated. |
331
397
  | `db.url` | `$SLICETEST_DATABASE_URL`, else a container | Use an existing Postgres server (e.g. a CI service container) instead of Testcontainers. |
332
- | `db.image` | `postgres:17-alpine` | |
398
+ | `db.image` | `postgres:17-alpine` / `mysql:8.4` | |
333
399
  | `db.reuse` | on, unless `CI` is set or `db.url` is given | Keep the container between runs and cache the migrated template. The cache key is the migration files' contents; for `{ command }`, list what it reads in `inputs: ["prisma/migrations"]`, or it migrates every run. Databases left by killed runs are dropped after a day. Remove the container (`docker rm -f` / `podman rm -f`) to start clean. |
400
+ | `containers` | `{}` | Dependencies as containers: `{ name: { image, port, env?, command?, ready?: { log }, reset? } }`. See [Containers](#containers-redis-search-s3-and-other-dependencies). |
334
401
  | `services` | `{}` | Other processes: `{ name: { command, env?, cwd?, ready?, readyTimeout? } }`. Without `ready` a service is not waited for. |
335
- | `stubs` | `[]` | Names of stubbed services, or `{ name, openapi }` to check calls and replies against the provider's spec. |
402
+ | `stubs` | `[]` | Names of stubbed services, or `{ name, openapi?, autoReply?, upstream?, recordings? }`: check calls against the provider's spec, answer from it, or [replay recordings](#recording-a-real-service) of the real service. |
336
403
  | `openapi` | none | The app's OpenAPI 3 spec, or `{ spec, minCoverage }`. Every response must match it; the run ends with a coverage report. |
337
404
  | `http` | `{}` | Default `headers` / `query` for every request. |
338
405
 
@@ -393,6 +460,7 @@ scenarios:
393
460
  | `log: <regex>` | `from` (a service; default the app), `within` (ms, default 5000). Waits for a matching line printed during the scenario. |
394
461
  | `changes: { <table>: { inserted, updated, deleted } }` | Each is a count or a list of subset rows (`updated` matches the row after the update). Tables that aren't listed must be unchanged. |
395
462
  | `checkpoint: true` | Later `changes` steps only see what happens after this step. |
463
+ | `snapshot: true` | The scenario's [trace](#snapshot-the-whole-scenario-trace) so far must match its stored snapshot. `mask: [keys]` hides more values. |
396
464
 
397
465
  `db`, `sql`, `received` and `changes` steps take `within: <ms>` to retry until they pass, for effects the app applies asynchronously.
398
466
 
@@ -422,6 +490,17 @@ npx slicetest polls -t voting # filter by file and scenario name
422
490
  npx slicetest --watch
423
491
  ```
424
492
 
493
+ ### Scenarios from your OpenAPI spec: `npx slicetest gen`
494
+
495
+ ```sh
496
+ npx slicetest gen # uses `openapi` from slicetest.config.yaml, or --spec openapi.yaml
497
+ npx slicetest gen --uncovered # only the documented responses the last run didn't produce
498
+ ```
499
+
500
+ writes `scenarios/<resource>.gen.scenario.yaml` with one scenario per documented response. Requests are built from the spec's examples and schemas. A path that needs an id gets a step that creates the resource first through the collection's `POST` and captures its id. A 404 on a made-up id and a 400/422 on an empty body are runnable as is; other responses are generated as `skip: true` scenarios marked TODO, so the skipped list in the test output is what's left to cover. Existing files are kept unless you pass `--force`.
501
+
502
+ `--uncovered` reads the coverage the last run left in `node_modules/.cache/slicetest/`, which closes the loop: run, look at the ✗ in the coverage table, `gen --uncovered`, fill in the TODOs.
503
+
425
504
  ## Examples
426
505
 
427
506
  `examples/` has a Node app (`node:http` + `pg`) and a Python app (`http.server` + `psycopg`) with the same API. **The same scenario files (`polls.test.ts` and `polls.scenario.yaml`) run against both**:
@@ -431,6 +510,10 @@ npm test # unit tests + both example apps
431
510
  npm run test:dist # the built package, and the CLI with examples/slicetest.config.yaml
432
511
  ```
433
512
 
513
+ ## Using a coding agent
514
+
515
+ [`.claude/skills/slicetest-write-tests`](.claude/skills/slicetest-write-tests/SKILL.md) teaches Claude Code (or any agent that reads skills) how to write slicetest scenarios. Copy it into your project's `.claude/skills/`. Contributors: see [`AGENTS.md`](AGENTS.md).
516
+
434
517
  ## Status
435
518
 
436
- Early. Postgres only. CI runs on Linux and Windows. Planned: MySQL.
519
+ Early. Postgres and MySQL. CI runs on Linux and Windows.
package/dist/cli.js CHANGED
@@ -12,22 +12,29 @@ 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
14
  slicetest init [--force]
15
+ slicetest gen [--spec <file>] [--out <dir>] [--uncovered] [--force]
15
16
 
16
17
  Runs *.scenario.yaml files against your app, as configured in slicetest.config.yaml.
17
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.
18
21
 
19
22
  Options:
20
23
  -c, --config <file> Config file (default: ${CONFIG_NAMES.join(" / ")} in the current directory)
21
24
  -w, --watch Re-run on changes
22
25
  -t, --name <pattern> Only run scenarios whose name matches
23
- --force init: overwrite existing files
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
24
30
  -h, --help Show this help
25
31
 
26
32
  Config (paths are relative to the config file):
27
33
  app: { command, env, cwd, ready: { path } | { log }, readyTimeout }
28
- db: { migrate: { atlas: { dir } } | { sql } | { command, inputs }, seed, url, image, schemas, keep, reuse }
29
- stubs: [name | { name, openapi }]
34
+ db: { migrate: { atlas: { dir } } | { sql } | { command, inputs }, engine, seed, url, image, schemas, keep, reuse }
35
+ stubs: [name | { name, openapi, autoReply, upstream, recordings }]
30
36
  services: { name: { command, env, cwd, ready } }
37
+ containers: { name: { image, port, env, command, ready: { log }, reset } }
31
38
  openapi: file | { spec, minCoverage }
32
39
  http: { headers, query }
33
40
  include: [globs] default ["**/*.scenario.{yaml,yml}"]
@@ -42,6 +49,9 @@ export async function main(argv = process.argv.slice(2)) {
42
49
  name: { type: "string", short: "t" },
43
50
  help: { type: "boolean", short: "h" },
44
51
  force: { type: "boolean" },
52
+ spec: { type: "string" },
53
+ out: { type: "string" },
54
+ uncovered: { type: "boolean" },
45
55
  },
46
56
  });
47
57
  if (values.help) {
@@ -57,6 +67,22 @@ export async function main(argv = process.argv.slice(2)) {
57
67
  const configPath = values.config
58
68
  ? path.resolve(values.config)
59
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
+ }
60
86
  if (!configPath || !existsSync(configPath)) {
61
87
  process.stderr.write(`slicetest: no config found. Run "npx slicetest init" to create ${CONFIG_NAMES[0]} (see --help).\n`);
62
88
  process.exitCode = 1;
package/dist/config.d.ts CHANGED
@@ -8,11 +8,7 @@ export interface SlicetestOptions {
8
8
  * With `autoReply: true` as well, calls no route matches are answered from the spec (its examples, or values
9
9
  * built from its schemas) instead of failing, so you only register the routes a scenario cares about.
10
10
  */
11
- stubs?: (string | {
12
- name: string;
13
- openapi?: string;
14
- autoReply?: boolean;
15
- })[];
11
+ stubs?: (string | StubOptions)[];
16
12
  /**
17
13
  * The app's own OpenAPI 3 spec (YAML or JSON, relative to the root). Every
18
14
  * response the app gives during a scenario must be documented and match its
@@ -34,6 +30,42 @@ export interface SlicetestOptions {
34
30
  * usable in `app.env` and in the env of services declared after them.
35
31
  */
36
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>;
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;
37
69
  }
38
70
  export interface ServiceOptions extends Omit<AppOptions, "ready"> {
39
71
  /** Default: no wait (for workers that don't listen). `{ path }` polls the service's own port. */
@@ -69,10 +101,15 @@ export interface AppOptions {
69
101
  readyTimeout?: number;
70
102
  }
71
103
  export interface DbOptions {
72
- /** 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. */
73
110
  image?: string;
74
111
  /**
75
- * 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.
76
113
  * Defaults to the `SLICETEST_DATABASE_URL` environment variable, which is handy in CI.
77
114
  */
78
115
  url?: string;
@@ -113,7 +150,8 @@ export interface ResolvedOptions {
113
150
  ready: ResolvedReady;
114
151
  };
115
152
  services: Record<string, ResolvedProcess>;
116
- db: Required<Pick<DbOptions, "image" | "schemas" | "keep" | "reuse">> & Omit<DbOptions, "image" | "schemas" | "keep" | "reuse">;
153
+ containers: Record<string, ContainerOptions>;
154
+ db: Required<Pick<DbOptions, "engine" | "image" | "schemas" | "keep" | "reuse">> & Omit<DbOptions, "engine" | "image" | "schemas" | "keep" | "reuse">;
117
155
  stubs: string[];
118
156
  /** Spec files, resolved against the root: the app's, and per stub name. */
119
157
  openapi: {
@@ -122,6 +160,12 @@ export interface ResolvedOptions {
122
160
  stubs: Record<string, string>;
123
161
  autoReply: string[];
124
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
+ }>;
125
169
  http?: RequestOptions;
126
170
  }
127
171
  export declare function resolveOptions(opts: SlicetestOptions, root: string): ResolvedOptions;
package/dist/config.js CHANGED
@@ -4,6 +4,7 @@ export function resolveOptions(opts, root) {
4
4
  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
+ containers: opts.containers ?? {},
7
8
  db: resolveDb(opts.db ?? {}),
8
9
  stubs: (opts.stubs ?? []).map(stubName),
9
10
  openapi: {
@@ -12,21 +13,39 @@ export function resolveOptions(opts, root) {
12
13
  stubs: Object.fromEntries((opts.stubs ?? []).flatMap((s) => (typeof s === "object" && s.openapi ? [[s.name, s.openapi]] : []))),
13
14
  autoReply: (opts.stubs ?? []).flatMap((s) => (typeof s === "object" && s.autoReply ? [s.name] : [])),
14
15
  },
16
+ recordings: resolveRecordings(opts.stubs ?? []),
15
17
  http: opts.http,
16
18
  };
17
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
+ }
18
32
  function resolveReady(ready) {
19
33
  if (!("log" in ready))
20
34
  return ready;
21
35
  return typeof ready.log === "string" ? { log: escapeRegExp(ready.log), flags: "" } : { log: ready.log.source, flags: ready.log.flags };
22
36
  }
23
37
  function resolveDb(db) {
24
- const url = db.url ?? (process.env.SLICETEST_DATABASE_URL || undefined);
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);
25
43
  return {
26
- image: "postgres:17-alpine",
44
+ image: engine === "mysql" ? "mysql:8.4" : "postgres:17-alpine",
27
45
  schemas: ["public"],
28
46
  keep: [],
29
47
  ...db,
48
+ engine,
30
49
  url,
31
50
  reuse: db.reuse ?? (!url && !process.env.CI),
32
51
  };
@@ -52,6 +71,22 @@ function validate(opts) {
52
71
  fail(`services.${name}.command is required`);
53
72
  checkReady(s.ready, `services.${name}`);
54
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)}`);
55
90
  const migrate = opts.db?.migrate;
56
91
  if (migrate) {
57
92
  const keys = Object.keys(migrate).filter((k) => ["atlas", "sql", "command"].includes(k));
@@ -72,6 +107,10 @@ function validate(opts) {
72
107
  fail(`each stub must be a name or { name, openapi }, got ${JSON.stringify(s)}`);
73
108
  if (typeof s === "object" && s.autoReply && !s.openapi)
74
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`);
75
114
  }
76
115
  const stubs = (opts.stubs ?? []).map(stubName);
77
116
  for (const name of stubs) {
@@ -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
+ }
@@ -1,5 +1,5 @@
1
1
  import type { ResolvedOptions } from "../config.js";
2
2
  import type { Engine } from "./driver.js";
3
3
  export type { Admin, Driver, Engine, Row, Table } from "./driver.js";
4
- /** The database engine a run uses. */
5
- export declare function engineFor(_opts: Pick<ResolvedOptions, "db">): Engine;
4
+ /** The database engine a run uses. MySQL's driver is an optional dependency, loaded only when asked for. */
5
+ export declare function engineFor(opts: Pick<ResolvedOptions, "db">): Promise<Engine>;
@@ -1,5 +1,14 @@
1
1
  import { postgres } from "./postgres.js";
2
- /** The database engine a run uses. */
3
- export function engineFor(_opts) {
2
+ /** The database engine a run uses. MySQL's driver is an optional dependency, loaded only when asked for. */
3
+ export async function engineFor(opts) {
4
+ if (opts.db.engine === "mysql") {
5
+ const mod = await import("./mysql.js").catch((e) => {
6
+ if (e.code === "ERR_MODULE_NOT_FOUND") {
7
+ throw new Error('slicetest: db.engine "mysql" needs the mysql2 package: npm i -D mysql2');
8
+ }
9
+ throw e;
10
+ });
11
+ return mod.mysqlEngine;
12
+ }
4
13
  return postgres;
5
14
  }
@@ -0,0 +1,29 @@
1
+ import type { Driver, Engine, Row, Table } from "./driver.js";
2
+ export declare class MysqlDriver implements Driver {
3
+ #private;
4
+ private readonly conn;
5
+ private constructor();
6
+ static connect(url: string): Promise<MysqlDriver>;
7
+ query<T extends Row = Row>(sql: string, params?: unknown[]): Promise<T[]>;
8
+ queryAll(sqls: string[]): Promise<Row[][]>;
9
+ exec(script: string): Promise<void>;
10
+ ident(name: string): string;
11
+ column(name: string): string;
12
+ param(_n: number): string;
13
+ inList(column: string, values: unknown[], params: unknown[]): string;
14
+ /**
15
+ * Base tables in the configured schemas. In MySQL a schema is a database;
16
+ * `public` (the default) stands for the database the URL points at.
17
+ */
18
+ listTables(schemas: string[], keep: string[]): Promise<Table[]>;
19
+ /**
20
+ * TRUNCATE also restarts AUTO_INCREMENT, but it is DDL and costs a few
21
+ * milliseconds per table, so only tables that have rows or a used counter are
22
+ * truncated. Foreign key checks are off for this session only.
23
+ */
24
+ truncate(tables: Table[]): Promise<void>;
25
+ /** MySQL has no RETURNING: insert, then read the row back by its primary key. */
26
+ insert(table: string, row: Row): Promise<Row[]>;
27
+ close(): Promise<void>;
28
+ }
29
+ export declare const mysqlEngine: Engine;