slicetest 0.2.0 → 0.4.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 +188 -9
- package/dist/ci.d.ts +29 -0
- package/dist/ci.js +57 -0
- package/dist/cli.js +52 -3
- package/dist/config.d.ts +61 -8
- package/dist/config.js +46 -2
- package/dist/containers.d.ts +22 -0
- package/dist/containers.js +59 -0
- package/dist/doctor.d.ts +22 -0
- package/dist/doctor.js +190 -0
- package/dist/drivers/driver.d.ts +2 -0
- package/dist/drivers/index.d.ts +2 -2
- package/dist/drivers/index.js +13 -2
- package/dist/drivers/mysql.d.ts +29 -0
- package/dist/drivers/mysql.js +270 -0
- package/dist/drivers/sqlite.d.ts +23 -0
- package/dist/drivers/sqlite.js +203 -0
- package/dist/gen.d.ts +24 -0
- package/dist/gen.js +116 -0
- package/dist/global-setup.js +53 -5
- package/dist/http.d.ts +6 -0
- package/dist/http.js +14 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +1 -0
- package/dist/init.d.ts +2 -0
- package/dist/init.js +180 -3
- package/dist/mail.d.ts +58 -0
- package/dist/mail.js +299 -0
- package/dist/matchers.d.ts +4 -0
- package/dist/matchers.js +33 -0
- package/dist/openapi.d.ts +19 -0
- package/dist/openapi.js +43 -1
- package/dist/provided.d.ts +2 -0
- package/dist/record-cli.d.ts +6 -0
- package/dist/record-cli.js +93 -0
- package/dist/record-session.d.ts +1 -0
- package/dist/record-session.js +10 -0
- package/dist/record.d.ts +46 -0
- package/dist/record.js +201 -0
- package/dist/recording.d.ts +43 -0
- package/dist/recording.js +148 -0
- package/dist/runtime.d.ts +17 -0
- package/dist/runtime.js +71 -6
- package/dist/stub.d.ts +1 -1
- package/dist/stub.js +14 -3
- package/dist/trace.d.ts +43 -0
- package/dist/trace.js +59 -0
- package/dist/vitest.js +5 -2
- package/dist/yaml-runtime.d.ts +1 -0
- package/dist/yaml-runtime.js +58 -13
- package/dist/yaml.d.ts +30 -1
- package/dist/yaml.js +41 -5
- package/package.json +18 -4
- package/schema/scenario.schema.json +173 -1
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, or SQLite) 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,21 @@ 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. A mail catcher there (Mailpit, MailHog, MailDev, smtp4dev, …) or a mail library in the dependencies turns on [`mail`](#mail-catch-what-the-app-sends). SQLite is picked up from Prisma's provider, Rails' `database.yml`, Django's settings or a SQLite driver, with `DATABASE_URL` in the form the framework reads (`file:…`, `sqlite3:…`). And third-party API URLs in `.env.example` (`STRIPE_API_BASE=https://api.stripe.com`) become stubs [recorded from that service](#recording-a-real-service), with the variable pointed at the stub, while local addresses, databases and your own URLs are left alone. 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
|
-
- **
|
|
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
|
+
- **Record instead of write.** `npx slicetest record` puts a proxy in front of the app: click through a flow, press Enter, and get a replayable YAML scenario with the stubs' answers, the responses, captured ids and the database changes.
|
|
52
|
+
- **Readable in CI.** On GitHub Actions, failing YAML steps are annotated in the pull request on the line that failed, and the job summary shows the OpenAPI coverage table.
|
|
53
|
+
- **Races on purpose.** `http.concurrently(10, ...)` and `toHaveStatuses({ 201: 1, 409: 9 })` turn "what if two people click at once" into a test against the real database.
|
|
54
|
+
- **Mail as a fourth boundary.** `mail: true` catches the app's SMTP traffic in-process, decoded, with the links pulled out, so a sign-up test can follow the confirmation link.
|
|
55
|
+
- **Postgres, MySQL or SQLite**, with the same scenarios and the same helpers on all three, plus Redis, MinIO or any other `containers` reset between scenarios.
|
|
49
56
|
- **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
57
|
|
|
51
58
|
## Install
|
|
@@ -54,7 +61,30 @@ npx slicetest # starts Postgres, migrates, starts your app, runs scenario
|
|
|
54
61
|
npm i -D slicetest vitest
|
|
55
62
|
```
|
|
56
63
|
|
|
57
|
-
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.
|
|
64
|
+
You also need Docker or Podman (`npx slicetest doctor` checks). 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.
|
|
65
|
+
|
|
66
|
+
### MySQL
|
|
67
|
+
|
|
68
|
+
Set `db: { engine: "mysql" }` (or give a `mysql://` URL) and install the driver:
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
npm i -D mysql2 @testcontainers/mysql # the second is only needed without db.url
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
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.
|
|
75
|
+
|
|
76
|
+
### SQLite
|
|
77
|
+
|
|
78
|
+
Set `db: { engine: "sqlite" }`. There's nothing to install and no container: slicetest uses Node's built-in `node:sqlite` (Node.js 22.5 or later), creates the database files in a temporary directory, migrates a template once and gives each worker a copy (`VACUUM INTO`). Point the app at the file:
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
slicetest({
|
|
82
|
+
app: { command: "python app.py", env: { PORT: "{{app.port}}", DATABASE_PATH: "{{db.path}}" } },
|
|
83
|
+
db: { engine: "sqlite", migrate: { sql: "schema.sql" } },
|
|
84
|
+
});
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`{{db.url}}` is `sqlite:///absolute/path.db` (the form SQLAlchemy, dj-database-url and many others read) and `{{db.path}}` the plain path. The files are in WAL mode, so the app keeps its connection while slicetest resets tables between scenarios (`DELETE` plus resetting `AUTOINCREMENT` counters, foreign keys off for that moment only). `db.changes()`, `trace()` and every other helper work the same; SQLite has no boolean type, so booleans you insert are stored and read back as `1` / `0`. Migrations with `sql`, `command` or Atlas (`sqlite://` URLs). `db.url`, `db.image` and `SLICETEST_DATABASE_URL` don't apply. With the default `reuse`, the migrated template stays in the system temp directory between runs.
|
|
58
88
|
|
|
59
89
|
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
90
|
|
|
@@ -246,12 +276,31 @@ To fail the run below a threshold, use `openapi: { spec: "openapi.yaml", minCove
|
|
|
246
276
|
|
|
247
277
|
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
278
|
|
|
279
|
+
### Recording a real service
|
|
280
|
+
|
|
281
|
+
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:
|
|
282
|
+
|
|
283
|
+
```ts
|
|
284
|
+
stubs: [{ name: "github", upstream: "https://api.github.com" }],
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Record once, with real credentials in the app's environment:
|
|
288
|
+
|
|
289
|
+
```sh
|
|
290
|
+
SLICETEST_RECORD=github npx vitest # or SLICETEST_RECORD=1 for every stub with an upstream
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
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.
|
|
294
|
+
|
|
295
|
+
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.
|
|
296
|
+
|
|
249
297
|
### Matchers
|
|
250
298
|
|
|
251
299
|
Registered automatically:
|
|
252
300
|
|
|
253
301
|
```ts
|
|
254
302
|
expect(res).toHaveStatus(201); // failure shows the response body
|
|
303
|
+
expect(responses).toHaveStatuses({ 201: 1, 409: 9 }); // an array, e.g. from http.concurrently()
|
|
255
304
|
expect(stub("slack")).toHaveReceived("POST", "/hook", { json: { text: "hi" } });
|
|
256
305
|
expect(stub("slack")).toHaveReceivedTimes(1, "POST", "/hook");
|
|
257
306
|
expect(stub("mail")).not.toHaveReceived("POST", "/send");
|
|
@@ -291,6 +340,55 @@ scenario("uploading an image queues a thumbnail job", async ({ http, db, service
|
|
|
291
340
|
|
|
292
341
|
`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
342
|
|
|
343
|
+
### Containers: Redis, search, S3 and other dependencies
|
|
344
|
+
|
|
345
|
+
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`:
|
|
346
|
+
|
|
347
|
+
```ts
|
|
348
|
+
slicetest({
|
|
349
|
+
containers: {
|
|
350
|
+
cache: { image: "redis:7-alpine", port: 6379, reset: ["redis-cli", "FLUSHALL"] },
|
|
351
|
+
s3: { image: "minio/minio", port: 9000, command: ["server", "/data"], env: { MINIO_ROOT_USER: "test", MINIO_ROOT_PASSWORD: "testtest" } },
|
|
352
|
+
},
|
|
353
|
+
app: { command: "node server.js", env: { REDIS_URL: "redis://{{container.cache}}", S3_ENDPOINT: "http://{{container.s3}}" } },
|
|
354
|
+
});
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
`{{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.
|
|
358
|
+
|
|
359
|
+
### Races: many requests at once
|
|
360
|
+
|
|
361
|
+
Double bookings, lost updates and duplicate charges only show up when requests overlap. `http.concurrently(n, send)` prepares `n` requests and releases them together, against the real database, and `toHaveStatuses` checks how they were answered:
|
|
362
|
+
|
|
363
|
+
```ts
|
|
364
|
+
scenario("ten people booking the same seat: one gets it", async ({ http, db }) => {
|
|
365
|
+
const responses = await http.concurrently(10, () => http.post("/bookings", { seat: 7 }));
|
|
366
|
+
expect(responses).toHaveStatuses({ 201: 1, 409: 9 });
|
|
367
|
+
await expect(db).toHaveRow("bookings", { seat: 7 }, 1);
|
|
368
|
+
});
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
`send` gets the request's index, for variations. When the counts are off, the failure shows one response per status. In YAML, `concurrency: 10` on a `request` step does the same, with `expect: { statuses: { 201: 1, 409: 9 } }`.
|
|
372
|
+
|
|
373
|
+
### Mail: catch what the app sends
|
|
374
|
+
|
|
375
|
+
`mail: true` starts an SMTP server for the app to send to (plain SMTP, no TLS, any username and password accepted). Point the app's mail settings at it, and read what arrived in the scenario, already decoded (encoded subjects, quoted-printable and base64 parts, multipart text and HTML):
|
|
376
|
+
|
|
377
|
+
```ts
|
|
378
|
+
slicetest({
|
|
379
|
+
mail: true,
|
|
380
|
+
app: { command: "node server.js", env: { SMTP_HOST: "{{mail.host}}", SMTP_PORT: "{{mail.port}}" } },
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
scenario("sign-up sends a confirmation link that works", async ({ http, mail }) => {
|
|
384
|
+
await http.post("/signup", { json: { email: "alice@example.com" } });
|
|
385
|
+
const message = await mail.waitFor({ to: "alice@example.com", subject: "Confirm" });
|
|
386
|
+
expect((await http.get(message.links[0]!)).status).toBe(200);
|
|
387
|
+
});
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
`mail.messages(filter?)`, `mail.last(filter?)` and `mail.waitFor(filter?, { within })` take `{ to, from, subject, text, html }`: addresses match exactly, other strings as substrings, and RegExps test the value. Each message has `from`, `to` (the envelope, so Cc and Bcc too), `subject`, `text`, `html`, `headers`, `links` and `raw`. The mailbox is emptied before each scenario, what was sent shows up in the failure output and in `trace()`, and `{{mail.url}}` is `smtp://host:port` for libraries that take a URL. No container is involved, so it works the same for apps in any language and on Windows.
|
|
391
|
+
|
|
294
392
|
### Asynchronous side effects
|
|
295
393
|
|
|
296
394
|
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 +400,28 @@ await expect.poll(() => db.count("jobs", { status: "done" })).toBe(1);
|
|
|
302
400
|
|
|
303
401
|
In YAML, add `within: <ms>` to a `db`, `sql`, `received` or `changes` step.
|
|
304
402
|
|
|
403
|
+
### Snapshot the whole scenario: `trace()`
|
|
404
|
+
|
|
405
|
+
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:
|
|
406
|
+
|
|
407
|
+
```ts
|
|
408
|
+
scenario("voting flow", async ({ http, stub, trace }) => {
|
|
409
|
+
stub("slack").on("POST", "/hook").reply(200, "ok");
|
|
410
|
+
const { json } = await http.post("/polls", { title: "Tea or coffee", a: "tea", b: "coffee" });
|
|
411
|
+
await http.post(`/polls/${json.id}/votes`, { choice: "b" });
|
|
412
|
+
|
|
413
|
+
expect(await trace()).toMatchSnapshot();
|
|
414
|
+
});
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
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]`).
|
|
418
|
+
|
|
419
|
+
The example apps share one snapshot file: the Node and the Python implementation must produce the same trace, byte for byte.
|
|
420
|
+
|
|
305
421
|
### Scenarios
|
|
306
422
|
|
|
307
423
|
```ts
|
|
308
|
-
scenario("name", async ({ http, db, stub, app }) => { ... }, timeoutMs?);
|
|
424
|
+
scenario("name", async ({ http, db, stub, app, service, container, trace }) => { ... }, timeoutMs?);
|
|
309
425
|
scenario.only / scenario.skip / scenario.todo
|
|
310
426
|
scenario.each([{ choice: "a", status: 204 }, { choice: "x", status: 400 }])(
|
|
311
427
|
"voting $choice returns $status",
|
|
@@ -324,15 +440,18 @@ Scenarios in one file share an app and a database, so they always run one at a t
|
|
|
324
440
|
| `app.cwd` | vitest root | |
|
|
325
441
|
| `app.ready` | `{ path: "/" }` | Poll a path until it answers below 500, or `{ log: "listening" \| /regex/ }`. |
|
|
326
442
|
| `app.readyTimeout` | `30000` | |
|
|
443
|
+
| `db.engine` | `postgres`, or `mysql` for a `mysql://` URL | `postgres`, `mysql` (see [MySQL](#mysql)) or `sqlite` (see [SQLite](#sqlite)). |
|
|
327
444
|
| `db.migrate` | none | `{ atlas: { dir } }`, `{ sql: "file-or-dir" }` or `{ command, inputs? }` (gets `DATABASE_URL`). |
|
|
328
445
|
| `db.seed` | none | SQL file re-run after every reset. |
|
|
329
446
|
| `db.schemas` | `["public"]` | Schemas whose tables are reset. |
|
|
330
447
|
| `db.keep` | `[]` | Extra tables (`name` or `schema.name`) never truncated. |
|
|
331
448
|
| `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` | |
|
|
449
|
+
| `db.image` | `postgres:17-alpine` / `mysql:8.4` | |
|
|
333
450
|
| `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. |
|
|
451
|
+
| `containers` | `{}` | Dependencies as containers: `{ name: { image, port, env?, command?, ready?: { log }, reset? } }`. See [Containers](#containers-redis-search-s3-and-other-dependencies). |
|
|
452
|
+
| `mail` | `false` | Start an SMTP server at `{{mail.host}}` / `{{mail.port}}` and collect the app's mail. See [Mail](#mail-catch-what-the-app-sends). |
|
|
334
453
|
| `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 }
|
|
454
|
+
| `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
455
|
| `openapi` | none | The app's OpenAPI 3 spec, or `{ spec, minCoverage }`. Every response must match it; the run ends with a coverage report. |
|
|
337
456
|
| `http` | `{}` | Default `headers` / `query` for every request. |
|
|
338
457
|
|
|
@@ -385,7 +504,7 @@ scenarios:
|
|
|
385
504
|
| Step | Keys |
|
|
386
505
|
|---|---|
|
|
387
506
|
| `stub: <name>` | `on: METHOD /path` (`:params` allowed), `when: { query, headers, json, body }`, one of `reply: { status, headers, body }` / `sequence: [...]` / `networkError: true`, plus `times`, `delay`. Replies may echo the call: `{{call.params.id}}`, `{{call.json.name}}`. |
|
|
388
|
-
| `request: METHOD /path` | `headers`, `query`, one of `json` / `form` / `body`, `follow`, `expect: { status, headers, json, text }`, `capture` |
|
|
507
|
+
| `request: METHOD /path` | `headers`, `query`, one of `json` / `form` / `body`, `follow`, `expect: { status, headers, json, text }`, `capture`. `concurrency: n` sends it `n` times at once; `expect` then applies to each response, and `expect.statuses: { 201: 1, 409: 9 }` counts them. |
|
|
389
508
|
| `insert: <table>` | `rows`, `capture` (from `row` / `rows`) |
|
|
390
509
|
| `db: <table>` | `where`, `orderBy`, `expect: { rows, count }`, `capture` |
|
|
391
510
|
| `sql: <query>` | `params`, `expect: { rows, count }`, `capture` |
|
|
@@ -393,9 +512,12 @@ scenarios:
|
|
|
393
512
|
| `log: <regex>` | `from` (a service; default the app), `within` (ms, default 5000). Waits for a matching line printed during the scenario. |
|
|
394
513
|
| `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
514
|
| `checkpoint: true` | Later `changes` steps only see what happens after this step. |
|
|
515
|
+
| `mail: { to, from, subject, text, html }` | `times` (exact; default at least one), `within` (ms, default 5000), `capture` from the last match (`subject`, `text`, `links.0`). Waits for mail the app sends. `{}` matches any message. |
|
|
516
|
+
| `snapshot: true` | The scenario's [trace](#snapshot-the-whole-scenario-trace) so far must match its stored snapshot. `mask: [keys]` hides more values. |
|
|
396
517
|
|
|
397
518
|
`db`, `sql`, `received` and `changes` steps take `within: <ms>` to retry until they pass, for effects the app applies asynchronously.
|
|
398
519
|
|
|
520
|
+
- `request:` also takes a captured URL of the app, e.g. `GET {{link}}` after capturing a link from a mail.
|
|
399
521
|
- `{{name}}` inserts a captured value or an `each` field. A string that is only `{{name}}` keeps the value's type, so `id: "{{pollId}}"` compares as a number.
|
|
400
522
|
- Expected `json`, `rows` and `headers` are subsets: extra keys are fine. `{ $type: number }`, `{ $regex: "^ch_" }`, `{ $contains: "..." }` and `{ $any: true }` match loosely.
|
|
401
523
|
- A file-level `setup:` list runs at the start of every scenario. `skip`, `only` and `timeout` work per scenario.
|
|
@@ -422,6 +544,59 @@ npx slicetest polls -t voting # filter by file and scenario name
|
|
|
422
544
|
npx slicetest --watch
|
|
423
545
|
```
|
|
424
546
|
|
|
547
|
+
### Scenarios from your OpenAPI spec: `npx slicetest gen`
|
|
548
|
+
|
|
549
|
+
```sh
|
|
550
|
+
npx slicetest gen # uses `openapi` from slicetest.config.yaml, or --spec openapi.yaml
|
|
551
|
+
npx slicetest gen --uncovered # only the documented responses the last run didn't produce
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
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`.
|
|
555
|
+
|
|
556
|
+
`--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.
|
|
557
|
+
|
|
558
|
+
### Record a scenario by using the app: `npx slicetest record`
|
|
559
|
+
|
|
560
|
+
The fastest way to a first scenario is to do the thing once. `record` starts the database, the stubs and the app exactly as a test run would, plus a proxy in front of the app:
|
|
561
|
+
|
|
562
|
+
```
|
|
563
|
+
$ npx slicetest record
|
|
564
|
+
Recording. Use the app through http://127.0.0.1:52301 (it forwards to http://127.0.0.1:52288).
|
|
565
|
+
Everything it does is captured: responses, calls to stubs, database changes.
|
|
566
|
+
Press Enter (or Ctrl+C) to finish and write the scenario.
|
|
567
|
+
|
|
568
|
+
Wrote scenarios/recorded-20261001-091500.scenario.yaml: 3 request(s), 1 stub route(s), changes in polls, votes.
|
|
569
|
+
Replay it with: npx slicetest scenarios/recorded-20261001-091500.scenario.yaml
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
Point a browser, curl, Postman or a mobile build at the proxy URL and go through the flow. The scenario it writes has:
|
|
573
|
+
|
|
574
|
+
- a `stub` step for every answer a stubbed service gave (from `autoReply`, or a real service with `upstream` and `SLICETEST_RECORD`), so the replay needs neither;
|
|
575
|
+
- a `request` step per request with the status and body to expect, where dates and UUIDs only have to be strings, and a `capture` for any value that a later request reuses (`POST /orders` → `GET /orders/{{id}}`);
|
|
576
|
+
- `received` steps for the calls the app made to stubs, and a closing `changes` step with the rows written per table.
|
|
577
|
+
|
|
578
|
+
Requests for scripts, styles and images are left out. The file starts with a comment of what to review: it is a starting point, and the assertions that matter to you are yours to tighten. `--out` names the file, `--port` fixes the proxy's port.
|
|
579
|
+
|
|
580
|
+
### On GitHub Actions
|
|
581
|
+
|
|
582
|
+
Nothing to configure. When `GITHUB_ACTIONS` is set, a failing YAML step is annotated on its own line of the `.scenario.yaml` file in the pull request (Vitest already does this for TypeScript tests), and the job summary gets a table of the failed steps and the OpenAPI coverage table, with ✅ / ❌ per documented response. A coverage below `minCoverage` is annotated on the spec file.
|
|
583
|
+
|
|
584
|
+
### Is everything in place? `npx slicetest doctor`
|
|
585
|
+
|
|
586
|
+
Checks what a run needs before it starts, instead of failing with a timeout halfway: the config, the container runtime (or the database server at `db.url` / `SLICETEST_DATABASE_URL`, with the password hidden), the migrations, seed and working directories, the `atlas` CLI, `mysql2` for MySQL, the programs the app and services start, the OpenAPI files and missing recordings. Each problem says what to do, and the exit code is 1 when something must be fixed, so it also works as the first step of a CI job.
|
|
587
|
+
|
|
588
|
+
```
|
|
589
|
+
✓ Node.js 24.13.0
|
|
590
|
+
✓ config slicetest.config.yaml
|
|
591
|
+
✗ no container runtime
|
|
592
|
+
Could not find a working container runtime strategy. Start Docker or a Podman machine (`podman machine start`), or set SLICETEST_DATABASE_URL / db.url to an existing database server
|
|
593
|
+
✓ migrations migrations
|
|
594
|
+
! stub github: no recordings yet (recordings/github.yaml)
|
|
595
|
+
run once with SLICETEST_RECORD=github to record https://api.github.com
|
|
596
|
+
|
|
597
|
+
1 problem(s) to fix before running.
|
|
598
|
+
```
|
|
599
|
+
|
|
425
600
|
## Examples
|
|
426
601
|
|
|
427
602
|
`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 +606,10 @@ npm test # unit tests + both example apps
|
|
|
431
606
|
npm run test:dist # the built package, and the CLI with examples/slicetest.config.yaml
|
|
432
607
|
```
|
|
433
608
|
|
|
609
|
+
## Using a coding agent
|
|
610
|
+
|
|
611
|
+
[`.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).
|
|
612
|
+
|
|
434
613
|
## Status
|
|
435
614
|
|
|
436
|
-
Early. Postgres
|
|
615
|
+
Early. Postgres and MySQL. CI runs on Linux and Windows.
|
package/dist/ci.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Actions output. Vitest already annotates failing TypeScript tests;
|
|
3
|
+
* YAML scenarios fail inside slicetest's runtime, so their annotations point at
|
|
4
|
+
* the `.scenario.yaml` line here instead. The OpenAPI coverage table also goes
|
|
5
|
+
* to the job summary.
|
|
6
|
+
*/
|
|
7
|
+
export declare const onGitHub: (env?: NodeJS.ProcessEnv) => boolean;
|
|
8
|
+
export interface YamlFailure {
|
|
9
|
+
/** Absolute path of the scenario file. */
|
|
10
|
+
file: string;
|
|
11
|
+
line: number;
|
|
12
|
+
scenario: string;
|
|
13
|
+
step: string;
|
|
14
|
+
message: string;
|
|
15
|
+
}
|
|
16
|
+
/** `::error file=…,line=…,title=…::message`, escaped as the runner expects. */
|
|
17
|
+
export declare function annotation(level: "error" | "warning", message: string, props?: {
|
|
18
|
+
file?: string;
|
|
19
|
+
line?: number;
|
|
20
|
+
title?: string;
|
|
21
|
+
}): string;
|
|
22
|
+
/** Paths in annotations are relative to the repository checkout. */
|
|
23
|
+
export declare function repoPath(file: string, env?: NodeJS.ProcessEnv): string;
|
|
24
|
+
/** Called in a worker: one JSON line per failed YAML step, printed by the main process at the end. */
|
|
25
|
+
export declare function recordYamlFailure(dir: string, failure: YamlFailure): Promise<void>;
|
|
26
|
+
export declare function yamlFailures(dir: string): Promise<YamlFailure[]>;
|
|
27
|
+
export declare function failureAnnotations(failures: YamlFailure[], env?: NodeJS.ProcessEnv): string[];
|
|
28
|
+
export declare function failureSummary(failures: YamlFailure[], env?: NodeJS.ProcessEnv): string;
|
|
29
|
+
export declare function appendSummary(markdown: string, env?: NodeJS.ProcessEnv): Promise<void>;
|
package/dist/ci.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { appendFile, readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* GitHub Actions output. Vitest already annotates failing TypeScript tests;
|
|
5
|
+
* YAML scenarios fail inside slicetest's runtime, so their annotations point at
|
|
6
|
+
* the `.scenario.yaml` line here instead. The OpenAPI coverage table also goes
|
|
7
|
+
* to the job summary.
|
|
8
|
+
*/
|
|
9
|
+
export const onGitHub = (env = process.env) => env.GITHUB_ACTIONS === "true";
|
|
10
|
+
/** `::error file=…,line=…,title=…::message`, escaped as the runner expects. */
|
|
11
|
+
export function annotation(level, message, props = {}) {
|
|
12
|
+
const prop = (v) => v.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A").replace(/:/g, "%3A").replace(/,/g, "%2C");
|
|
13
|
+
const data = message.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
|
|
14
|
+
const list = Object.entries(props)
|
|
15
|
+
.filter(([, v]) => v !== undefined && v !== "")
|
|
16
|
+
.map(([k, v]) => `${k}=${prop(String(v))}`)
|
|
17
|
+
.join(",");
|
|
18
|
+
return `::${level}${list ? ` ${list}` : ""}::${data}`;
|
|
19
|
+
}
|
|
20
|
+
/** Paths in annotations are relative to the repository checkout. */
|
|
21
|
+
export function repoPath(file, env = process.env) {
|
|
22
|
+
return path.relative(env.GITHUB_WORKSPACE ?? process.cwd(), file).replace(/\\/g, "/");
|
|
23
|
+
}
|
|
24
|
+
/** Called in a worker: one JSON line per failed YAML step, printed by the main process at the end. */
|
|
25
|
+
export async function recordYamlFailure(dir, failure) {
|
|
26
|
+
await appendFile(path.join(dir, `${process.pid}.jsonl`), `${JSON.stringify(failure)}\n`);
|
|
27
|
+
}
|
|
28
|
+
export async function yamlFailures(dir) {
|
|
29
|
+
const out = [];
|
|
30
|
+
for (const f of await readdir(dir).catch(() => [])) {
|
|
31
|
+
for (const line of (await readFile(path.join(dir, f), "utf8")).split("\n"))
|
|
32
|
+
if (line)
|
|
33
|
+
out.push(JSON.parse(line));
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
export function failureAnnotations(failures, env = process.env) {
|
|
38
|
+
return failures.map((f) => annotation("error", f.message, { file: repoPath(f.file, env), line: f.line, title: `${f.scenario}: ${f.step}` }));
|
|
39
|
+
}
|
|
40
|
+
export function failureSummary(failures, env = process.env) {
|
|
41
|
+
if (failures.length === 0)
|
|
42
|
+
return "";
|
|
43
|
+
const cell = (s) => s.replace(/\|/g, "\\|").replace(/\r?\n/g, "<br>");
|
|
44
|
+
return [
|
|
45
|
+
`### slicetest: ${failures.length} failed YAML step(s)`,
|
|
46
|
+
"",
|
|
47
|
+
"| Where | Scenario | Step | Error |",
|
|
48
|
+
"|---|---|---|---|",
|
|
49
|
+
...failures.map((f) => `| \`${repoPath(f.file, env)}:${f.line}\` | ${cell(f.scenario)} | ${cell(f.step)} | ${cell(f.message.split("\n")[0].slice(0, 300))} |`),
|
|
50
|
+
"",
|
|
51
|
+
].join("\n");
|
|
52
|
+
}
|
|
53
|
+
export async function appendSummary(markdown, env = process.env) {
|
|
54
|
+
if (!markdown || !env.GITHUB_STEP_SUMMARY)
|
|
55
|
+
return;
|
|
56
|
+
await appendFile(env.GITHUB_STEP_SUMMARY, `${markdown}\n`).catch(() => { });
|
|
57
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -12,22 +12,38 @@ 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]
|
|
16
|
+
slicetest doctor [--config <file>]
|
|
17
|
+
slicetest record [--out <file>] [--port <n>]
|
|
15
18
|
|
|
16
19
|
Runs *.scenario.yaml files against your app, as configured in slicetest.config.yaml.
|
|
17
20
|
\`slicetest init\` looks at the project and writes a starting config and scenario.
|
|
21
|
+
\`slicetest gen\` writes scenario skeletons for the documented responses of the
|
|
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.
|
|
18
27
|
|
|
19
28
|
Options:
|
|
20
29
|
-c, --config <file> Config file (default: ${CONFIG_NAMES.join(" / ")} in the current directory)
|
|
21
30
|
-w, --watch Re-run on changes
|
|
22
31
|
-t, --name <pattern> Only run scenarios whose name matches
|
|
23
|
-
--
|
|
32
|
+
--spec <file> gen: OpenAPI file (default: \`openapi\` from the config)
|
|
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)
|
|
36
|
+
--uncovered gen: only responses the last run didn't cover
|
|
37
|
+
--force init, gen: overwrite existing files
|
|
24
38
|
-h, --help Show this help
|
|
25
39
|
|
|
26
40
|
Config (paths are relative to the config file):
|
|
27
41
|
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 }]
|
|
42
|
+
db: { migrate: { atlas: { dir } } | { sql } | { command, inputs }, engine, seed, url, image, schemas, keep, reuse }
|
|
43
|
+
stubs: [name | { name, openapi, autoReply, upstream, recordings }]
|
|
30
44
|
services: { name: { command, env, cwd, ready } }
|
|
45
|
+
containers: { name: { image, port, env, command, ready: { log }, reset } }
|
|
46
|
+
mail: true SMTP server at {{mail.host}} / {{mail.port}}
|
|
31
47
|
openapi: file | { spec, minCoverage }
|
|
32
48
|
http: { headers, query }
|
|
33
49
|
include: [globs] default ["**/*.scenario.{yaml,yml}"]
|
|
@@ -42,6 +58,10 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
42
58
|
name: { type: "string", short: "t" },
|
|
43
59
|
help: { type: "boolean", short: "h" },
|
|
44
60
|
force: { type: "boolean" },
|
|
61
|
+
spec: { type: "string" },
|
|
62
|
+
out: { type: "string" },
|
|
63
|
+
uncovered: { type: "boolean" },
|
|
64
|
+
port: { type: "string" },
|
|
45
65
|
},
|
|
46
66
|
});
|
|
47
67
|
if (values.help) {
|
|
@@ -57,12 +77,41 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
57
77
|
const configPath = values.config
|
|
58
78
|
? path.resolve(values.config)
|
|
59
79
|
: CONFIG_NAMES.map((n) => path.resolve(n)).find((p) => existsSync(p));
|
|
80
|
+
if (positionals[0] === "doctor") {
|
|
81
|
+
const { doctor, formatChecks } = await import("./doctor.js");
|
|
82
|
+
const checks = await doctor(configPath);
|
|
83
|
+
process.stdout.write(formatChecks(checks));
|
|
84
|
+
if (checks.some((c) => c.status === "fail"))
|
|
85
|
+
process.exitCode = 1;
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (positionals[0] === "gen") {
|
|
89
|
+
const configOpenapi = configPath && existsSync(configPath) ? (parse(await readFile(configPath, "utf8")) ?? {}).openapi : undefined;
|
|
90
|
+
const spec = values.spec ?? (typeof configOpenapi === "object" ? configOpenapi.spec : configOpenapi);
|
|
91
|
+
if (!spec)
|
|
92
|
+
throw new Error("slicetest gen: no OpenAPI spec. Pass --spec openapi.yaml, or set `openapi` in the config.");
|
|
93
|
+
const root = values.spec || !configPath ? process.cwd() : path.dirname(configPath);
|
|
94
|
+
const { gen } = await import("./gen.js");
|
|
95
|
+
const { written, skipped, count } = await gen(root, { spec, out: values.out, uncovered: values.uncovered, force: values.force });
|
|
96
|
+
const lines = [
|
|
97
|
+
count === 0 ? "Every documented response is already covered; nothing to generate." : `${count} scenario(s) for the responses in ${spec}.`,
|
|
98
|
+
...written.map((f) => ` wrote ${f}`),
|
|
99
|
+
...skipped.map((f) => ` skipped ${f} (exists; --force to overwrite)`),
|
|
100
|
+
];
|
|
101
|
+
process.stdout.write(`${lines.join("\n")}\n`);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
60
104
|
if (!configPath || !existsSync(configPath)) {
|
|
61
105
|
process.stderr.write(`slicetest: no config found. Run "npx slicetest init" to create ${CONFIG_NAMES[0]} (see --help).\n`);
|
|
62
106
|
process.exitCode = 1;
|
|
63
107
|
return;
|
|
64
108
|
}
|
|
65
109
|
const { include, ...options } = (parse(await readFile(configPath, "utf8")) ?? {});
|
|
110
|
+
if (positionals[0] === "record") {
|
|
111
|
+
const { record } = await import("./record-cli.js");
|
|
112
|
+
await record(configPath, options, { out: values.out, port: values.port ? Number(values.port) : 0 });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
66
115
|
const { startVitest } = await import("vitest/node");
|
|
67
116
|
const { slicetest, YAML_SCENARIOS } = await import("./vitest.js");
|
|
68
117
|
const vitest = await startVitest(positionals, {
|
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,48 @@ 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
|
+
* Catch the mail the app sends. An SMTP server (no TLS, any credentials)
|
|
42
|
+
* listens at `{{mail.host}}` / `{{mail.port}}` (`{{mail.url}}` is `smtp://host:port`);
|
|
43
|
+
* scenarios read what arrived with `mail.messages()` / `mail.waitFor()`.
|
|
44
|
+
*/
|
|
45
|
+
mail?: boolean;
|
|
46
|
+
}
|
|
47
|
+
export interface ContainerOptions {
|
|
48
|
+
image: string;
|
|
49
|
+
/** The port the service listens on inside the container. */
|
|
50
|
+
port: number;
|
|
51
|
+
env?: Record<string, string>;
|
|
52
|
+
command?: string[];
|
|
53
|
+
/** Wait for this log line instead of the port accepting connections. */
|
|
54
|
+
ready?: {
|
|
55
|
+
log: string;
|
|
56
|
+
};
|
|
57
|
+
/** Command run inside the container before each scenario, e.g. `["redis-cli", "FLUSHALL"]`. */
|
|
58
|
+
reset?: string[];
|
|
59
|
+
}
|
|
60
|
+
export interface StubOptions {
|
|
61
|
+
name: string;
|
|
62
|
+
/** The provider's OpenAPI spec: the app's calls and the stub's replies are checked against it. */
|
|
63
|
+
openapi?: string;
|
|
64
|
+
/** Answer calls no route matches from the spec's examples or schemas. Needs `openapi`. */
|
|
65
|
+
autoReply?: boolean;
|
|
66
|
+
/**
|
|
67
|
+
* The real service's base URL, e.g. `https://api.github.com`. Calls no route
|
|
68
|
+
* matches are answered from `recordings`; run with `SLICETEST_RECORD=<name>`
|
|
69
|
+
* (or `=1` for every stub) to forward the ones without a recording to this
|
|
70
|
+
* URL and record the answers.
|
|
71
|
+
*/
|
|
72
|
+
upstream?: string;
|
|
73
|
+
/** Recordings file, relative to the root. Default `recordings/<name>.yaml`. */
|
|
74
|
+
recordings?: string;
|
|
37
75
|
}
|
|
38
76
|
export interface ServiceOptions extends Omit<AppOptions, "ready"> {
|
|
39
77
|
/** Default: no wait (for workers that don't listen). `{ path }` polls the service's own port. */
|
|
@@ -69,10 +107,17 @@ export interface AppOptions {
|
|
|
69
107
|
readyTimeout?: number;
|
|
70
108
|
}
|
|
71
109
|
export interface DbOptions {
|
|
72
|
-
/**
|
|
110
|
+
/**
|
|
111
|
+
* `postgres` (default), `mysql` or `sqlite`. Inferred from `url` when it starts with `mysql://`.
|
|
112
|
+
* MySQL needs the `mysql2` package, and `@testcontainers/mysql` unless `url` is given.
|
|
113
|
+
* SQLite needs Node.js 22.5+ and nothing else: no server, no container. The app gets
|
|
114
|
+
* `{{db.url}}` as `sqlite:///path/to/file.db` and `{{db.path}}` as the file path.
|
|
115
|
+
*/
|
|
116
|
+
engine?: "postgres" | "mysql" | "sqlite";
|
|
117
|
+
/** Image used when no `url` is given. Default `postgres:17-alpine`, or `mysql:8.4` for MySQL. */
|
|
73
118
|
image?: string;
|
|
74
119
|
/**
|
|
75
|
-
* Use an existing
|
|
120
|
+
* Use an existing server instead of starting a container. Must point at a superuser (root) connection.
|
|
76
121
|
* Defaults to the `SLICETEST_DATABASE_URL` environment variable, which is handy in CI.
|
|
77
122
|
*/
|
|
78
123
|
url?: string;
|
|
@@ -113,7 +158,9 @@ export interface ResolvedOptions {
|
|
|
113
158
|
ready: ResolvedReady;
|
|
114
159
|
};
|
|
115
160
|
services: Record<string, ResolvedProcess>;
|
|
116
|
-
|
|
161
|
+
containers: Record<string, ContainerOptions>;
|
|
162
|
+
mail: boolean;
|
|
163
|
+
db: Required<Pick<DbOptions, "engine" | "image" | "schemas" | "keep" | "reuse">> & Omit<DbOptions, "engine" | "image" | "schemas" | "keep" | "reuse">;
|
|
117
164
|
stubs: string[];
|
|
118
165
|
/** Spec files, resolved against the root: the app's, and per stub name. */
|
|
119
166
|
openapi: {
|
|
@@ -122,6 +169,12 @@ export interface ResolvedOptions {
|
|
|
122
169
|
stubs: Record<string, string>;
|
|
123
170
|
autoReply: string[];
|
|
124
171
|
};
|
|
172
|
+
/** Stubs backed by recordings of a real service; `record` when SLICETEST_RECORD selects them. */
|
|
173
|
+
recordings: Record<string, {
|
|
174
|
+
file: string;
|
|
175
|
+
upstream: string;
|
|
176
|
+
record: boolean;
|
|
177
|
+
}>;
|
|
125
178
|
http?: RequestOptions;
|
|
126
179
|
}
|
|
127
180
|
export declare function resolveOptions(opts: SlicetestOptions, root: string): ResolvedOptions;
|