slicetest 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +131 -35
  2. package/dist/app.js +10 -2
  3. package/dist/auth.js +2 -1
  4. package/dist/cli-run.d.ts +53 -0
  5. package/dist/cli-run.js +80 -0
  6. package/dist/cli.js +27 -29
  7. package/dist/config.d.ts +33 -2
  8. package/dist/config.js +121 -2
  9. package/dist/container-runtime.d.ts +4 -0
  10. package/dist/container-runtime.js +16 -0
  11. package/dist/containers.js +4 -3
  12. package/dist/db.d.ts +3 -1
  13. package/dist/db.js +45 -8
  14. package/dist/deadline.d.ts +7 -0
  15. package/dist/deadline.js +24 -0
  16. package/dist/doctor.js +27 -5
  17. package/dist/drivers/driver.d.ts +6 -0
  18. package/dist/drivers/mysql.js +5 -1
  19. package/dist/drivers/postgres.d.ts +2 -0
  20. package/dist/drivers/postgres.js +20 -1
  21. package/dist/exit.d.ts +1 -0
  22. package/dist/exit.js +28 -0
  23. package/dist/form.d.ts +2 -0
  24. package/dist/form.js +62 -9
  25. package/dist/global-setup.js +3 -6
  26. package/dist/har.js +4 -4
  27. package/dist/http.d.ts +38 -4
  28. package/dist/http.js +135 -10
  29. package/dist/init.js +64 -10
  30. package/dist/list.d.ts +1 -0
  31. package/dist/list.js +16 -3
  32. package/dist/mail.d.ts +1 -1
  33. package/dist/mail.js +7 -7
  34. package/dist/matchers.d.ts +18 -6
  35. package/dist/matchers.js +75 -8
  36. package/dist/openapi.d.ts +2 -5
  37. package/dist/openapi.js +146 -20
  38. package/dist/record-cli.js +2 -1
  39. package/dist/record.js +2 -1
  40. package/dist/recording.d.ts +11 -1
  41. package/dist/recording.js +51 -6
  42. package/dist/request-url.d.ts +1 -0
  43. package/dist/request-url.js +5 -0
  44. package/dist/runtime.d.ts +3 -3
  45. package/dist/runtime.js +144 -18
  46. package/dist/scenario.js +11 -2
  47. package/dist/sql-migrations.d.ts +11 -0
  48. package/dist/sql-migrations.js +38 -0
  49. package/dist/stub.d.ts +10 -3
  50. package/dist/stub.js +64 -16
  51. package/dist/vitest.d.ts +4 -0
  52. package/dist/vitest.js +6 -2
  53. package/dist/webhook.d.ts +3 -3
  54. package/dist/webhook.js +10 -2
  55. package/dist/yaml-runtime.d.ts +4 -4
  56. package/dist/yaml-runtime.js +141 -38
  57. package/dist/yaml.d.ts +12 -2
  58. package/dist/yaml.js +47 -4
  59. package/docs/guarantees.md +92 -0
  60. package/docs/state-isolation.md +94 -0
  61. package/package.json +4 -1
  62. package/schema/config.schema.json +675 -0
  63. package/schema/scenario.schema.json +97 -7
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  [![CI](https://github.com/revo1290/slicetest/actions/workflows/ci.yml/badge.svg)](https://github.com/revo1290/slicetest/actions/workflows/ci.yml) [![npm](https://img.shields.io/npm/v/slicetest)](https://www.npmjs.com/package/slicetest) [![license](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
4
4
 
5
+ [Report a bug / 不具合報告](https://github.com/revo1290/slicetest/issues/new/choose) · [Contributing / 貢献ガイド](CONTRIBUTING.md)
6
+
5
7
  Tests that sit between unit tests and end-to-end tests, for apps written in any language or framework.
6
8
 
7
9
  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:
@@ -21,15 +23,37 @@ scenario("creating a poll stores it and notifies Slack", async ({ http, db, stub
21
23
  });
22
24
  ```
23
25
 
24
- No browser, no mocked database, no hooks inside your app. The app only has to read its port, database URL and outbound base URLs from environment variables.
26
+ No browser, no mocked database, no hooks inside your app. The app only has to read its port, database URL and outbound base URLs from environment variables. (State the app keeps in memory is the one thing that may need your help: [state between scenarios](docs/state-isolation.md).)
27
+
28
+ ## Why, and when not to use it
29
+
30
+ Testing an API against a real server, a real database and stubbed third parties is an established approach, and most stacks can do it already ([Spring Boot](https://docs.spring.io/spring-boot/reference/testing/index.html) starts a real server in tests and [integrates with Testcontainers](https://docs.spring.io/spring-boot/reference/testing/testcontainers.html); [Playwright has API testing](https://playwright.dev/docs/api-testing); [WireMock](https://wiremock.org/docs/simulating-faults/) stubs HTTP services and simulates faults). slicetest doesn't claim something only it can do. It bundles the parts you'd otherwise assemble per project, for any language the app is written in: starting the app, the database and the stubs, resetting them between scenarios, checking the three boundaries in one scenario, and explaining a failure.
31
+
32
+ The usual labels mix up three separate questions: is anything mocked, is a real server running, and how much does the test cover. Side by side:
25
33
 
26
- ## Why
34
+ | Approach | Mainly checks | Strong at | Limits |
35
+ |---|---|---|---|
36
+ | Function / class tests | Logic, boundaries, exception branches | Control over internal dependencies | Real connections and SQL need a separate check |
37
+ | API integration tests you already have | HTTP, the app, a real DB, calls to external services | Fit with your existing framework | Starting, resetting and diagnosing is yours to build, depending on the setup |
38
+ | slicetest | The same API-level tests, plus environment management and diagnostics | Several checks in one scenario; the same scenarios for any language | Reaching into the app's internals, or a dependency it doesn't support, takes extra design |
39
+ | Browser E2E | From UI actions through the main flows | What happens in the browser | Poor at controlling fine-grained internal state |
40
+
41
+ What slicetest keeps real: the HTTP server, the SQL and the migrations. What it replaces: services you don't own, with stubs. With OpenAPI specs it also checks that your stubs' replies could come from the real service, within what the spec documents ([what each check does and doesn't guarantee](docs/guarantees.md)).
42
+
43
+ Whether it's worth adopting depends on what you have:
44
+
45
+ | Team or use | Verdict |
46
+ |---|---|
47
+ | No test setup for the API, the DB and external calls | A small trial fits |
48
+ | Several languages, and you want one way to test all of them | A comparatively strong candidate |
49
+ | Failures take long to investigate in logs and the DB | Worth measuring what the combined output saves |
50
+ | Spring (or similar) integration tests that already run stably | Don't plan to replace them; measure what slicetest would add |
51
+ | Many branches of internal logic to cover | Unit tests first |
52
+ | The UI, or a real identity provider or external API | Browser E2E, or a check against the real service, alongside |
27
53
 
28
- - **Unit tests** mock the database and the network, so broken SQL, migrations and request payloads slip through.
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. With OpenAPI specs, it also checks that those replacements behave like the real thing.
54
+ Not measured yet: whether it is faster than other setups, cheaper to maintain, or stable over long use on real projects. The one comparison made so far (the same nine checks on a Spring Boot app, written with JUnit + Testcontainers + WireMock and with slicetest; run times, flaky runs, injected faults, and what each failure shows) is in [examples/comparison](examples/comparison/README.md), with its limits: one machine, one small app, both suites by one author. The speeds quoted below are narrow measurements, with their conditions.
31
55
 
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.
56
+ The database is reset between scenarios with `TRUNCATE ... RESTART IDENTITY CASCADE` over every table, and the app keeps its connections, so this works with any driver or ORM. In the spike that chose this (`TRUNCATE polls RESTART IDENTITY`: one table, no `CASCADE`, 20 runs, median, a Postgres container on a laptop) the reset took 1.5 ms; dropping and re-creating the database took about 130 ms and crashed some apps when their pooled connections were cut. That is the reset step alone, not the time of a test run, and it wasn't measured with more tables. Since the table list is now read at every reset, a later check on three small tables (same machine, 300 resets) took 1.4 ms for the `TRUNCATE` alone and 2.0 ms with the list. **Of the app's own state, only what is in the database is reset by default**: whatever the app keeps in memory or does in the background survives into the next scenario unless you say otherwise ([state between scenarios](docs/state-isolation.md)).
33
57
 
34
58
  ## Quick start
35
59
 
@@ -38,19 +62,19 @@ npx slicetest init # detects your stack, writes slicetest.config.yaml and a fi
38
62
  npx slicetest # starts Postgres, migrates, starts your app, runs scenarios/*.scenario.yaml
39
63
  ```
40
64
 
41
- `init` recognises Node (`npm start`, or Bun, pnpm or Yarn from the lockfile), Deno, Phoenix, Django, FastAPI, Flask, Rails, Laravel, Symfony, plain PHP, Spring Boot, ASP.NET Core, Go and Rust apps; Atlas, Prisma, Alembic, Django, Rails, Laravel, Doctrine, EF Core, Ecto, 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. Token issuer settings there (`OIDC_ISSUER`, `AUTH0_DOMAIN`, `JWKS_URL`, `JWT_AUDIENCE`, …) turn on [`auth`](#auth-a-real-openid-issuer-tokens-with-any-claims) and point at slicetest's issuer instead of becoming stubs. It lists every guess as a comment in the config so you know what to check.
65
+ `init` recognises Node (`npm start`, or Bun, pnpm or Yarn from the lockfile), Deno, Phoenix, Django, FastAPI, Flask, Rails, Laravel, Symfony, plain PHP, Spring Boot, ASP.NET Core, Go (the main package at the root or in `cmd/<name>`) and Rust apps (both compiled once before the workers start); Atlas, Prisma, Alembic, Django, Rails, Laravel, Doctrine, EF Core, Ecto, Drizzle, Knex and plain SQL migrations (golang-migrate, goose, sqlx, Diesel and dbmate layouts in `migrations/`, `db/migrations/` or `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. Token issuer settings there (`OIDC_ISSUER`, `AUTH0_DOMAIN`, `JWKS_URL`, `JWT_AUDIENCE`, …) turn on [`auth`](#auth-a-real-openid-issuer-tokens-with-any-claims) and point at slicetest's issuer instead of becoming stubs. It lists every guess as a comment in the config so you know what to check.
42
66
 
43
- ## What you get that's hard to find elsewhere
67
+ ## What it bundles
44
68
 
45
69
  - **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
70
  - **`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. The run also lists which of the provider's operations the app depends on, flagging deprecated ones.
71
+ - **Stubs checked against the provider's spec.** Give a stub the provider's OpenAPI spec, and a canned reply that contradicts what the spec documents (a status or body the real service doesn't list) fails the test. The spec has to be right and complete for this to mean much. The run also lists which of the provider's operations the app depends on, flagging deprecated ones.
48
72
  - **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`, or import a HAR file saved from the browser, 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.
73
+ - **Record the real service once, replay offline.** Point a stub at the real API with `SLICETEST_RECORD=1`, or import a HAR file saved from the browser, commit the YAML it writes, and later runs are offline and deterministic. A replay is the service as it was when recorded: re-record now and then to notice changes.
74
+ - **OpenAPI coverage** of your own API: which documented responses (operation and status) some scenario produced, across all scenarios. It isn't code or business-rule coverage. `slicetest gen --uncovered` scaffolds scenarios for what's missing.
51
75
  - **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
76
  - **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 and a sequence diagram of each failed scenario.
53
- - **Diagrams that can't go stale.** `--diagrams docs/flows` writes a Mermaid sequence diagram of every scenario (app, stubs, mail, database), regenerated from what really happened on each run.
77
+ - **Diagrams regenerated from each run.** `--diagrams docs/flows` writes a Mermaid sequence diagram of every scenario (app, stubs, mail, database), regenerated from what really happened on each run.
54
78
  - **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.
55
79
  - **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.
56
80
  - **Real token verification, any user.** `auth: true` gives the app an OpenID issuer with a JWKS, so JWT checks stay on in tests, and scenarios mint tokens with any claims, including expired or foreign-signed ones.
@@ -61,7 +85,7 @@ npx slicetest # starts Postgres, migrates, starts your app, runs scenario
61
85
  - **Hard-coded APIs, stubbed anyway.** `hosts: [api.github.com]` catches calls to URLs written in the code or built into a framework, over HTTPS, from Node, Python, Go, Ruby or the JVM, with no change to the app. Redirects to those hosts are followed to the stub, so OAuth logins run end to end.
62
86
  - **N+1 detection for any stack.** A wire-protocol proxy records the SQL the app runs, so query counts are asserted at the HTTP boundary, whatever the ORM or language.
63
87
  - **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.
64
- - **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.
88
+ - **Cheap resets.** `TRUNCATE` between scenarios with the app still running (about 1.5 ms in the spike above), and a cached migrated template, so the second run skips container start-up and migrations. For apps that need their process restarted or their in-memory state cleared too: `restart`, `reset` and `idle` ([state between scenarios](docs/state-isolation.md)).
65
89
 
66
90
  ## Install
67
91
 
@@ -131,11 +155,30 @@ export default defineConfig({
131
155
  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`).
132
156
  2. **Once per worker.** It clones the template into the worker's own database.
133
157
  3. **Once per test file.** It starts the stub servers, your `services` and your app.
134
- 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.
135
- 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.
158
+ 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, or has `restart: scenario`, it is stopped before the reset and started after it.
159
+ 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. With `idle`, it waits for the app's background work to finish before those checks.
136
160
 
137
161
  Database names are unique per run, so several projects or CI jobs can share one Postgres server via `db.url`.
138
162
 
163
+ ### What is reset between scenarios, and what isn't
164
+
165
+ | Reset before every scenario | Kept, unless you configure otherwise |
166
+ |---|---|
167
+ | Database rows (and the seed is re-applied), stubs, recordings, mail, the auth issuer, cookies and request history, containers that have a `reset` command | The app and `services` processes, so **anything the app holds in memory (caches, singletons, timers) and any background work in flight**; containers without `reset`; files, other stores and processes the app uses |
168
+
169
+ So "the database is reset" does not mean every scenario starts from an identical app. An app that caches a query result serves it after the table was emptied, and a write scheduled by one scenario can land in the next one. Three settings deal with that, on `app` and on each of `services`:
170
+
171
+ ```yaml
172
+ app:
173
+ restart: scenario # stop the process before the reset, start it after
174
+ reset: { path: /__test/reset } # or: an endpoint that makes the app drop its caches and timers
175
+ idle: { path: /__test/idle } # and/or: an endpoint that answers 2xx once no background work is left
176
+ ```
177
+
178
+ `reset` and `idle` are requests slicetest sends to the app itself, so the app has to serve them: register those endpoints only in test configuration (an environment variable the test config sets), never in production, since `reset` is an unauthenticated call that wipes state. `restart` costs a process start per scenario, which counts toward the test timeout, and the first scenario of a file restarts the app started moments before.
179
+
180
+ Which one covers what, the order of everything that happens around a scenario, and what stays your job (files, workers, queues) is in [State between scenarios, and running in parallel](docs/state-isolation.md).
181
+
139
182
  ### When a scenario fails
140
183
 
141
184
  slicetest prints what happened during that scenario, next to Vitest's own error:
@@ -181,10 +224,10 @@ await http.submit(await http.get("/signup"), { button: "Sign up", fields: { emai
181
224
  await http.graphql("query Poll($id: ID!) { poll(id: $id) { title } }", { id: 1 }); // POST /graphql ({ path } for another)
182
225
 
183
226
  const admin = http.with({ headers: { authorization: `Bearer ${token}` } }); // shares cookies with http
184
- http.cookies.get("session"); // cookies persist within a scenario
227
+ http.cookies.get("session"); // cookies persist within a scenario, until their Max-Age / Expires
185
228
  ```
186
229
 
187
- Requests may only go to the app under test; absolute URLs to other hosts are rejected. Defaults for every request can be set with `http: { headers, timeout }` in the plugin config. With `timeout` (ms), a request the app doesn't answer in time fails with its method and path instead of the whole test timing out, and a refused or dropped connection says the app isn't listening or closed it. A list in `query` repeats the parameter (`{ tag: ["a", "b"] }` → `?tag=a&tag=b`). With `follow`, cookies set by each redirect are kept (a login answering `302` with `Set-Cookie`), `303` and `301`/`302` turn into a `GET` as in browsers, and a redirect to another host is returned instead of followed. Cookies follow their `Path` as in browsers.
230
+ Requests may only go to the app under test; absolute URLs to other hosts are rejected. Defaults for every request can be set with `http: { headers, timeout }` in the plugin config. With `timeout` (ms), a request the app doesn't answer in time fails with its method and path instead of the whole test timing out, and a refused or dropped connection says the app isn't listening or closed it. A list in `query` repeats the parameter (`{ tag: ["a", "b"] }` → `?tag=a&tag=b`). With `follow`, cookies set by each redirect are kept (a login answering `302` with `Set-Cookie`), a `303` turns the request into a `GET` and a `301`/`302` does so for a `POST` only (as fetch and browsers do; a `PUT` or `DELETE` is repeated with its body), `307`/`308` repeat it, and a redirect to another host is returned instead of followed. Cookies follow their `Path` as in browsers.
188
231
 
189
232
  #### Forms: `http.submit()`
190
233
 
@@ -203,7 +246,7 @@ await http.submit(await http.get("/settings"), {
203
246
 
204
247
  File inputs take a `File` (`fields: { avatar: new File([bytes], "me.png", { type: "image/png" }) }`, YAML `{ file: me.png }` relative to the scenario), and one left alone is sent as the empty file a browser sends; the form must be `multipart/form-data`. An image button (`<input type="image">`) sends its click position.
205
248
 
206
- `button` matches a submit button's text, `value`, `name` or `id`, and picks its form; its `formaction` / `formmethod` / `formenctype` apply. `fields` replace what the page had and must name existing fields, so a typo fails. When nothing matches, the error lists the page's forms and their buttons. In YAML, `submit:` uses the page the previous step requested.
249
+ `button` matches a submit button's text, `value`, `name` or `id`, and picks its form; its `formaction` / `formmethod` / `formenctype` apply. `fields` replace what the page had and must name existing fields, so a typo fails. A select, radio or checkbox value must be one the page offers (a select option by its value or its label), as a browser can't send anything else; to send what the page doesn't offer, use a plain request with `http.form()`. Line breaks go out as CRLF, as browsers send them. When nothing matches, the error lists the page's forms and their buttons. In YAML, `submit:` uses the page the previous step requested.
207
250
 
208
251
  ### `db` — arrange and inspect the real database
209
252
 
@@ -294,7 +337,7 @@ stub("slack").calls("POST", "/hook"); // recorded calls:
294
337
 
295
338
  Form-encoded bodies (Stripe, Twilio, OAuth token requests) are parsed into `call.form`, with bracket keys nested the way those providers read them: `metadata[order]=7&items[0][price]=p_1` is `{ metadata: { order: "7" }, items: [{ price: "p_1" }] }`. `form` conditions match a subset of it, and numbers and booleans compare with the strings sent. `multipart/form-data` bodies are read the same way, with each file as `{ filename, type, size, text }` (`text` for text, JSON, XML and CSV files), so `form: { avatar: { filename: "a.png", type: "image/png" } }` checks an upload the app passed on.
296
339
 
297
- Later routes win. `path` may also be a RegExp, and `method` may be `*`. Unanswered calls get a `501` and fail the scenario, with the closest route and why it didn't match (`stub.explain(call)`).
340
+ Later routes win. `path` may also be a RegExp, and `method` may be `*`. A `*` segment matches the rest of the path, slashes included, as in Express (`/bucket/*` for `PUT /bucket/photos/2026/a.png`, `/v1/*/items`), and the part it matched is `call.params["*"]`. A path with a space or non-ASCII characters (`/検索/:word`) matches what the app sends percent-encoded. A query written into the path (`on("GET", "/search?q=tea")`, also in `calls()`, `toHaveReceived()` and YAML stubs) is a condition on those parameters, like `query: { q: "tea" }`; other parameters may come along. A list matches a repeated parameter's values in order: `query: { ids: ["1", "2"] }` for `?ids=1&ids=2`. Unanswered calls get a `501` and fail the scenario, with the closest route and why it didn't match (`stub.explain(call)`).
298
341
 
299
342
  ### OpenAPI contracts — for your app and for the services you stub
300
343
 
@@ -308,9 +351,9 @@ slicetest({
308
351
  });
309
352
  ```
310
353
 
311
- - **Your app's responses** must be documented (path, method, status) and match the schema.
312
- - **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`).
313
- - **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.
354
+ - **Your app's responses** must be documented (path, method, status) and match the schema, and the headers the spec lists for a response must be sent when marked `required` and fit their schema (`X-Rate-Limit` as an integer, a `pattern`). `Content-Type`, `Accept` and `Authorization` are left to the content check.
355
+ - **The app's requests to a stub** must match the provider's spec: required query parameters and header parameters, query values against their schema (`?limit=abc` for an integer, a value outside an `enum`), content type and request body. Spec paths are matched with or without the server's base path (`/v1`).
356
+ - **Your stubs' replies** must be something the provider's spec says the real service sends. 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. A provider that differs from its own spec isn't caught.
314
357
 
315
358
  ```
316
359
  slicetest: traffic doesn't match the OpenAPI spec:
@@ -319,6 +362,8 @@ slicetest: traffic doesn't match the OpenAPI spec:
319
362
  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)
320
363
  ```
321
364
 
365
+ These checks compare traffic with a spec; they don't prove behavior. For each one, what it catches and what it can't (a right-shaped but wrong value, request headers, a spec that has drifted from the service, …) is in [What the checks guarantee](docs/guarantees.md).
366
+
322
367
  At the end of the run, each stub with a spec reports which of the provider's operations the app called across all scenarios: its footprint on that API, for planning an upgrade or a switch of provider. Operations the provider marks `deprecated` are flagged, and on GitHub Actions they become a warning annotation and the list goes to the job summary:
323
368
 
324
369
  ```
@@ -465,7 +510,7 @@ Record once, with real credentials in the app's environment:
465
510
  SLICETEST_RECORD=github npx vitest # or SLICETEST_RECORD=1 for every stub with an upstream
466
511
  ```
467
512
 
468
- 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.
513
+ 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. Credentials sent in the query or the body (`api_key`, `key` in the query, `appid`, `access_token`, `client_secret`, `password`, signatures, …) are stored as `[redacted]` and match any value on replay, so the test environment's dummy key replays what the real key recorded. Other values are stored as sent, so review the file before committing it.
469
514
 
470
515
  #### From a HAR file: `npx slicetest import`
471
516
 
@@ -478,6 +523,8 @@ npx slicetest import session.har --stub stripe --upstream https://api.stripe.com
478
523
 
479
524
  Requests under a stub's `upstream` become entries of its recordings file, in the same format and with the same filtering as recording: only the five response headers above, no request headers, the path relative to the upstream's. Preflights, aborted requests and binary responses are skipped, and the hosts it didn't import are listed. Entries already in the file aren't added twice.
480
525
 
526
+ A replay is the real service as it was when recorded. If the provider changes, the recording keeps passing: delete the file (or entries) and record again from time to time, and review the diff, or check the provider with a separate contract test. Values other than the well-known credential names are stored as sent.
527
+
481
528
  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.
482
529
 
483
530
  ### Matchers
@@ -486,6 +533,10 @@ Registered automatically:
486
533
 
487
534
  ```ts
488
535
  expect(res).toHaveStatus(201); // failure shows the response body
536
+ expect(res).toHaveStatus("2xx"); // a class, or a list: [200, 204], ["2xx", 304]
537
+ expect(res).toRespondWithin(300); // a response-time budget, in ms
538
+ expect(res).toSetCookie("sid", { httpOnly: true, secure: true, sameSite: "Lax" }); // a cookie and its attributes
539
+ expect(res.events).toContainEqual({ event: "message_stop", data: {} }); // text/event-stream responses are parsed into events
489
540
  expect(responses).toHaveStatuses({ 201: 1, 409: 9 }); // an array, e.g. from http.concurrently()
490
541
  expect(stub("slack")).toHaveReceived("POST", "/hook", { json: { text: "hi" } });
491
542
  expect(stub("slack")).toHaveReceivedTimes(1, "POST", "/hook");
@@ -631,8 +682,12 @@ In YAML, `auth` on a `request` step sends `Authorization: Bearer` with those cla
631
682
  | `zoom` | `x-zm-signature: v0=…`, `x-zm-request-timestamp` |
632
683
  | `twilio` | `X-Twilio-Signature` over the URL and the sorted form parameters; objects are sent as a form. The URL is the app's address and the path, or `url` when the app validates against a public URL it's configured with |
633
684
  | `twitch` | EventSub: `Twitch-Eventsub-Message-Signature`, `-Id`, `-Timestamp`, `-Type` (`event`, default `notification`) |
685
+ | `typeform` | `Typeform-Signature: sha256=…` (base64) |
686
+ | `notion` | `X-Notion-Signature: sha256=…` (the `verification_token` as `secret`) |
687
+ | `bitbucket` | Bitbucket Cloud: `X-Hub-Signature: sha256=…` |
688
+ | `intercom` | `X-Hub-Signature: sha1=…` (the app's `client_secret`) |
634
689
 
635
- The signatures are checked against the providers' documented examples where they publish one.
690
+ The signatures are checked against the providers' documented examples where they publish one. `typeform`, `notion`, `bitbucket` and `intercom` follow the algorithm in the provider's documentation (read on 2026-10-05); none of them publishes an example to compare with, so they are recomputed in the tests, not checked against an SDK.
636
691
 
637
692
  ```ts
638
693
  const stripe = { provider: "stripe", secret: "whsec_test" } as const; // the same secret as in app.env
@@ -674,6 +729,8 @@ scenario("voting flow", async ({ http, stub, trace }) => {
674
729
 
675
730
  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]`).
676
731
 
732
+ A snapshot records what the app does today, bugs included, and `-u` accepts whatever it shows: review a snapshot update like a change to an assertion, and don't let it be the only assertion on behavior a requirement spells out.
733
+
677
734
  The example apps share one snapshot file: the Node and the Python implementation must produce the same trace, byte for byte.
678
735
 
679
736
  ### Sequence diagrams of every scenario
@@ -708,12 +765,14 @@ scenario.each([{ choice: "a", status: 204 }, { choice: "x", status: 400 }])(
708
765
  );
709
766
  ```
710
767
 
711
- Scenarios in one file share an app and a database, so they always run one at a time; `.concurrent` is rejected.
768
+ Scenarios in one file share an app and a database, so they always run one at a time; `.concurrent` is rejected. Test files do run in parallel, one database per Vitest worker and their own stubs and app process, and requests inside one scenario can overlap (`http.concurrently`): [what is shared and what isn't](docs/state-isolation.md#running-in-parallel).
712
769
 
713
770
  Tags select scenarios across files: `npx slicetest --tag smoke` (repeat `--tag` for any of several, `--tag '!slow'` to leave some out), or `SLICETEST_TAGS=smoke,!slow npx vitest` with your own Vitest config. Scenarios a filter leaves out are reported as skipped. In YAML, `tags: [smoke, payments]` on a scenario.
714
771
 
715
772
  ### Configuration reference
716
773
 
774
+ `slicetest.config.yaml` has a JSON Schema for editor completion and checks: put `# yaml-language-server: $schema=https://unpkg.com/slicetest/schema/config.schema.json` on its first line (VS Code with the YAML extension, JetBrains IDEs), as `slicetest init` does. In YAML, `env` values may be numbers or booleans; they reach the process as strings.
775
+
717
776
  | Option | Default | |
718
777
  |---|---|---|
719
778
  | `app.command` | (required) | Shell command. May use `{{app.port}}` and the other placeholders. |
@@ -723,10 +782,13 @@ Tags select scenarios across files: `npx slicetest --tag smoke` (repeat `--tag`
723
782
  | `app.cwd` | vitest root | |
724
783
  | `app.ready` | `{ path: "/" }` | Poll a path until it answers below 500, or `{ log: "listening" \| /regex/ }`. |
725
784
  | `app.readyTimeout` | `30000` | |
785
+ | `app.restart` | `"never"` | `"scenario"`: stop the process before each scenario's reset and start it after, so nothing in its memory survives. Also on `services`. See [state between scenarios](docs/state-isolation.md). |
786
+ | `app.reset` | none | `{ path, method?, timeout? }`: an endpoint the app serves to drop its in-process state, called (`POST` by default) after the reset and before each scenario. `path` is a path on the app, `method` one of GET / POST / PUT / PATCH / DELETE / HEAD. Redirects aren't followed and a status of 300 or more fails the scenario. Also on `services`. |
787
+ | `app.idle` | none | `{ path, method?, timeout? }`: an endpoint that answers 2xx once the app has no background work left, polled (`GET`) after each scenario for up to `timeout` (5000 ms, at most 600000) or the time the test's own timeout leaves. If it's still busy, the scenario fails and the process is restarted. Also on `services`. |
726
788
  | `app.scope` | `"file"` | `"worker"`: start the app (and stubs, services) once per Vitest worker and keep it for all of that worker's test files, for apps that start slowly. Sets Vitest's `isolate: false`. |
727
789
  | `db.engine` | `postgres`, or `mysql` for a `mysql://` URL | `postgres`, `mysql` (see [MySQL](#mysql)) or `sqlite` (see [SQLite](#sqlite)). |
728
- | `db.migrate` | none | `{ atlas: { dir } }`, `{ sql: "file-or-dir" }` or `{ command, inputs?, env? }`. The command gets `DATABASE_URL`, and both it and `env` may use the `{{db.*}}` placeholders, for tools that read other variables: `{ command: "php artisan migrate --force", env: { DB_HOST: "{{db.host}}", DB_DATABASE: "{{db.name}}" } }`, `{ command: "dotnet ef database update --connection \"{{db.adoNet}}\"" }`. |
729
- | `db.seed` | none | SQL file re-run after every reset. |
790
+ | `db.migrate` | none | `{ atlas: { dir } }`, `{ sql: "file-or-dir" }` or `{ command, inputs?, env? }`. A `sql` directory is applied in name order with version numbers compared as numbers (`V2__` before `V10__`), leaving out rollbacks: `*.down.sql` (golang-migrate, sqlx, Diesel), Flyway undo files (`U2__…`) and the down section of goose (`-- +goose Down`) and dbmate (`-- migrate:down`) files. The command gets `DATABASE_URL`, and both it and `env` may use the `{{db.*}}` placeholders, for tools that read other variables: `{ command: "php artisan migrate --force", env: { DB_HOST: "{{db.host}}", DB_DATABASE: "{{db.name}}" } }`, `{ command: "dotnet ef database update --connection \"{{db.adoNet}}\"" }`. |
791
+ | `db.seed` | none | A file, or a list of files run in that order, re-run after every reset: SQL, or YAML / JSON rows per table (`plans: [{ id: 1, name: free }]`), inserted with the engine's own quoting, so one seed serves Postgres, MySQL and SQLite. After rows with explicit ids, Postgres sequences are moved past them so the app's next insert doesn't collide (MySQL and SQLite do that themselves). |
730
792
  | `db.schemas` | `["public"]` | Schemas whose tables are reset. |
731
793
  | `db.keep` | `[]` | Extra tables (`name` or `schema.name`) never truncated. |
732
794
  | `db.ignoreChanges` | `[]` | Columns (`updated_at`, `orders.synced_at`) and tables (`sessions.*`) left out of `db.changes()`, `trace()` and the failure output. |
@@ -795,8 +857,8 @@ scenarios:
795
857
  | Step | Keys |
796
858
  |---|---|
797
859
  | `stub: <name>` | `on: METHOD /path` (`:params` allowed) or `graphql: <operation>`, `when: { query, headers, json, form, body, variables }`, one of `reply: { status, headers, body }` (`{ file }` for a fixture file, `{ data, errors }` for GraphQL) / `sequence: [...]` / `networkError: true`, plus `times`, `delay`. Replies may echo the call: `{{call.params.id}}`, `{{call.json.name}}`, `{{call.form.amount}}`, `{{call.variables.id}}`. |
798
- | `submit: <button>` | `form`, `fields` (`{ file: path }` for a file input), `headers`, `follow`, `expect: { status, headers, json, text }`, `capture`. Submits a form of the page the last request returned, like `http.submit()`; `submit: true` presses the form's only button. |
799
- | `request: METHOD /path` | `headers`, `query`, one of `json` / `form` / `multipart` / `body` / `graphql: { query, variables, operationName }`, `expect.schema` (a JSON Schema, or `../openapi.yaml#/components/schemas/Poll` relative to the file), `follow`, `expect: { status, headers, json, text }`, `capture`. `timeout: <ms>` fails it, by name, when the app doesn't answer in time. `concurrency: n` sends it `n` times at once; `expect` then applies to each response, and `expect.statuses: { 201: 1, 409: 9 }` counts them. |
860
+ | `submit: <button>` | `form`, `fields` (`{ file: path }` for a file input), `headers`, `follow`, `expect: { status, headers, json, text }` (`status` may be a class, `2xx`, or a list, `[200, 204]`; `duration: 300` fails a response slower than 300 ms, `duration: { $lt: 300 }` takes matchers; `cookies: { sid: { httpOnly: true, sameSite: Lax }, tracking: null }` checks the cookies it sets and their attributes, `null` for one it must not set; `events: [{ event: message_start }, { data: "[DONE]" }]` checks a `text/event-stream` response's events in order, others allowed between), `capture` (also from `events.0.data.id`). Submits a form of the page the last request returned, like `http.submit()`; `submit: true` presses the form's only button. |
861
+ | `request: METHOD /path` | `headers`, `query`, one of `json` / `form` / `multipart` / `body` / `graphql: { query, variables, operationName }`, `expect.schema` (a JSON Schema, or `../openapi.yaml#/components/schemas/Poll` relative to the file), `follow`, `expect: { status, headers, json, text }` (a header set to `null` must be absent: `x-powered-by: null`), `capture`. `timeout: <ms>` fails it, by name, when the app doesn't answer in time. `concurrency: n` sends it `n` times at once; `expect` then applies to each response, and `expect.statuses: { 201: 1, 409: 9 }` counts them (classes count the responses no listed code takes: `{ 201: 1, 4xx: 9 }`). |
800
862
  | `insert: <table>` | `rows`, `capture` (from `row` / `rows`) |
801
863
  | `request` with `auth` | `auth: true` or the claims: sends a bearer token from the `auth` issuer |
802
864
  | `request` with `webhook` | `{ provider, secret, event, stale, invalidSignature }`: signs the body like that provider's deliveries |
@@ -810,7 +872,7 @@ scenarios:
810
872
  | `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. `ignore: [updated_at, sessions.*]` leaves out columns and tables. |
811
873
  | `checkpoint: true` | Later `changes` steps only see what happens after this step. |
812
874
  | `set: { name: value }` | Defines variables for later steps, e.g. `{ orderId: "{{$uuid}}", expires: "{{$now+1d}}" }`. |
813
- | `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. |
875
+ | `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. A field takes a string or `{ $regex, $options }`. |
814
876
  | `use: <definition>` | `with: { param: value }`. Runs the steps of a `define:` entry. |
815
877
  | `snapshot: true` | The scenario's [trace](#snapshot-the-whole-scenario-trace) so far must match its stored snapshot. `mask: [keys]` hides more values. |
816
878
 
@@ -826,6 +888,8 @@ scenarios:
826
888
  capture: { file: json.url }
827
889
  ```
828
890
 
891
+ Every wait (`within`, `log`, `mail`) ends shortly before the scenario's own timeout, which is Vitest's 5000 ms unless the scenario sets `timeout:`; the failure then names what it waited for and says the timeout cut it short. A `within: 10000` needs a scenario `timeout:` above it.
892
+
829
893
  - `reply: { file: replies/charge.json }` answers with a file relative to the scenario file: `.json` and `.yaml` are sent as JSON and may still use `{{call.*}}`, other files as they are (images, PDFs, CSV), with a content type from the extension unless `headers` set one. Large provider payloads stay out of the scenario.
830
894
  - `multipart:` sends `multipart/form-data`: plain values are fields, `{ file: fixtures/avatar.png }` uploads a file (relative to the scenario file, content type from its extension, or `type` / `filename`), `{ content: ..., filename: notes.json }` an inline one, and a list sends a field several times.
831
895
  - `request:` also takes a captured URL of the app, e.g. `GET {{link}}` after capturing a link from a mail.
@@ -837,13 +901,16 @@ scenarios:
837
901
  |---|---|
838
902
  | `{ $type: number }` | `string`, `number`, `integer`, `boolean`, `array`, `object`, `null` |
839
903
  | `{ $regex: "^ch_" }` | a string the pattern finds |
904
+ | `{ $regex: "^ab", $options: i }` | the same with flags (`i`, `m`, `s`, …), as in MongoDB |
840
905
  | `{ $contains: "ok" }` | a string with that substring, or a list with a matching item (`{ $contains: { sku: a } }`) |
841
- | `{ $gte: 1, $lt: 10 }` | numbers, or strings such as ISO dates (`{ $gte: "2026-01-01" }`); also `$gt`, `$lte` |
906
+ | `{ $gte: 1, $lt: 10 }` | numbers (also decimals that `numeric` / `DECIMAL` columns return as strings, `"12.50"`), or strings such as ISO dates (`{ $gte: "2026-01-01" }`); also `$gt`, `$lte` |
907
+ | `{ $closeTo: 9.99 }` | a number within ±0.005 (`[9.99, 0.1]` for another tolerance), for floats and computed totals; decimal strings too |
842
908
  | `{ $len: 3 }` | a string or list of that length; `{ $len: { $gte: 1 } }` |
843
909
  | `{ $oneOf: [paid, pending] }` | any of the values (or matchers) |
844
910
  | `{ $not: "" }` | anything the value or matcher doesn't match |
845
- | `{ $format: uuid }` | `uuid`, `email`, `date`, `date-time`, `uri`, `integer` (a string of digits) |
911
+ | `{ $format: uuid }` | `uuid`, `email`, `date`, `date-time`, `time`, `uri`, `hostname`, `ipv4`, `ipv6`, `ulid`, `integer` (a string of digits), `jwt` (three base64url parts with a JSON header naming its `alg`; the signature isn't checked) |
846
912
  | `{ $any: true }` | anything but `null` / missing |
913
+ | `{ $absent: true }` | the key isn't there at all (`null` is there): `password_hash: { $absent: true }` checks a response doesn't leak it. Expected JSON is otherwise a subset, so extra keys pass unless named this way |
847
914
 
848
915
  Several `$` keys in one mapping must all hold.
849
916
  - A file-level `setup:` list runs at the start of every scenario. `skip`, `only`, `timeout` and `tags` work per scenario.
@@ -897,6 +964,13 @@ npx slicetest # every *.scenario.yaml under the config's directo
897
964
  npx slicetest polls -t voting # filter by file and scenario name
898
965
  npx slicetest --watch
899
966
  npx slicetest list --tag smoke # what would run: file, line, tags, steps (--json for tools)
967
+ npx slicetest -u # rewrite `snapshot: true` snapshots that no longer match
968
+ npx slicetest --reporter junit --output-file reports/slicetest.xml # GitLab, Jenkins, CircleCI test reports
969
+ npx slicetest --shard 2/4 # the second of four parts, for parallel CI jobs
970
+ npx slicetest --shuffle # scenarios in a random order (the seed is printed): a scenario that only passes after another one leans on state the reset doesn't clear
971
+ npx slicetest --shuffle --seed 1791208846 # that order again
972
+ npx slicetest --retry 2 # run a failing scenario up to twice more (each attempt after a fresh reset)
973
+ npx slicetest --version # the installed version; an unknown option names the nearest one
900
974
  ```
901
975
 
902
976
  ### Scenarios from your OpenAPI spec: `npx slicetest gen`
@@ -910,6 +984,8 @@ writes `scenarios/<resource>.gen.scenario.yaml` with one scenario per documented
910
984
 
911
985
  Operations that the spec protects with a bearer token (`http: bearer`, `oauth2` or `openIdConnect` security) get `auth:` on their requests, with the scopes the spec requires in the token's `scope` claim, and their 401 responses become runnable scenarios that send no token. With [`auth`](#auth-a-real-openid-issuer-tokens-with-any-claims) in the config, the generated scenarios run against the app's real token checks.
912
986
 
987
+ The generated expectations come from the spec, so a mistake in the spec is copied into them; review the scenarios before committing them, as you would code (see [tests written by an AI agent](docs/guarantees.md#tests-written-by-an-ai-agent)).
988
+
913
989
  `--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.
914
990
 
915
991
  ### Record a scenario by using the app: `npx slicetest record`
@@ -1038,17 +1114,37 @@ app:
1038
1114
 
1039
1115
  ## Examples
1040
1116
 
1041
- `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**:
1117
+ `examples/` has a Node app (`node:http` + `pg`), a Python app (`http.server` + `psycopg`) and a [Spring Boot app](examples/spring-boot-api/README.md) (Java 21, Maven, JDBC) with the same API. **The same scenario files (`polls.test.ts` and `polls.scenario.yaml`) run against all three**:
1042
1118
 
1043
1119
  ```sh
1044
1120
  npm test # unit tests + both example apps
1045
1121
  npm run test:dist # the built package, and the CLI with examples/slicetest.config.yaml
1122
+ npm run test:spring # the same scenarios against the Spring Boot app (needs JDK 21 and Maven)
1046
1123
  ```
1047
1124
 
1048
1125
  ## Using a coding agent
1049
1126
 
1050
1127
  [`.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).
1051
1128
 
1052
- ## Status
1129
+ ## Feedback and contributing
1130
+
1131
+ Found a bug, have an idea, or need help? [Open an issue](https://github.com/revo1290/slicetest/issues/new/choose) using the bug report, feature request, or question form. **Japanese and English are welcome.** See [CONTRIBUTING.md](CONTRIBUTING.md) for reporting and contribution guidance.
1132
+
1133
+ 不具合報告・機能提案・質問を[Issues](https://github.com/revo1290/slicetest/issues/new/choose)で受け付けています。日本語でお気軽に投稿してください。投稿方法は[貢献ガイド](CONTRIBUTING.md)をご覧ください。
1134
+
1135
+ ## Compatibility and releases
1136
+
1137
+ Version 0.x, early. What is run, and where:
1138
+
1139
+ | | Verified in CI on every push | Used by hand, not in CI | Not verified |
1140
+ |---|---|---|---|
1141
+ | Node.js | 24 | the maintainer's machine (also 24); 2026-10-05, the Node example app's 31 tests by hand: Node 22.23.3 (containers) and Node 20.20.2 with Vitest 5.0.2 against a database server you run (`SLICETEST_DATABASE_URL`) | the rest of the suite on 20 and 22. On Node below 22.22 the container library doesn't load (Testcontainers' requirement; checked on 20.20.2), so a database or dependency in a container needs 22.22+, and the error says so. SQLite needs 22.5+; `hosts` for Node apps needs 22.21+ / 24.5+ |
1142
+ | Vitest | 5.0.x (the version in the lockfile) | 4.x on one project (the maintainer's report); 2026-10-05, Vitest 4.1.11 in a copy of the repo: the Node example's 31 tests, the unit project's 369 tests, the CLI against the example's YAML scenarios (14) and the `record` round trip. With Vitest 4 the matcher types resolve with `skipLibCheck: true`; with `skipLibCheck: false` TypeScript reports TS2428 in `dist/matchers.d.ts`, because Vitest 4 and 5 declare `Matchers` with different type parameters | 6 and later |
1143
+ | OS | Ubuntu (the full suite); Windows (unit tests, the fixtures that crash and fail, and the built package with the CLI) | macOS | Windows with Docker-based databases: Windows CI uses a Postgres service instead of containers |
1144
+ | Database | PostgreSQL 17 (`postgres:17-alpine`, on Ubuntu) and the Postgres service of the Windows runner (its version isn't pinned); MySQL 8.4 and SQLite (Ubuntu; SQLite also in the isolation tests on Windows) | | other PostgreSQL / MySQL versions, MariaDB, other SQLite builds |
1145
+ | Container runtime | Docker (Ubuntu) | Podman (macOS) | |
1146
+ | App stacks | Node, Python and Spring Boot (Java 21, Maven) example apps (Ubuntu; the Spring one in its own job), small Node fixtures | Spring Boot, Next.js and others, on the maintainer's own projects (the maintainer's report; no automated test) | the rest of what `slicetest init` recognizes: detection is tested on fixture projects, not on running apps |
1147
+
1148
+ The built package, not only the sources, is checked: `npm run test:dist` runs the Node example and the CLI (with the Node example's config) against `dist/` and type-checks them against the published types.
1053
1149
 
1054
- Early. Postgres, MySQL and SQLite. CI runs on Linux and Windows.
1150
+ **Releases.** Before 1.0, a minor version (`0.N.0`) may change behavior or configuration, and a patch version (`0.N.P`) only fixes bugs. [CHANGELOG.md](CHANGELOG.md) lists every change. Pin the minor (`"slicetest": "~0.10.0"`) and upgrade on purpose: read the entries between your version and the new one, run `npx slicetest doctor`, and review snapshot diffs instead of running `-u`. A change that can make a passing suite fail (a check that got stricter, a key that was removed) is called out in that version's entry as it is released.
package/dist/app.js CHANGED
@@ -1,7 +1,9 @@
1
+ import { waitBudget } from "./deadline.js";
1
2
  import { spawn, spawnSync } from "node:child_process";
2
3
  import { createServer } from "node:net";
3
4
  import path from "node:path";
4
5
  import { StringDecoder } from "node:string_decoder";
6
+ import { onProcessExit } from "./exit.js";
5
7
  const LOG_LINES = 200;
6
8
  /** How long to wait for stdout/stderr to drain after the process exits. */
7
9
  const DRAIN_MS = 100;
@@ -16,6 +18,7 @@ export class App {
16
18
  /** Lines ever received, so callers can ask for output since a point in time. */
17
19
  #lineCount = 0;
18
20
  #exit;
21
+ #stopped = false;
19
22
  #exited;
20
23
  #listeners = new Set();
21
24
  /** `mark()` at the start of the current scenario. */
@@ -151,6 +154,7 @@ export class App {
151
154
  * ```
152
155
  */
153
156
  async waitForLog(pattern, timeout = 5000) {
157
+ const { ms, note } = waitBudget(timeout);
154
158
  const re = typeof pattern === "string" ? new RegExp(escapeRegExp(pattern)) : new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, ""));
155
159
  const n = Math.min(this.#lineCount - this.#scenarioMark, this.#log.length);
156
160
  const seen = n > 0 ? this.#log.slice(-n).find((l) => re.test(l)) : undefined;
@@ -166,7 +170,7 @@ export class App {
166
170
  if (re.test(line))
167
171
  done(() => resolve(line));
168
172
  };
169
- 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);
173
+ const timer = setTimeout(() => done(() => reject(new Error(`slicetest: ${this.label} printed no line matching ${re} within ${ms}ms${note}. Output during this scenario:\n${this.logs(this.#scenarioMark) || "(none)"}`))), ms);
170
174
  this.#listeners.add(listener);
171
175
  });
172
176
  }
@@ -176,6 +180,10 @@ export class App {
176
180
  */
177
181
  async stop() {
178
182
  running.delete(this);
183
+ // Not signalling again: a process group id can be reused once the first stop has reaped it.
184
+ if (this.#stopped)
185
+ return;
186
+ this.#stopped = true;
179
187
  const pid = this.#child.pid;
180
188
  if (pid === undefined)
181
189
  return;
@@ -251,7 +259,7 @@ export class App {
251
259
  }
252
260
  /** Apps still running, killed synchronously if the worker exits without tearing down. */
253
261
  const running = new Set();
254
- process.once("exit", () => {
262
+ onProcessExit(() => {
255
263
  for (const app of running)
256
264
  app.killNow();
257
265
  });
package/dist/auth.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createSign, generateKeyPairSync, randomUUID } from "node:crypto";
2
2
  import { createServer } from "node:http";
3
+ import { requestUrl } from "./request-url.js";
3
4
  const b64url = (data) => Buffer.from(data).toString("base64url");
4
5
  function newKey() {
5
6
  const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
@@ -93,7 +94,7 @@ export class Issuer {
93
94
  this.#issued = 0;
94
95
  }
95
96
  async #handle(req) {
96
- const path = new URL(req.url ?? "/", this.url).pathname;
97
+ const path = requestUrl(req.url, this.url).pathname;
97
98
  if (req.method === "GET" && path === "/.well-known/openid-configuration") {
98
99
  return [
99
100
  200,
@@ -0,0 +1,53 @@
1
+ export interface RunFlags {
2
+ update?: boolean;
3
+ reporter?: string[];
4
+ "output-file"?: string;
5
+ shard?: string;
6
+ retry?: string;
7
+ shuffle?: boolean;
8
+ seed?: string;
9
+ }
10
+ /** Vitest options for the run flags of `npx slicetest`; paths are taken from where the command runs, like `--diagrams`. */
11
+ export declare function runOptions(flags: RunFlags, cwd?: string): {
12
+ update?: boolean | undefined;
13
+ retry?: number | undefined;
14
+ sequence?: {
15
+ shuffle: boolean;
16
+ seed?: number | undefined;
17
+ } | undefined;
18
+ reporters?: string[] | undefined;
19
+ outputFile?: string | undefined;
20
+ shard?: string | undefined;
21
+ };
22
+ /** parseArgs, with its errors worded for this CLI (and a guess at a misspelt option). */
23
+ export declare function parseCliArgs(argv: string[]): {
24
+ values: {
25
+ config?: string | undefined;
26
+ watch?: boolean | undefined;
27
+ name?: string | undefined;
28
+ tag?: string[] | undefined;
29
+ help?: boolean | undefined;
30
+ version?: boolean | undefined;
31
+ force?: boolean | undefined;
32
+ spec?: string | undefined;
33
+ out?: string | undefined;
34
+ uncovered?: boolean | undefined;
35
+ port?: string | undefined;
36
+ diagrams?: string | undefined;
37
+ stub?: string | undefined;
38
+ upstream?: string | undefined;
39
+ json?: boolean | undefined;
40
+ update?: boolean | undefined;
41
+ reporter?: string[] | undefined;
42
+ "output-file"?: string | undefined;
43
+ shard?: string | undefined;
44
+ retry?: string | undefined;
45
+ shuffle?: boolean | undefined;
46
+ seed?: string | undefined;
47
+ };
48
+ positionals: string[];
49
+ };
50
+ /** This package's version: the package.json next to dist/ (and next to src/ when run from a checkout). */
51
+ export declare function packageVersion(): Promise<string>;
52
+ /** `startVitest` as the installed Vitest takes it: 4 wants the mode ("test") first, 5 dropped it. */
53
+ export declare function compatStart(vitest: typeof import("vitest/node")): typeof import("vitest/node").startVitest;
@@ -0,0 +1,80 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { parseArgs } from "node:util";
4
+ import { editDistance } from "./config.js";
5
+ /** Vitest options for the run flags of `npx slicetest`; paths are taken from where the command runs, like `--diagrams`. */
6
+ export function runOptions(flags, cwd = process.cwd()) {
7
+ if (flags.shard !== undefined) {
8
+ const m = /^(\d+)\/(\d+)$/.exec(flags.shard);
9
+ if (!m || Number(m[1]) < 1 || Number(m[1]) > Number(m[2]))
10
+ throw new Error(`slicetest: --shard takes <index>/<count> with 1 ≤ index ≤ count, e.g. --shard 1/3, got "${flags.shard}"`);
11
+ }
12
+ if (flags.retry !== undefined && !/^\d+$/.test(flags.retry))
13
+ throw new Error(`slicetest: --retry takes a whole number of retries, e.g. --retry 2, got "${flags.retry}"`);
14
+ if (flags.seed !== undefined && !flags.shuffle)
15
+ throw new Error("slicetest: --seed goes with --shuffle (--shuffle --seed 1234 repeats a shuffled order)");
16
+ if (flags.seed !== undefined && !/^[1-9]\d*$/.test(flags.seed))
17
+ throw new Error(`slicetest: --seed takes a positive whole number, got "${flags.seed}"`);
18
+ return {
19
+ ...(flags.update ? { update: true } : {}),
20
+ ...(flags.retry !== undefined ? { retry: Number(flags.retry) } : {}),
21
+ ...(flags.shuffle ? { sequence: { shuffle: true, ...(flags.seed ? { seed: Number(flags.seed) } : {}) } } : {}),
22
+ ...(flags.reporter?.length ? { reporters: flags.reporter } : {}),
23
+ ...(flags["output-file"] ? { outputFile: path.resolve(cwd, flags["output-file"]) } : {}),
24
+ ...(flags.shard ? { shard: flags.shard } : {}),
25
+ };
26
+ }
27
+ const OPTIONS = {
28
+ config: { type: "string", short: "c" },
29
+ watch: { type: "boolean", short: "w" },
30
+ name: { type: "string", short: "t" },
31
+ tag: { type: "string", multiple: true },
32
+ help: { type: "boolean", short: "h" },
33
+ version: { type: "boolean", short: "v" },
34
+ force: { type: "boolean" },
35
+ spec: { type: "string" },
36
+ out: { type: "string" },
37
+ uncovered: { type: "boolean" },
38
+ port: { type: "string" },
39
+ diagrams: { type: "string" },
40
+ stub: { type: "string" },
41
+ upstream: { type: "string" },
42
+ json: { type: "boolean" },
43
+ update: { type: "boolean", short: "u" },
44
+ reporter: { type: "string", multiple: true },
45
+ "output-file": { type: "string" },
46
+ shard: { type: "string" },
47
+ retry: { type: "string" },
48
+ shuffle: { type: "boolean" },
49
+ seed: { type: "string" },
50
+ };
51
+ /** parseArgs, with its errors worded for this CLI (and a guess at a misspelt option). */
52
+ export function parseCliArgs(argv) {
53
+ try {
54
+ return parseArgs({ args: argv, allowPositionals: true, options: OPTIONS });
55
+ }
56
+ catch (e) {
57
+ const err = e;
58
+ const unknown = /^Unknown option '(-{1,2}[^']*)'/.exec(err.message ?? "")?.[1];
59
+ if (err.code === "ERR_PARSE_ARGS_UNKNOWN_OPTION" && unknown) {
60
+ const name = unknown.replace(/^-+/, "");
61
+ const near = Object.keys(OPTIONS)
62
+ .map((k) => ({ k, d: editDistance(k, name.toLowerCase()) }))
63
+ .filter(({ k, d }) => d <= Math.max(2, Math.floor(name.length / 3)) || (name.length >= 3 && k.startsWith(name.toLowerCase())))
64
+ .sort((a, b) => a.d - b.d)[0];
65
+ throw new Error(`slicetest: unknown option ${unknown}${near ? `; did you mean --${near.k}?` : ""} (see npx slicetest --help)`);
66
+ }
67
+ // A missing value (`--config` last on the line) and the like: node's own wording says what is wrong.
68
+ throw new Error(`slicetest: ${err.message ?? e} (see npx slicetest --help)`);
69
+ }
70
+ }
71
+ /** This package's version: the package.json next to dist/ (and next to src/ when run from a checkout). */
72
+ export async function packageVersion() {
73
+ const file = new URL("../package.json", import.meta.url);
74
+ return JSON.parse(await readFile(file, "utf8")).version;
75
+ }
76
+ /** `startVitest` as the installed Vitest takes it: 4 wants the mode ("test") first, 5 dropped it. */
77
+ export function compatStart(vitest) {
78
+ const { startVitest, version } = vitest;
79
+ return (Number.parseInt(version, 10) === 4 ? startVitest.bind(null, "test") : startVitest);
80
+ }