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/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";
@@ -27,10 +27,30 @@ No browser, no mocked database, no hooks inside your app. The app only has to re
27
27
 
28
28
  - **Unit tests** mock the database and the network, so broken SQL, migrations and request payloads slip through.
29
29
  - **End-to-end tests** drive a browser against a deployed stack. They are slow and hard to make deterministic.
30
- - **slicetest** keeps the real HTTP server, the real SQL and the real migrations, and replaces only the things you don't own: third-party APIs.
30
+ - **slicetest** keeps the real HTTP server, the real SQL and the real migrations, and replaces only the things you don't own: third-party APIs. With OpenAPI specs, it also checks that those replacements behave like the real thing.
31
31
 
32
32
  The database is reset between scenarios with a single `TRUNCATE ... RESTART IDENTITY CASCADE` (about 1.5 ms). The app keeps its connections, so this works with any driver or ORM. Resetting by dropping and re-creating the database takes about 130 ms, and it crashed some apps when their pooled connections were cut.
33
33
 
34
+ ## Quick start
35
+
36
+ ```sh
37
+ npx slicetest init # detects your stack, writes slicetest.config.yaml and a first scenario
38
+ npx slicetest # starts Postgres, migrates, starts your app, runs scenarios/*.scenario.yaml
39
+ ```
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`. 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
+
43
+ ## What you get that's hard to find elsewhere
44
+
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
+ - **`db.changes()`**: a diff of every row the scenario inserted, updated or deleted. `toEqual` on it catches writes you didn't expect.
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
+ - **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.
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.
53
+
34
54
  ## Install
35
55
 
36
56
  ```sh
@@ -39,6 +59,16 @@ npm i -D slicetest vitest
39
59
 
40
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.
41
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
+
42
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`.
43
73
 
44
74
  ## Configure
@@ -73,11 +103,11 @@ export default defineConfig({
73
103
 
74
104
  ### How a run works
75
105
 
76
- 1. **Once per run.** slicetest starts `postgres:17-alpine` and migrates a template database.
106
+ 1. **Once per run.** slicetest starts `postgres:17-alpine` and migrates a template database. Locally, the container is kept running and the migrated template is cached by the contents of your migrations, so the next run with unchanged migrations skips both steps (see `db.reuse`).
77
107
  2. **Once per worker.** It clones the template into the worker's own database.
78
- 3. **Once per test file.** It starts the stub servers and your app.
79
- 4. **Before each scenario.** It truncates every table except migration bookkeeping tables (`atlas_schema_revisions`, `_prisma_migrations`, `alembic_version`, `django_migrations`, …) and extension-owned tables such as PostGIS's `spatial_ref_sys`, re-runs the seed, and clears the stubs, cookies and request history. If the app crashed in the previous scenario, it is restarted.
80
- 5. **After each scenario.** The scenario fails if the app crashed or called a stub route you didn't register.
108
+ 3. **Once per test file.** It starts the stub servers, your `services` and your app.
109
+ 4. **Before each scenario.** It truncates every table except migration bookkeeping tables (`atlas_schema_revisions`, `_prisma_migrations`, `alembic_version`, `django_migrations`, …) and extension-owned tables such as PostGIS's `spatial_ref_sys`, re-runs the seed, and clears the stubs, cookies and request history. If the app or a service crashed in the previous scenario, it is restarted.
110
+ 5. **After each scenario.** The scenario fails if the app or a service crashed, the app called a stub route you didn't register, or (with `openapi`) any traffic didn't match the spec.
81
111
 
82
112
  Database names are unique per run, so several projects or CI jobs can share one Postgres server via `db.url`.
83
113
 
@@ -94,16 +124,22 @@ stub calls with no matching route:
94
124
  requests to the app:
95
125
  POST /signup → 500 (14ms) {"error":"internal"}
96
126
 
127
+ database changes during this scenario:
128
+ users: 1 inserted
129
+ + {"id":1,"email":"a@example.com","verified":false}
130
+ audit_log: 1 updated
131
+ ~ id=7 status: "pending" → "failed"
132
+
97
133
  app output during this scenario:
98
134
  TypeError: Cannot read properties of undefined (reading 'email')
99
135
  -----------------
100
136
  ```
