slicetest 0.9.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.
- package/README.md +118 -30
- package/dist/app.js +10 -2
- package/dist/auth.js +2 -1
- package/dist/cli-run.d.ts +40 -0
- package/dist/cli-run.js +65 -0
- package/dist/cli.js +20 -34
- package/dist/config.d.ts +33 -2
- package/dist/config.js +82 -8
- package/dist/container-runtime.d.ts +4 -0
- package/dist/container-runtime.js +16 -0
- package/dist/containers.js +4 -3
- package/dist/db.d.ts +3 -1
- package/dist/db.js +45 -8
- package/dist/deadline.d.ts +7 -0
- package/dist/deadline.js +24 -0
- package/dist/doctor.js +27 -5
- package/dist/drivers/driver.d.ts +6 -0
- package/dist/drivers/mysql.js +5 -1
- package/dist/drivers/postgres.d.ts +2 -0
- package/dist/drivers/postgres.js +20 -1
- package/dist/exit.d.ts +1 -0
- package/dist/exit.js +28 -0
- package/dist/form.d.ts +1 -0
- package/dist/form.js +50 -6
- package/dist/har.js +2 -2
- package/dist/http.d.ts +9 -3
- package/dist/http.js +40 -5
- package/dist/init.js +11 -2
- package/dist/list.d.ts +1 -0
- package/dist/list.js +16 -3
- package/dist/mail.d.ts +1 -1
- package/dist/mail.js +7 -7
- package/dist/matchers.d.ts +4 -3
- package/dist/matchers.js +18 -6
- package/dist/openapi.d.ts +2 -0
- package/dist/openapi.js +133 -14
- package/dist/record-cli.js +2 -1
- package/dist/record.js +2 -1
- package/dist/recording.d.ts +4 -1
- package/dist/recording.js +14 -3
- package/dist/request-url.d.ts +1 -0
- package/dist/request-url.js +5 -0
- package/dist/runtime.d.ts +3 -3
- package/dist/runtime.js +132 -18
- package/dist/scenario.js +11 -2
- package/dist/stub.js +17 -6
- package/dist/vitest.d.ts +4 -0
- package/dist/vitest.js +6 -2
- package/dist/webhook.d.ts +3 -3
- package/dist/webhook.js +10 -2
- package/dist/yaml-runtime.d.ts +4 -4
- package/dist/yaml-runtime.js +96 -34
- package/dist/yaml.d.ts +4 -0
- package/dist/yaml.js +33 -2
- package/docs/guarantees.md +92 -0
- package/docs/state-isolation.md +94 -0
- package/package.json +4 -1
- package/schema/config.schema.json +675 -0
- package/schema/scenario.schema.json +27 -3
package/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/revo1290/slicetest/actions/workflows/ci.yml) [](https://www.npmjs.com/package/slicetest) [](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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
|
@@ -40,17 +64,17 @@ npx slicetest # starts Postgres, migrates, starts your app, runs scenario
|
|
|
40
64
|
|
|
41
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
|
|
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
|
|
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
|
|
50
|
-
- **OpenAPI coverage** of your own API
|
|
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
|
|
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
|
-
- **
|
|
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
|
|
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`
|
|
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 `*`. 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)`).
|
|
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
|
|
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
|
```
|
|
@@ -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
|
|
@@ -635,8 +682,12 @@ In YAML, `auth` on a `request` step sends `Authorization: Bearer` with those cla
|
|
|
635
682
|
| `zoom` | `x-zm-signature: v0=…`, `x-zm-request-timestamp` |
|
|
636
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 |
|
|
637
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`) |
|
|
638
689
|
|
|
639
|
-
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.
|
|
640
691
|
|
|
641
692
|
```ts
|
|
642
693
|
const stripe = { provider: "stripe", secret: "whsec_test" } as const; // the same secret as in app.env
|
|
@@ -678,6 +729,8 @@ scenario("voting flow", async ({ http, stub, trace }) => {
|
|
|
678
729
|
|
|
679
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]`).
|
|
680
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
|
+
|
|
681
734
|
The example apps share one snapshot file: the Node and the Python implementation must produce the same trace, byte for byte.
|
|
682
735
|
|
|
683
736
|
### Sequence diagrams of every scenario
|
|
@@ -712,12 +765,14 @@ scenario.each([{ choice: "a", status: 204 }, { choice: "x", status: 400 }])(
|
|
|
712
765
|
);
|
|
713
766
|
```
|
|
714
767
|
|
|
715
|
-
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).
|
|
716
769
|
|
|
717
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.
|
|
718
771
|
|
|
719
772
|
### Configuration reference
|
|
720
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
|
+
|
|
721
776
|
| Option | Default | |
|
|
722
777
|
|---|---|---|
|
|
723
778
|
| `app.command` | (required) | Shell command. May use `{{app.port}}` and the other placeholders. |
|
|
@@ -727,10 +782,13 @@ Tags select scenarios across files: `npx slicetest --tag smoke` (repeat `--tag`
|
|
|
727
782
|
| `app.cwd` | vitest root | |
|
|
728
783
|
| `app.ready` | `{ path: "/" }` | Poll a path until it answers below 500, or `{ log: "listening" \| /regex/ }`. |
|
|
729
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`. |
|
|
730
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`. |
|
|
731
789
|
| `db.engine` | `postgres`, or `mysql` for a `mysql://` URL | `postgres`, `mysql` (see [MySQL](#mysql)) or `sqlite` (see [SQLite](#sqlite)). |
|
|
732
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}}\"" }`. |
|
|
733
|
-
| `db.seed` | none |
|
|
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). |
|
|
734
792
|
| `db.schemas` | `["public"]` | Schemas whose tables are reset. |
|
|
735
793
|
| `db.keep` | `[]` | Extra tables (`name` or `schema.name`) never truncated. |
|
|
736
794
|
| `db.ignoreChanges` | `[]` | Columns (`updated_at`, `orders.synced_at`) and tables (`sessions.*`) left out of `db.changes()`, `trace()` and the failure output. |
|
|
@@ -800,7 +858,7 @@ scenarios:
|
|
|
800
858
|
|---|---|
|
|
801
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}}`. |
|
|
802
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. |
|
|
803
|
-
| `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 }
|
|
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 }`). |
|
|
804
862
|
| `insert: <table>` | `rows`, `capture` (from `row` / `rows`) |
|
|
805
863
|
| `request` with `auth` | `auth: true` or the claims: sends a bearer token from the `auth` issuer |
|
|
806
864
|
| `request` with `webhook` | `{ provider, secret, event, stale, invalidSignature }`: signs the body like that provider's deliveries |
|
|
@@ -814,7 +872,7 @@ scenarios:
|
|
|
814
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. |
|
|
815
873
|
| `checkpoint: true` | Later `changes` steps only see what happens after this step. |
|
|
816
874
|
| `set: { name: value }` | Defines variables for later steps, e.g. `{ orderId: "{{$uuid}}", expires: "{{$now+1d}}" }`. |
|
|
817
|
-
| `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 }`. |
|
|
818
876
|
| `use: <definition>` | `with: { param: value }`. Runs the steps of a `define:` entry. |
|
|
819
877
|
| `snapshot: true` | The scenario's [trace](#snapshot-the-whole-scenario-trace) so far must match its stored snapshot. `mask: [keys]` hides more values. |
|
|
820
878
|
|
|
@@ -830,6 +888,8 @@ scenarios:
|
|
|
830
888
|
capture: { file: json.url }
|
|
831
889
|
```
|
|
832
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
|
+
|
|
833
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.
|
|
834
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.
|
|
835
895
|
- `request:` also takes a captured URL of the app, e.g. `GET {{link}}` after capturing a link from a mail.
|
|
@@ -841,14 +901,16 @@ scenarios:
|
|
|
841
901
|
|---|---|
|
|
842
902
|
| `{ $type: number }` | `string`, `number`, `integer`, `boolean`, `array`, `object`, `null` |
|
|
843
903
|
| `{ $regex: "^ch_" }` | a string the pattern finds |
|
|
904
|
+
| `{ $regex: "^ab", $options: i }` | the same with flags (`i`, `m`, `s`, …), as in MongoDB |
|
|
844
905
|
| `{ $contains: "ok" }` | a string with that substring, or a list with a matching item (`{ $contains: { sku: a } }`) |
|
|
845
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` |
|
|
846
907
|
| `{ $closeTo: 9.99 }` | a number within ±0.005 (`[9.99, 0.1]` for another tolerance), for floats and computed totals; decimal strings too |
|
|
847
908
|
| `{ $len: 3 }` | a string or list of that length; `{ $len: { $gte: 1 } }` |
|
|
848
909
|
| `{ $oneOf: [paid, pending] }` | any of the values (or matchers) |
|
|
849
910
|
| `{ $not: "" }` | anything the value or matcher doesn't match |
|
|
850
|
-
| `{ $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) |
|
|
851
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 |
|
|
852
914
|
|
|
853
915
|
Several `$` keys in one mapping must all hold.
|
|
854
916
|
- A file-level `setup:` list runs at the start of every scenario. `skip`, `only`, `timeout` and `tags` work per scenario.
|
|
@@ -905,6 +967,10 @@ npx slicetest list --tag smoke # what would run: file, line, tags, steps (--json
|
|
|
905
967
|
npx slicetest -u # rewrite `snapshot: true` snapshots that no longer match
|
|
906
968
|
npx slicetest --reporter junit --output-file reports/slicetest.xml # GitLab, Jenkins, CircleCI test reports
|
|
907
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
|
|
908
974
|
```
|
|
909
975
|
|
|
910
976
|
### Scenarios from your OpenAPI spec: `npx slicetest gen`
|
|
@@ -918,6 +984,8 @@ writes `scenarios/<resource>.gen.scenario.yaml` with one scenario per documented
|
|
|
918
984
|
|
|
919
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.
|
|
920
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
|
+
|
|
921
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.
|
|
922
990
|
|
|
923
991
|
### Record a scenario by using the app: `npx slicetest record`
|
|
@@ -1046,17 +1114,37 @@ app:
|
|
|
1046
1114
|
|
|
1047
1115
|
## Examples
|
|
1048
1116
|
|
|
1049
|
-
`examples/` has a Node app (`node:http` + `pg`)
|
|
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**:
|
|
1050
1118
|
|
|
1051
1119
|
```sh
|
|
1052
1120
|
npm test # unit tests + both example apps
|
|
1053
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)
|
|
1054
1123
|
```
|
|
1055
1124
|
|
|
1056
1125
|
## Using a coding agent
|
|
1057
1126
|
|
|
1058
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).
|
|
1059
1128
|
|
|
1060
|
-
##
|
|
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.
|
|
1061
1149
|
|
|
1062
|
-
|
|
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 ${
|
|
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
|
-
|
|
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 =
|
|
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,
|
package/dist/cli-run.d.ts
CHANGED
|
@@ -3,11 +3,51 @@ export interface RunFlags {
|
|
|
3
3
|
reporter?: string[];
|
|
4
4
|
"output-file"?: string;
|
|
5
5
|
shard?: string;
|
|
6
|
+
retry?: string;
|
|
7
|
+
shuffle?: boolean;
|
|
8
|
+
seed?: string;
|
|
6
9
|
}
|
|
7
10
|
/** Vitest options for the run flags of `npx slicetest`; paths are taken from where the command runs, like `--diagrams`. */
|
|
8
11
|
export declare function runOptions(flags: RunFlags, cwd?: string): {
|
|
9
12
|
update?: boolean | undefined;
|
|
13
|
+
retry?: number | undefined;
|
|
14
|
+
sequence?: {
|
|
15
|
+
shuffle: boolean;
|
|
16
|
+
seed?: number | undefined;
|
|
17
|
+
} | undefined;
|
|
10
18
|
reporters?: string[] | undefined;
|
|
11
19
|
outputFile?: string | undefined;
|
|
12
20
|
shard?: string | undefined;
|
|
13
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;
|
package/dist/cli-run.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
1
2
|
import path from "node:path";
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { editDistance } from "./config.js";
|
|
2
5
|
/** Vitest options for the run flags of `npx slicetest`; paths are taken from where the command runs, like `--diagrams`. */
|
|
3
6
|
export function runOptions(flags, cwd = process.cwd()) {
|
|
4
7
|
if (flags.shard !== undefined) {
|
|
@@ -6,10 +9,72 @@ export function runOptions(flags, cwd = process.cwd()) {
|
|
|
6
9
|
if (!m || Number(m[1]) < 1 || Number(m[1]) > Number(m[2]))
|
|
7
10
|
throw new Error(`slicetest: --shard takes <index>/<count> with 1 ≤ index ≤ count, e.g. --shard 1/3, got "${flags.shard}"`);
|
|
8
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}"`);
|
|
9
18
|
return {
|
|
10
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) } : {}) } } : {}),
|
|
11
22
|
...(flags.reporter?.length ? { reporters: flags.reporter } : {}),
|
|
12
23
|
...(flags["output-file"] ? { outputFile: path.resolve(cwd, flags["output-file"]) } : {}),
|
|
13
24
|
...(flags.shard ? { shard: flags.shard } : {}),
|
|
14
25
|
};
|
|
15
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
|
+
}
|