101
137
 
102
- Only this scenario's app output is shown, not the whole log. Requests that never got a response (for example because the app crashed) appear as `failed`.
138
+ Only this scenario's app output is shown, not the whole log. The database section is a diff against the state right after the reset and seed, so you see what the app actually wrote. Requests that never got a response (for example because the app crashed) appear as `failed`.
103
139
 
104
140
  ## API
105
141
 
106
- Every scenario receives `{ http, db, stub, app }`.
142
+ Every scenario receives `{ http, db, stub, app, service }`.
107
143
 
108
144
  ### `http` — talk to the app
109
145
 
@@ -132,7 +168,25 @@ await db.sql`SELECT * FROM users WHERE id = ${user.id}`; // values beco
132
168
  await db.query("UPDATE users SET name = $1", ["b"]);
133
169
  ```
134
170
 
135
- In `where`, `null` means `IS NULL` and an array means `IN (...)`. `bigint` columns (bigserial ids, `count(*)`) come back as numbers when they fit safely.
171
+ In `where`, `null` means `IS NULL` and an array means `IN (...)`.
172
+
173
+ #### `db.changes()` — assert on everything the app wrote
174
+
175
+ Instead of guessing which tables to query, ask for the diff. Rows are matched by primary key, so updates show which columns changed:
176
+
177
+ ```ts
178
+ await db.insert("users", { email: "a@example.com" });
179
+ await db.checkpoint(); // ignore what the test itself arranged
180
+
181
+ await http.post("/users/1/verify");
182
+
183
+ expect(await db.changes()).toEqual({
184
+ users: { inserted: [], deleted: [], updated: [expect.objectContaining({ changed: ["verified"] })] },
185
+ audit_log: { inserted: [expect.objectContaining({ action: "verify" })], updated: [], deleted: [] },
186
+ });
187
+ ```
188
+
189
+ `toEqual` fails if the app wrote to a table you didn't list, which catches unexpected side effects. Each entry in `updated` has `key`, `before`, `after` and `changed`. Tables without a primary key report an update as one deleted row plus one inserted row. `bigint` columns (bigserial ids, `count(*)`) come back as numbers when they fit safely.
136
190
 
137
191
  ### `stub(name)` — fake the services the app calls
138
192
 
@@ -158,6 +212,71 @@ stub("slack").calls("POST", "/hook"); // recorded calls:
158
212
 
159
213
  Later routes win. `path` may also be a RegExp, and `method` may be `*`. Unanswered calls get a `501` and fail the scenario.
160
214
 
215
+ ### OpenAPI contracts — for your app and for the services you stub
216
+
217
+ Point slicetest at OpenAPI 3.0 / 3.1 files and every scenario doubles as a contract test, with no extra assertions:
218
+
219
+ ```ts
220
+ slicetest({
221
+ openapi: "openapi.yaml", // your app's spec
222
+ stubs: ["slack", { name: "stripe", openapi: "specs/stripe.yaml" }], // a provider's spec
223
+ // ...
224
+ });
225
+ ```
226
+
227
+ - **Your app's responses** must be documented (path, method, status) and match the schema.
228
+ - **The app's requests to a stub** must match the provider's spec: required query parameters, content type and request body. Spec paths are matched with or without the server's base path (`/v1`).
229
+ - **Your stubs' replies** must be something the real service could send. A stub that returns `200 { ok: true }` where the provider documents `202 { messageId }` makes tests pass against an API that doesn't exist; slicetest fails the scenario instead.
230
+
231
+ ```
232
+ slicetest: traffic doesn't match the OpenAPI spec:
233
+ app: GET /users/{id} → 200: /id must be integer
234
+ app → mail: POST /mail/send request: body must have required property 'subject'
235
+ stub mail reply (the real service wouldn't answer this way): POST /mail/send responded 200, which specs/mail.yaml doesn't document (documented: 202)
236
+ ```
237
+
238
+ #### `autoReply`: stubs generated from the provider's spec
239
+
240
+ Add `autoReply: true` to a stub with a spec, and calls that no route matches are answered with the provider's documented example, or with values built from the schema (formats such as `email` and `date-time`, enums, `minimum`, `allOf` are respected). Register routes only for what a scenario cares about; a registered route always wins.
241
+
242
+ ```ts
243
+ stubs: [{ name: "stripe", openapi: "specs/stripe.yaml", autoReply: true }],
244
+ ```
245
+
246
+ Calls answered this way have `call.fallback === true`. Paths that aren't in the spec still get a 501 and fail the scenario.
247
+
248
+ At the end of the run, slicetest prints which documented responses your scenarios actually produced, merged across workers and across TypeScript and YAML scenarios:
249
+
250
+ ```
251
+ slicetest: OpenAPI coverage (openapi.yaml): 8/9 documented responses (89%)
252
+ GET /health 200 ✓
253
+ POST /polls 201 ✓ 400 ✓ 502 ✓
254
+ GET /polls/{id} 200 ✓ 404 ✗
255
+ POST /polls/{id}/votes 204 ✓ 400 ✓ 404 ✓
256
+ ```
257
+
258
+ To fail the run below a threshold, use `openapi: { spec: "openapi.yaml", minCoverage: 100 }`. Filtered runs (`-t`, a single file) count too, so you may want `minCoverage: process.env.CI ? 100 : undefined`.
259
+
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`.
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
+
161
280
  ### Matchers
162
281
 
163
282
  Registered automatically:
@@ -173,10 +292,85 @@ await expect(db).toHaveRow("votes", { poll_id: 1 }, 3); // exactly thre
173
292
 
174
293
  Failure messages list the calls the stub actually received, or the first rows of the table.
175
294
 
295
+ ### Services: workers and other processes
296
+
297
+ Real apps are rarely one process. Declare the others under `services` and slicetest starts them before the app (in order), watches them like the app, and restarts one that crashed — on the same port, so URLs handed to other processes stay valid:
298
+
299
+ ```ts
300
+ slicetest({
301
+ services: {
302
+ pricing: { command: "go run ./cmd/pricing", ready: { path: "/health" } }, // another HTTP service
303
+ worker: { command: "bundle exec sidekiq" }, // no port, no ready check
304
+ },
305
+ app: {
306
+ command: "node server.js",
307
+ env: { PORT: "{{app.port}}", DATABASE_URL: "{{db.url}}", PRICING_URL: "{{service.pricing}}" },
308
+ },
309
+ });
310
+ ```
311
+
312
+ Each service gets `PORT` = `{{service.<name>.port}}` and `DATABASE_URL` unless you pass `env`. A crash fails the scenario, and each service's output during the scenario is part of the failure output.
313
+
314
+ ```ts
315
+ scenario("uploading an image queues a thumbnail job", async ({ http, db, service }) => {
316
+ await http.post("/images", { url: "https://example.com/cat.png" });
317
+
318
+ await service("worker").waitForLog(/thumbnail \d+ done/); // this scenario's output only
319
+ await expect(db).toHaveRow("images", { thumbnail_ready: true });
320
+ });
321
+ ```
322
+
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>`.
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
+
341
+ ### Asynchronous side effects
342
+
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:
344
+
345
+ ```ts
346
+ await vi.waitFor(() => expect(stub("mail")).toHaveReceived("POST", "/send"));
347
+ await expect.poll(() => db.count("jobs", { status: "done" })).toBe(1);
348
+ ```
349
+
350
+ In YAML, add `within: <ms>` to a `db`, `sql`, `received` or `changes` step.
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
+
176
370
  ### Scenarios
177
371
 
178
372
  ```ts
179
- scenario("name", async ({ http, db, stub, app }) => { ... }, timeoutMs?);
373
+ scenario("name", async ({ http, db, stub, app, service, container, trace }) => { ... }, timeoutMs?);
180
374
  scenario.only / scenario.skip / scenario.todo
181
375
  scenario.each([{ choice: "a", status: 204 }, { choice: "x", status: 400 }])(
182
376
  "voting $choice returns $status",
@@ -195,13 +389,18 @@ Scenarios in one file share an app and a database, so they always run one at a t
195
389
  | `app.cwd` | vitest root | |
196
390
  | `app.ready` | `{ path: "/" }` | Poll a path until it answers below 500, or `{ log: "listening" \| /regex/ }`. |
197
391
  | `app.readyTimeout` | `30000` | |
198
- | `db.migrate` | none | `{ atlas: { dir } }`, `{ sql: "file-or-dir" }` or `{ command }` (gets `DATABASE_URL`). |
392
+ | `db.engine` | `postgres`, or `mysql` for a `mysql://` URL | `postgres` or `mysql` (see [MySQL](#mysql)). |
393
+ | `db.migrate` | none | `{ atlas: { dir } }`, `{ sql: "file-or-dir" }` or `{ command, inputs? }` (gets `DATABASE_URL`). |
199
394
  | `db.seed` | none | SQL file re-run after every reset. |
200
395
  | `db.schemas` | `["public"]` | Schemas whose tables are reset. |
201
396
  | `db.keep` | `[]` | Extra tables (`name` or `schema.name`) never truncated. |
202
397
  | `db.url` | `$SLICETEST_DATABASE_URL`, else a container | Use an existing Postgres server (e.g. a CI service container) instead of Testcontainers. |
203
- | `db.image` | `postgres:17-alpine` | |
204
- | `stubs` | `[]` | Names of stubbed services. |
398
+ | `db.image` | `postgres:17-alpine` / `mysql:8.4` | |
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). |
401
+ | `services` | `{}` | Other processes: `{ name: { command, env?, cwd?, ready?, readyTimeout? } }`. Without `ready` a service is not waited for. |
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. |
403
+ | `openapi` | none | The app's OpenAPI 3 spec, or `{ spec, minCoverage }`. Every response must match it; the run ends with a coverage report. |
205
404
  | `http` | `{}` | Default `headers` / `query` for every request. |
206
405
 
207
406
  The config is validated up front: a missing `app.command`, an ambiguous `db.migrate` or a duplicate stub name fails with a clear message instead of a timeout.
@@ -237,6 +436,9 @@ scenarios:
237
436
  when:
238
437
  json: { text: "New poll: Dogs or cats?" }
239
438
 
439
+ - changes: # and nothing else was written
440
+ polls: { inserted: 1 }
441
+
240
442
  - name: voting {{choice}} returns {{status}}
241
443
  each:
242
444
  - { choice: a, status: 204 }
@@ -255,6 +457,12 @@ scenarios:
255
457
  | `db: <table>` | `where`, `orderBy`, `expect: { rows, count }`, `capture` |
256
458
  | `sql: <query>` | `params`, `expect: { rows, count }`, `capture` |
257
459
  | `received: <stub>` | `call: METHOD /path`, `when`, `times` (exact; default at least once) |
460
+ | `log: <regex>` | `from` (a service; default the app), `within` (ms, default 5000). Waits for a matching line printed during the scenario. |
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. |
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. |
464
+
465
+ `db`, `sql`, `received` and `changes` steps take `within: <ms>` to retry until they pass, for effects the app applies asynchronously.
258
466
 
259
467
  - `{{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.
260
468
  - Expected `json`, `rows` and `headers` are subsets: extra keys are fine. `{ $type: number }`, `{ $regex: "^ch_" }`, `{ $contains: "..." }` and `{ $any: true }` match loosely.
@@ -282,6 +490,17 @@ npx slicetest polls -t voting # filter by file and scenario name
282
490
  npx slicetest --watch
283
491
  ```
284
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
+
285
504
  ## Examples
286
505
 
287
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**:
@@ -291,6 +510,10 @@ npm test # unit tests + both example apps
291
510
  npm run test:dist # the built package, and the CLI with examples/slicetest.config.yaml
292
511
  ```
293
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
+
294
517
  ## Status
295
518
 
296
- Early prototype. Postgres only. CI runs on Linux and Windows. Planned: container reuse across runs, MySQL.
519
+ Early. Postgres and MySQL. CI runs on Linux and Windows.
package/dist/app.d.ts CHANGED
@@ -1,24 +1,47 @@
1
- import type { ResolvedOptions } from "./config.js";
1
+ import type { ResolvedProcess } from "./config.js";
2
+ type ProcessOptions = ResolvedProcess;
2
3
  type Exit = {
3
4
  code: number | null;
4
5
  signal: NodeJS.Signals | null;
5
6
  error?: Error;
6
7
  };
7
- /** The app under test, running as a real child process. */
8
+ /** The app under test (or one of its `services`), running as a real child process. */
8
9
  export declare class App {
9
10
  #private;
11
+ readonly port: number;
12
+ /** `app`, or `service <name>`, for messages. */
13
+ readonly label: string;
10
14
  readonly url: string;
11
15
  private constructor();
12
- static start(opts: ResolvedOptions["app"], root: string, vars: Record<string, string>): Promise<App>;
16
+ /**
17
+ * `key` names the process in placeholders and messages: `app`, or
18
+ * `service.<name>` for a service (its port is then `{{service.<name>.port}}`).
19
+ */
20
+ static start(opts: ProcessOptions, root: string, vars: Record<string, string>, key?: string, fixedPort?: number): Promise<App>;
13
21
  get exited(): Exit | undefined;
14
22
  /**
15
23
  * The exit event is delivered asynchronously, so a crash caused by the last
16
24
  * request may not be visible yet. Give it a moment before trusting `exited`.
25
+ * Windows reports it later (the process runs under cmd.exe), so it waits longer.
17
26
  */
18
27
  settle(ms?: number): Promise<Exit | undefined>;
19
28
  /** The app's recent output (last 200 lines), or only what it printed after `since` (a value from `mark()`). */
20
29
  logs(since?: number): string;
21
30
  mark(): number;
31
+ /** What the process printed during the current scenario. */
32
+ scenarioLogs(): string;
33
+ /** Called before each scenario; `waitForLog()` only looks at output after this point. */
34
+ beginScenario(): number;
35
+ /**
36
+ * Wait until the process prints a line matching `pattern` during the current
37
+ * scenario (lines printed earlier in the scenario count). Resolves with the line.
38
+ *
39
+ * ```ts
40
+ * await http.post("/orders", { ... });
41
+ * await service("worker").waitForLog(/order \d+ shipped/);
42
+ * ```
43
+ */
44
+ waitForLog(pattern: string | RegExp, timeout?: number): Promise<string>;
22
45
  /**
23
46
  * POSIX: SIGTERM the whole process group, then SIGKILL whatever is left (including orphaned grandchildren).
24
47
  * Windows has no signals to ask politely with, so the tree is terminated at once.
package/dist/app.js CHANGED
@@ -7,19 +7,27 @@ const LOG_LINES = 200;
7
7
  const DRAIN_MS = 100;
8
8
  const KILL_GRACE_MS = 3000;
9
9
  const WINDOWS = process.platform === "win32";
10
- /** The app under test, running as a real child process. */
10
+ /** The app under test (or one of its `services`), running as a real child process. */
11
11
  export class App {
12
- url;
12
+ port;
13
+ label;
13
14
  #child;
14
15
  #log = [];
15
16
  /** Lines ever received, so callers can ask for output since a point in time. */
16
17
  #lineCount = 0;
17
18
  #exit;
18
19
  #exited;
19
- #onLine;
20
- constructor(child, url) {
21
- this.url = url;
20
+ #listeners = new Set();
21
+ /** `mark()` at the start of the current scenario. */
22
+ #scenarioMark = 0;
23
+ url;
24
+ constructor(child, port,
25
+ /** `app`, or `service <name>`, for messages. */
26
+ label) {
27
+ this.port = port;
28
+ this.label = label;
22
29
  this.#child = child;
30
+ this.url = `http://127.0.0.1:${port}`;
23
31
  for (const stream of [child.stdout, child.stderr]) {
24
32
  // Keep partial lines (and split multi-byte characters) until the rest arrives.
25
33
  const decoder = new StringDecoder("utf8");
@@ -31,7 +39,8 @@ export class App {
31
39
  this.#lineCount++;
32
40
  if (this.#log.length > LOG_LINES)
33
41
  this.#log.shift();
34
- this.#onLine?.(line);
42
+ for (const listener of this.#listeners)
43
+ listener(line);
35
44
  };
36
45
  stream.on("data", (chunk) => {
37
46
  const lines = (partial + decoder.write(chunk)).split(/\r?\n/);
@@ -52,21 +61,26 @@ export class App {
52
61
  child.on("close", (code, signal) => done({ code, signal }));
53
62
  });
54
63
  }
55
- static async start(opts, root, vars) {
56
- const port = await freePort();
57
- vars = { ...vars, "app.port": String(port) };
58
- const env = opts.env ?? { PORT: "{{app.port}}", DATABASE_URL: "{{db.url}}" };
59
- const child = spawn(interpolate(opts.command, vars, "app.command"), {
64
+ /**
65
+ * `key` names the process in placeholders and messages: `app`, or
66
+ * `service.<name>` for a service (its port is then `{{service.<name>.port}}`).
67
+ */
68
+ static async start(opts, root, vars, key = "app", fixedPort) {
69
+ // A restarted service keeps its port, so URLs already handed to other processes stay valid.
70
+ const port = fixedPort ?? (await freePort());
71
+ vars = { ...vars, [`${key}.port`]: String(port) };
72
+ const env = opts.env ?? { PORT: `{{${key}.port}}`, DATABASE_URL: "{{db.url}}" };
73
+ const child = spawn(interpolate(opts.command, vars, `${key}.command`), {
60
74
  shell: true,
61
75
  cwd: path.resolve(root, opts.cwd ?? "."),
62
- env: { ...process.env, ...mapValues(env, (v) => interpolate(v, vars, "app.env")) },
76
+ env: { ...process.env, ...mapValues(env, (v) => interpolate(v, vars, `${key}.env`)) },
63
77
  stdio: ["ignore", "pipe", "pipe"],
64
78
  // POSIX: own process group, so stop() also kills whatever the shell spawned.
65
79
  // Windows: detaching would open a console window; taskkill /T walks the tree instead.
66
80
  detached: !WINDOWS,
67
81
  windowsHide: true,
68
82
  });
69
- const app = new App(child, `http://127.0.0.1:${port}`);
83
+ const app = new App(child, port, key === "app" ? "app" : key.replace(/^service\./, "service "));
70
84
  running.add(app);
71
85
  try {
72
86
  await app.#waitReady(opts);
@@ -83,8 +97,9 @@ export class App {
83
97
  /**
84
98
  * The exit event is delivered asynchronously, so a crash caused by the last
85
99
  * request may not be visible yet. Give it a moment before trusting `exited`.
100
+ * Windows reports it later (the process runs under cmd.exe), so it waits longer.
86
101
  */
87
- async settle(ms = 20) {
102
+ async settle(ms = WINDOWS ? 100 : 20) {
88
103
  if (this.#exit)
89
104
  return this.#exit;
90
105
  let timer;
@@ -102,6 +117,44 @@ export class App {
102
117
  mark() {
103
118
  return this.#lineCount;
104
119
  }
120
+ /** What the process printed during the current scenario. */
121
+ scenarioLogs() {
122
+ return this.logs(this.#scenarioMark);
123
+ }
124
+ /** Called before each scenario; `waitForLog()` only looks at output after this point. */
125
+ beginScenario() {
126
+ this.#scenarioMark = this.#lineCount;
127
+ return this.#scenarioMark;
128
+ }
129
+ /**
130
+ * Wait until the process prints a line matching `pattern` during the current
131
+ * scenario (lines printed earlier in the scenario count). Resolves with the line.
132
+ *
133
+ * ```ts
134
+ * await http.post("/orders", { ... });
135
+ * await service("worker").waitForLog(/order \d+ shipped/);
136
+ * ```
137
+ */
138
+ async waitForLog(pattern, timeout = 5000) {
139
+ const re = typeof pattern === "string" ? new RegExp(escapeRegExp(pattern)) : new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, ""));
140
+ const n = Math.min(this.#lineCount - this.#scenarioMark, this.#log.length);
141
+ const seen = n > 0 ? this.#log.slice(-n).find((l) => re.test(l)) : undefined;
142
+ if (seen !== undefined)
143
+ return seen;
144
+ return new Promise((resolve, reject) => {
145
+ const done = (fn) => {
146
+ clearTimeout(timer);
147
+ this.#listeners.delete(listener);
148
+ fn();
149
+ };
150
+ const listener = (line) => {
151
+ if (re.test(line))
152
+ done(() => resolve(line));
153
+ };
154
+ const timer = setTimeout(() => done(() => reject(new Error(`slicetest: ${this.label} printed no line matching ${re} within ${timeout}ms. Output during this scenario:\n${this.logs(this.#scenarioMark) || "(none)"}`))), timeout);
155
+ this.#listeners.add(listener);
156
+ });
157
+ }
105
158
  /**
106
159
  * POSIX: SIGTERM the whole process group, then SIGKILL whatever is left (including orphaned grandchildren).
107
160
  * Windows has no signals to ask politely with, so the tree is terminated at once.
@@ -139,18 +192,23 @@ export class App {
139
192
  const timeout = opts.readyTimeout ?? 30_000;
140
193
  const deadline = Date.now() + timeout;
141
194
  const ready = opts.ready;
195
+ // A service without `ready` (e.g. a queue worker) counts as ready once it's spawned.
196
+ if (!ready)
197
+ return;
142
198
  let logSeen = false;
199
+ let onLine;
143
200
  if ("log" in ready) {
144
201
  // Drop g/y so test() doesn't keep state between lines.
145
202
  const pattern = new RegExp(ready.log, ready.flags.replace(/[gy]/g, ""));
146
203
  logSeen = this.#log.some((l) => pattern.test(l));
147
- this.#onLine = (line) => (logSeen ||= pattern.test(line));
204
+ onLine = (line) => (logSeen ||= pattern.test(line));
205
+ this.#listeners.add(onLine);
148
206
  }
149
207
  try {
150
208
  while (Date.now() < deadline) {
151
209
  if (this.#exit) {
152
210
  const why = this.#exit.error ? this.#exit.error.message : `code ${this.#exit.code}, signal ${this.#exit.signal}`;
153
- throw new Error(`slicetest: app exited before becoming ready (${why})\n${this.logs()}`);
211
+ throw new Error(`slicetest: ${this.label} exited before becoming ready (${why})\n${this.logs()}`);
154
212
  }
155
213
  if ("log" in ready) {
156
214
  if (logSeen)
@@ -168,10 +226,11 @@ export class App {
168
226
  }
169
227
  await new Promise((r) => setTimeout(r, 50));
170
228
  }
171
- throw new Error(`slicetest: app did not become ready within ${timeout}ms\n${this.logs()}`);
229
+ throw new Error(`slicetest: ${this.label} did not become ready within ${timeout}ms\n${this.logs()}`);
172
230
  }
173
231
  finally {
174
- this.#onLine = undefined;
232
+ if (onLine)
233
+ this.#listeners.delete(onLine);
175
234
  }
176
235
  }
177
236
  }
@@ -201,6 +260,9 @@ export function interpolate(template, vars, where = "app.env") {
201
260
  return vars[key];
202
261
  });
203
262
  }
263
+ function escapeRegExp(s) {
264
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
265
+ }
204
266
  function mapValues(obj, fn) {
205
267
  return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, fn(v)]));
206
268
  }