slicetest 0.6.1 → 0.8.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 +177 -24
- package/dist/cli.js +66 -2
- package/dist/config.d.ts +18 -0
- package/dist/config.js +13 -0
- package/dist/connection.d.ts +5 -0
- package/dist/connection.js +31 -0
- package/dist/db.d.ts +12 -1
- package/dist/db.js +33 -6
- package/dist/diagram.d.ts +13 -0
- package/dist/diagram.js +89 -0
- package/dist/form.d.ts +7 -2
- package/dist/form.js +72 -7
- package/dist/global-setup.d.ts +5 -0
- package/dist/global-setup.js +74 -9
- package/dist/graphql.d.ts +34 -0
- package/dist/graphql.js +56 -0
- package/dist/har.d.ts +49 -0
- package/dist/har.js +107 -0
- package/dist/http.d.ts +21 -3
- package/dist/http.js +47 -10
- package/dist/index.d.ts +5 -3
- package/dist/init.js +184 -15
- package/dist/list.d.ts +26 -0
- package/dist/list.js +71 -0
- package/dist/matchers.d.ts +16 -0
- package/dist/matchers.js +81 -1
- package/dist/openapi.d.ts +21 -0
- package/dist/openapi.js +32 -1
- package/dist/provided.d.ts +1 -0
- package/dist/record.d.ts +1 -1
- package/dist/record.js +3 -1
- package/dist/recording.d.ts +1 -1
- package/dist/recording.js +2 -2
- package/dist/runtime.d.ts +12 -5
- package/dist/runtime.js +78 -22
- package/dist/scenario.d.ts +14 -4
- package/dist/scenario.js +36 -17
- package/dist/schema.d.ts +5 -0
- package/dist/schema.js +73 -0
- package/dist/stub.d.ts +63 -0
- package/dist/stub.js +292 -5
- package/dist/timeline.d.ts +9 -0
- package/dist/timeline.js +6 -0
- package/dist/webhook.d.ts +16 -7
- package/dist/webhook.js +47 -7
- package/dist/yaml-runtime.d.ts +3 -2
- package/dist/yaml-runtime.js +308 -51
- package/dist/yaml.d.ts +82 -6
- package/dist/yaml.js +187 -21
- package/package.json +2 -1
- package/schema/scenario.schema.json +640 -14
package/README.md
CHANGED
|
@@ -38,23 +38,25 @@ npx slicetest init # detects your stack, writes slicetest.config.yaml and a fi
|
|
|
38
38
|
npx slicetest # starts Postgres, migrates, starts your app, runs scenarios/*.scenario.yaml
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
-
`init` recognises Node (`npm start
|
|
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.
|
|
42
42
|
|
|
43
43
|
## What you get that's hard to find elsewhere
|
|
44
44
|
|
|
45
45
|
- **One scenario, three boundaries.** Assert on the HTTP response, the rows in the real database and the calls to third-party APIs in the same test, in any language the app is written in.
|
|
46
46
|
- **`db.changes()`**: a diff of every row the scenario inserted, updated or deleted. `toEqual` on it catches writes you didn't expect.
|
|
47
|
-
- **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.
|
|
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.
|
|
48
48
|
- **Whole-scenario snapshots.** `expect(await trace()).toMatchSnapshot()` pins the responses, the outbound calls and the database changes in one reviewable file, with dates and UUIDs masked.
|
|
49
|
-
- **Record the real service once, replay forever.** Point a stub at the real API with `SLICETEST_RECORD=1`, commit the YAML it writes, and later runs are offline and deterministic.
|
|
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
50
|
- **OpenAPI coverage** of your own API, per operation and status, across all scenarios, and `slicetest gen --uncovered` to scaffold scenarios for what's missing.
|
|
51
51
|
- **Record instead of write.** `npx slicetest record` puts a proxy in front of the app: click through a flow, press Enter, and get a replayable YAML scenario with the stubs' answers, the responses, captured ids and the database changes.
|
|
52
|
-
- **Readable in CI.** On GitHub Actions, failing YAML steps are annotated in the pull request on the line that failed, and the job summary shows the OpenAPI coverage table.
|
|
52
|
+
- **Readable in CI.** On GitHub Actions, failing YAML steps are annotated in the pull request on the line that failed, and the job summary shows the OpenAPI coverage table 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.
|
|
53
54
|
- **Races on purpose.** `http.concurrently(10, ...)` and `toHaveStatuses({ 201: 1, 409: 9 })` turn "what if two people click at once" into a test against the real database.
|
|
54
55
|
- **Mail as a fourth boundary.** `mail: true` catches the app's SMTP traffic in-process, decoded, with the links pulled out, so a sign-up test can follow the confirmation link.
|
|
55
56
|
- **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.
|
|
56
57
|
- **Webhooks signed like the real sender.** Stripe, GitHub, Slack, Shopify and Standard Webhooks signatures, plus forged and replayed deliveries, so signature checks are tested instead of bypassed.
|
|
57
58
|
- **Reproducible chaos.** Stubs can fail the first calls, drop connections or add latency, from a seed the failure output prints, so a resilience test that fails once fails again on demand.
|
|
59
|
+
- **GraphQL on both sides.** Stubs answer by operation name and variables rather than by path, and `toHaveGraphQLData()` fails on the `errors` a GraphQL server returns with status 200.
|
|
58
60
|
- **Forms as a browser sends them.** `http.submit()` presses a button on a server-rendered page, hidden fields included, so CSRF tokens and Next.js server actions work without knowing their internals.
|
|
59
61
|
- **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.
|
|
60
62
|
- **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.
|
|
@@ -142,7 +144,8 @@ slicetest prints what happened during that scenario, next to Vitest's own error:
|
|
|
142
144
|
--- slicetest ---
|
|
143
145
|
stub calls with no matching route:
|
|
144
146
|
mail: POST /send
|
|
145
|
-
|
|
147
|
+
closest route POST /send: json.to: expected "a@example.com", got "b@example.com"
|
|
148
|
+
registered on mail: POST /send + json conditions
|
|
146
149
|
|
|
147
150
|
requests to the app:
|
|
148
151
|
POST /signup → 500 (14ms) {"error":"internal"}
|
|
@@ -158,7 +161,7 @@ TypeError: Cannot read properties of undefined (reading 'email')
|
|
|
158
161
|
-----------------
|
|
159
162
|
```
|
|
160
163
|
|
|
161
|
-
Only this scenario's app output is shown, not the whole log. The database section is a diff against the state right after the reset and seed, so you see what the app actually wrote. Requests that never got a response (for example because the app crashed) appear as `failed`.
|
|
164
|
+
For a call no route answered, slicetest names the route it came closest to and the first thing that differed: the method, a path that is off by a trailing slash, letter case or a base-URL prefix (`/api/v1/charges` against `/v1/charges`), a missing header, or the JSON field and value (`json.items.0.sku: expected "a", got "b"`), the GraphQL operation or variables, or a `once()` route that was already used. Only this scenario's app output is shown, not the whole log. The database section is a diff against the state right after the reset and seed, so you see what the app actually wrote. Requests that never got a response (for example because the app crashed) appear as `failed`.
|
|
162
165
|
|
|
163
166
|
## API
|
|
164
167
|
|
|
@@ -172,14 +175,16 @@ res.status; res.headers; res.text; res.json; res.durationMs;
|
|
|
172
175
|
|
|
173
176
|
await http.get("/polls", { query: { page: 2 }, headers: { accept: "text/html" } });
|
|
174
177
|
await http.post("/login", http.form({ user: "a", pass: "b" })); // urlencoded; FormData, Blob and bytes also work
|
|
178
|
+
await http.post("/orders", http.form({ items: [{ sku: "a" }], tags: ["x", "y"] })); // items[0][sku]=a&tags=x&tags=y
|
|
175
179
|
await http.get("/old-path", { follow: true }); // redirects are NOT followed by default
|
|
176
180
|
await http.submit(await http.get("/signup"), { button: "Sign up", fields: { email: "a@b.test" } }); // a form, as a browser sends it
|
|
181
|
+
await http.graphql("query Poll($id: ID!) { poll(id: $id) { title } }", { id: 1 }); // POST /graphql ({ path } for another)
|
|
177
182
|
|
|
178
183
|
const admin = http.with({ headers: { authorization: `Bearer ${token}` } }); // shares cookies with http
|
|
179
184
|
http.cookies.get("session"); // cookies persist within a scenario
|
|
180
185
|
```
|
|
181
186
|
|
|
182
|
-
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 }` in the plugin config. 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.
|
|
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.
|
|
183
188
|
|
|
184
189
|
#### Forms: `http.submit()`
|
|
185
190
|
|
|
@@ -196,6 +201,8 @@ await http.submit(await http.get("/settings"), {
|
|
|
196
201
|
});
|
|
197
202
|
```
|
|
198
203
|
|
|
204
|
+
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
|
+
|
|
199
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.
|
|
200
207
|
|
|
201
208
|
### `db` — arrange and inspect the real database
|
|
@@ -239,7 +246,7 @@ expect(await db.changes()).toEqual({
|
|
|
239
246
|
});
|
|
240
247
|
```
|
|
241
248
|
|
|
242
|
-
`toEqual` fails if the app wrote to a table you didn't list, which catches unexpected side effects. Each entry in `updated` has `key`, `before`, `after` and `changed`. Tables without a primary key report an update as one deleted row plus one inserted row. `bigint` columns (bigserial ids, `count(*)`) come back as numbers when they fit safely.
|
|
249
|
+
`toEqual` fails if the app wrote to a table you didn't list, which catches unexpected side effects. Values the app sets on every write would make that brittle, so leave them out: `db.changes({ ignore: ["updated_at", "orders.synced_at", "sessions.*"] })` drops a column in every table, a column in one table, or a whole table, and an update that only touched ignored columns isn't reported. `db: { ignoreChanges: [...] }` in the config does the same for every `changes()`, YAML `changes` step (which also takes `ignore:`), `trace()` snapshot and failure output. Each entry in `updated` has `key`, `before`, `after` and `changed`. Tables without a primary key report an update as one deleted row plus one inserted row. `bigint` columns (bigserial ids, `count(*)`) come back as numbers when they fit safely.
|
|
243
250
|
|
|
244
251
|
#### `db.queries()` — the SQL the app ran, from any language
|
|
245
252
|
|
|
@@ -280,10 +287,14 @@ stub("pay").on("GET", "/status").replySequence([{ status: 503 }, { status: 200 }
|
|
|
280
287
|
stub("pay").on("POST", "/charge").delay(5_000).reply(200); // exercise the app's timeouts
|
|
281
288
|
stub("pay").on("POST", "/charge").networkError(); // drop the connection
|
|
282
289
|
|
|
290
|
+
stub("stripe").on("POST", "/v1/payment_intents", { form: { amount: 2000, metadata: { order: "7" } } }).reply(200, { id: "pi_1" }); // form-encoded bodies
|
|
291
|
+
stub("slack").on("POST", "/hook").optional().reply(200); // may go uncalled, even with strictStubs
|
|
283
292
|
stub("slack").calls("POST", "/hook"); // recorded calls: method, path, params, query, headers, body, json
|
|
284
293
|
```
|
|
285
294
|
|
|
286
|
-
|
|
295
|
+
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
|
+
|
|
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)`).
|
|
287
298
|
|
|
288
299
|
### OpenAPI contracts — for your app and for the services you stub
|
|
289
300
|
|
|
@@ -308,6 +319,15 @@ slicetest: traffic doesn't match the OpenAPI spec:
|
|
|
308
319
|
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)
|
|
309
320
|
```
|
|
310
321
|
|
|
322
|
+
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
|
+
|
|
324
|
+
```
|
|
325
|
+
slicetest: the app used 3 of 587 operations of stripe (specs/stripe.yaml), 1 deprecated
|
|
326
|
+
POST /v1/charges ⚠ deprecated
|
|
327
|
+
POST /v1/payment_intents
|
|
328
|
+
GET /v1/customers/{customer}
|
|
329
|
+
```
|
|
330
|
+
|
|
311
331
|
#### `autoReply`: stubs generated from the provider's spec
|
|
312
332
|
|
|
313
333
|
Add `autoReply: true` to a stub with a spec, and calls that no route matches are answered with the provider's documented example, or with values built from the schema (formats such as `email` and `date-time`, enums, `minimum`, `allOf` are respected). Register routes only for what a scenario cares about; a registered route always wins.
|
|
@@ -328,7 +348,7 @@ slicetest: OpenAPI coverage (openapi.yaml): 8/9 documented responses (89%)
|
|
|
328
348
|
POST /polls/{id}/votes 204 ✓ 400 ✓ 404 ✓
|
|
329
349
|
```
|
|
330
350
|
|
|
331
|
-
To fail the run below a threshold, use `openapi: { spec: "openapi.yaml", minCoverage: 100 }`.
|
|
351
|
+
To fail the run below a threshold, use `openapi: { spec: "openapi.yaml", minCoverage: 100 }`. It is checked on full runs only: a run filtered by file, `-t`, `--tag` or a shard still prints the coverage it saw, marked as partial, without failing, and doesn't replace what `gen --uncovered` reads.
|
|
332
352
|
|
|
333
353
|
The example apps in `examples/` run every scenario against `examples/openapi.yaml` with `minCoverage: 100`, and their Slack calls against `examples/slack.openapi.yaml`.
|
|
334
354
|
|
|
@@ -351,6 +371,23 @@ stub("anthropic").on("POST", "/v1/messages").reply(sse([
|
|
|
351
371
|
|
|
352
372
|
In YAML, `reply: { sse: [{ event: message_start, data: { ... } }, ...] }` instead of `body`.
|
|
353
373
|
|
|
374
|
+
#### GraphQL: stubs that answer an operation
|
|
375
|
+
|
|
376
|
+
GraphQL APIs (GitHub, Shopify, Linear, Contentful, your own) take every call at one path, so `on("POST", "/graphql")` can't tell them apart. `stub.graphql()` matches the operation instead, whatever the path, for POSTs with `{ query, variables, operationName }` and GETs with those as query parameters. Without `operationName`, the name in the document counts (`query Viewer { ... }`):
|
|
377
|
+
|
|
378
|
+
```ts
|
|
379
|
+
stub("github").graphql("Viewer").data({ viewer: { login: "octocat" } });
|
|
380
|
+
stub("github").graphql("CreateIssue", { variables: { title: "Bug" } }).data((call) => ({ createIssue: { issue: { number: 1, title: call.graphql.variables.title } } }));
|
|
381
|
+
stub("github").graphql("CreateIssue").once().errors(["rate limited"]); // { errors: [{ message }] } with status 200, as servers do
|
|
382
|
+
|
|
383
|
+
expect(stub("github")).toHaveReceivedGraphQL("CreateIssue", { title: "Bug" }); // variables as a subset
|
|
384
|
+
expect(await http.graphql(REPORT_BUG, { title: "Bug" })).toHaveGraphQLData({ reportBug: { number: 1 } });
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
`toHaveGraphQLData()` fails on a response with `errors` and prints them, which `toHaveStatus(200)` can't, since GraphQL servers report errors with 200. Unanswered operations fail the scenario named as `GraphQL mutation CreateIssue`. `call.graphql` holds `{ operation, type, query, variables }` for every GraphQL call a stub receives.
|
|
388
|
+
|
|
389
|
+
In YAML, a stub step takes `graphql: CreateIssue` instead of `on`, `when: { variables }`, and `reply: { data, errors }`; a request takes `graphql: { query, variables }` instead of `json` and fails on `errors` unless `expect.json` names them; a received step takes `graphql:` instead of `call`.
|
|
390
|
+
|
|
354
391
|
#### Chaos: faults the app must survive
|
|
355
392
|
|
|
356
393
|
`chaos()` makes a stub misbehave for the rest of the scenario, to test retries, timeouts and fallbacks against the app's real HTTP client:
|
|
@@ -430,6 +467,17 @@ SLICETEST_RECORD=github npx vitest # or SLICETEST_RECORD=1 for every stub wi
|
|
|
430
467
|
|
|
431
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.
|
|
432
469
|
|
|
470
|
+
#### From a HAR file: `npx slicetest import`
|
|
471
|
+
|
|
472
|
+
No credentials at hand, or the call happens in a flow that's easier to click through? Save the network traffic as HAR (the browser's network panel: "Save all as HAR"; Charles, mitmproxy, Proxyman and Postman export it too) and import it:
|
|
473
|
+
|
|
474
|
+
```sh
|
|
475
|
+
npx slicetest import session.har # every stub with an upstream that the HAR has requests for
|
|
476
|
+
npx slicetest import session.har --stub stripe --upstream https://api.stripe.com
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
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
|
+
|
|
433
481
|
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.
|
|
434
482
|
|
|
435
483
|
### Matchers
|
|
@@ -442,11 +490,15 @@ expect(responses).toHaveStatuses({ 201: 1, 409: 9 }); // an array, e.
|
|
|
442
490
|
expect(stub("slack")).toHaveReceived("POST", "/hook", { json: { text: "hi" } });
|
|
443
491
|
expect(stub("slack")).toHaveReceivedTimes(1, "POST", "/hook");
|
|
444
492
|
expect(stub("mail")).not.toHaveReceived("POST", "/send");
|
|
493
|
+
expect(stub).toHaveReceivedInOrder([["stripe", "POST", "/v1/charges"], ["mail", "POST", "/send"]]); // across stubs, others may come between
|
|
494
|
+
expect(stub("github")).toHaveReceivedGraphQL("CreateIssue", { title: "Bug" });
|
|
495
|
+
expect(await http.graphql(QUERY)).toHaveGraphQLData({ poll: { title: "x" } }); // no errors, data as a subset
|
|
445
496
|
await expect(db).toHaveRow("polls", { title: "x" }); // at least one row
|
|
446
497
|
await expect(db).toHaveRow("votes", { poll_id: 1 }, 3); // exactly three
|
|
498
|
+
expect(res).toMatchSchema("openapi.yaml#/components/schemas/Poll"); // a response's JSON, or any value; inline schemas too
|
|
447
499
|
```
|
|
448
500
|
|
|
449
|
-
Failure messages list the calls the stub actually received, or the first rows of the table.
|
|
501
|
+
Failure messages list the calls the stub actually received, or the first rows of the table. `toHaveReceivedInOrder` is called on the scenario's `stub` accessor and checks the order of calls across stubs ("charge, then send the receipt"), each entry `[stub, method, path, match?]`; on failure it marks which entry didn't follow and lists every call to those stubs in the order they came. `toMatchSchema` takes a JSON Schema object or a file with an optional pointer (relative to the working directory; JSON or YAML, OpenAPI 3.0 `nullable` understood, `$ref`s resolved within the file), and lists every mismatch by its JSON path: no OpenAPI setup is needed to check one response's shape.
|
|
450
502
|
|
|
451
503
|
### Services: workers and other processes
|
|
452
504
|
|
|
@@ -563,7 +615,24 @@ In YAML, `auth` on a `request` step sends `Authorization: Bearer` with those cla
|
|
|
563
615
|
|
|
564
616
|
### Webhooks: deliveries signed like the provider's
|
|
565
617
|
|
|
566
|
-
`http.webhook()` posts a payload the way
|
|
618
|
+
`http.webhook()` posts a payload the way the provider delivers it, signed with the secret the app is configured with, so the app's real verification code runs:
|
|
619
|
+
|
|
620
|
+
| `provider` | Sends |
|
|
621
|
+
|---|---|
|
|
622
|
+
| `stripe` | `Stripe-Signature: t=…,v1=…` |
|
|
623
|
+
| `github` | `X-Hub-Signature-256`, `X-GitHub-Event` (`event`) |
|
|
624
|
+
| `slack` | `X-Slack-Signature: v0=…`, `X-Slack-Request-Timestamp` |
|
|
625
|
+
| `shopify` | `X-Shopify-Hmac-Sha256`, `X-Shopify-Topic` (`event`) |
|
|
626
|
+
| `standard` | [Standard Webhooks](https://www.standardwebhooks.com/) (Svix, Resend, Clerk, …): `webhook-id`, `webhook-timestamp`, `webhook-signature` |
|
|
627
|
+
| `line` | LINE Messaging API: `X-Line-Signature` (base64, with the channel secret) |
|
|
628
|
+
| `paddle` | Paddle Billing: `Paddle-Signature: ts=…;h1=…` |
|
|
629
|
+
| `linear` | `Linear-Signature`, `Linear-Event` (`event`) |
|
|
630
|
+
| `gitlab` | `X-Gitlab-Token` (the secret token), `X-Gitlab-Event` (`event`, default `Push Hook`) |
|
|
631
|
+
| `zoom` | `x-zm-signature: v0=…`, `x-zm-request-timestamp` |
|
|
632
|
+
| `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
|
+
| `twitch` | EventSub: `Twitch-Eventsub-Message-Signature`, `-Id`, `-Timestamp`, `-Type` (`event`, default `notification`) |
|
|
634
|
+
|
|
635
|
+
The signatures are checked against the providers' documented examples where they publish one.
|
|
567
636
|
|
|
568
637
|
```ts
|
|
569
638
|
const stripe = { provider: "stripe", secret: "whsec_test" } as const; // the same secret as in app.env
|
|
@@ -576,7 +645,7 @@ expect(await http.webhook("/webhooks/stripe", event, { ...stripe, invalidSignatu
|
|
|
576
645
|
expect(await http.webhook("/webhooks/stripe", event, { ...stripe, stale: true })).toHaveStatus(400); // signed 10 minutes ago
|
|
577
646
|
```
|
|
578
647
|
|
|
579
|
-
Objects are sent as JSON, `URLSearchParams` as a form (Slack slash commands), strings as they are. Other HMAC schemes: `provider: { header: "X-Signature", prefix: "sha256=", encoding: "hex" }`. `signWebhook(body, opts)` returns just the headers. In YAML, add `webhook: { provider, secret, event, stale, invalidSignature }` to a `request` step; its `json`, `form` or `body` is what gets signed.
|
|
648
|
+
Objects are sent as JSON, `URLSearchParams` as a form (Slack slash commands), strings as they are. Other HMAC schemes: `provider: { header: "X-Signature", prefix: "sha256=", encoding: "hex" }`. `signWebhook(body, opts)` returns just the headers. In YAML, add `webhook: { provider, secret, event, stale, invalidSignature, url }` to a `request` step; its `json`, `form` or `body` is what gets signed.
|
|
580
649
|
|
|
581
650
|
### Asynchronous side effects
|
|
582
651
|
|
|
@@ -607,10 +676,31 @@ Dates (`Date` values and ISO strings) become `[date]` and UUIDs `[uuid]`. Mask m
|
|
|
607
676
|
|
|
608
677
|
The example apps share one snapshot file: the Node and the Python implementation must produce the same trace, byte for byte.
|
|
609
678
|
|
|
679
|
+
### Sequence diagrams of every scenario
|
|
680
|
+
|
|
681
|
+
`await diagram()` returns the scenario so far as a [Mermaid](https://mermaid.js.org/) sequence diagram: each request to the app, the stub calls the app made while answering it (GraphQL ones by operation), their replies, the mail it sent and the tables it changed:
|
|
682
|
+
|
|
683
|
+
```mermaid
|
|
684
|
+
sequenceDiagram
|
|
685
|
+
participant test as scenario
|
|
686
|
+
participant app
|
|
687
|
+
participant s_slack as slack (stub)
|
|
688
|
+
participant db as database
|
|
689
|
+
test->>+app: POST /polls
|
|
690
|
+
app->>+s_slack: POST /hook
|
|
691
|
+
s_slack-->>-app: 200
|
|
692
|
+
app-->>-test: 201 id: 1
|
|
693
|
+
Note over app,db: polls +1
|
|
694
|
+
```
|
|
695
|
+
|
|
696
|
+
- **Living documentation.** `npx slicetest --diagrams docs/flows` (or `SLICETEST_DIAGRAMS=docs/flows` with Vitest) writes one Markdown page per scenario file, with a diagram for each scenario. Commit them, and a pull request that changes how the app talks to the outside world shows it as a diagram diff.
|
|
697
|
+
- **Failures on GitHub Actions.** A failing scenario's diagram goes to the job summary, collapsed under its name, next to the error.
|
|
698
|
+
|
|
610
699
|
### Scenarios
|
|
611
700
|
|
|
612
701
|
```ts
|
|
613
|
-
scenario("name", async ({ http, db, stub, app, service, container, trace }) => { ... }, timeoutMs?);
|
|
702
|
+
scenario("name", async ({ http, db, stub, app, service, container, trace, diagram }) => { ... }, timeoutMs?);
|
|
703
|
+
scenario("refunds a charge", async (ctx) => { ... }, { tags: ["payments", "slow"], timeout: 10_000 });
|
|
614
704
|
scenario.only / scenario.skip / scenario.todo
|
|
615
705
|
scenario.each([{ choice: "a", status: 204 }, { choice: "x", status: 400 }])(
|
|
616
706
|
"voting $choice returns $status",
|
|
@@ -620,6 +710,8 @@ scenario.each([{ choice: "a", status: 204 }, { choice: "x", status: 400 }])(
|
|
|
620
710
|
|
|
621
711
|
Scenarios in one file share an app and a database, so they always run one at a time; `.concurrent` is rejected.
|
|
622
712
|
|
|
713
|
+
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
|
+
|
|
623
715
|
### Configuration reference
|
|
624
716
|
|
|
625
717
|
| Option | Default | |
|
|
@@ -627,16 +719,17 @@ Scenarios in one file share an app and a database, so they always run one at a t
|
|
|
627
719
|
| `app.command` | (required) | Shell command. May use `{{app.port}}` and the other placeholders. |
|
|
628
720
|
| `app.build` | none | Shell command run once per run before the app starts, while the database starts, e.g. `npm run build` for `next start`. It gets `app.env`'s literal values (Next.js inlines `NEXT_PUBLIC_*` at build time), not those with placeholders. Not repeated in watch mode. Services take `build` too. |
|
|
629
721
|
| `db` | Postgres in a container | Database options (below), or `false` for an app without a database: nothing is started, `{{db.*}}` aren't set and `db.*` in scenarios explains that it's off. `slicetest init` writes `false` when it finds no migrations, no database service and no database library. |
|
|
630
|
-
| `app.env` | `{ PORT, DATABASE_URL }` | Values may use `{{app.port}}`, `{{db.url}}`, `{{stub.<name>}}`. For apps that don't take one URL: `{{db.jdbcUrl}}` (`jdbc:postgresql://…`), `{{db.host}}`, `{{db.port}}`, `{{db.name}}`, `{{db.user}}`, `{{db.password}}`. The rest of `process.env` is inherited. |
|
|
722
|
+
| `app.env` | `{ PORT, DATABASE_URL }` | Values may use `{{app.port}}`, `{{db.url}}`, `{{stub.<name>}}`. For apps that don't take one URL: `{{db.jdbcUrl}}` (`jdbc:postgresql://…`), `{{db.adoNet}}` (an ADO.NET connection string, `Host=…;Port=…;Database=…;Username=…;Password=…`, for .NET's `ConnectionStrings__Default`), `{{db.host}}`, `{{db.port}}`, `{{db.name}}`, `{{db.user}}`, `{{db.password}}`. The rest of `process.env` is inherited. |
|
|
631
723
|
| `app.cwd` | vitest root | |
|
|
632
724
|
| `app.ready` | `{ path: "/" }` | Poll a path until it answers below 500, or `{ log: "listening" \| /regex/ }`. |
|
|
633
725
|
| `app.readyTimeout` | `30000` | |
|
|
634
726
|
| `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`. |
|
|
635
727
|
| `db.engine` | `postgres`, or `mysql` for a `mysql://` URL | `postgres`, `mysql` (see [MySQL](#mysql)) or `sqlite` (see [SQLite](#sqlite)). |
|
|
636
|
-
| `db.migrate` | none | `{ atlas: { dir } }`, `{ sql: "file-or-dir" }` or `{ command, inputs? }
|
|
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}}\"" }`. |
|
|
637
729
|
| `db.seed` | none | SQL file re-run after every reset. |
|
|
638
730
|
| `db.schemas` | `["public"]` | Schemas whose tables are reset. |
|
|
639
731
|
| `db.keep` | `[]` | Extra tables (`name` or `schema.name`) never truncated. |
|
|
732
|
+
| `db.ignoreChanges` | `[]` | Columns (`updated_at`, `orders.synced_at`) and tables (`sessions.*`) left out of `db.changes()`, `trace()` and the failure output. |
|
|
640
733
|
| `db.neon` | `false` | For Neon's serverless driver over HTTP: `{{db.url}}` is a Neon-style URL and slicetest answers the driver's queries from the test database ([Neon](#neon-and-vercel-postgres)). |
|
|
641
734
|
| `db.queries` | `false` | Point the app at a proxy that records its SQL (Postgres, MySQL), for [`db.queries()`](#dbqueries--the-sql-the-app-ran-from-any-language). |
|
|
642
735
|
| `db.url` | `$SLICETEST_DATABASE_URL`, else a container | Use an existing Postgres server (e.g. a CI service container) instead of Testcontainers. |
|
|
@@ -647,6 +740,7 @@ Scenarios in one file share an app and a database, so they always run one at a t
|
|
|
647
740
|
| `auth` | `false` | `true` or `{ audience, claims }`: an OpenID Connect issuer at `{{auth.issuer}}` (JWKS at `{{auth.jwks}}`) whose tokens scenarios mint with `auth.token()`. See [Auth](#auth-a-real-openid-issuer-tokens-with-any-claims). |
|
|
648
741
|
| `services` | `{}` | Other processes: `{ name: { command, env?, cwd?, ready?, readyTimeout? } }`. Without `ready` a service is not waited for. |
|
|
649
742
|
| `offline` | `false` | Refuse the app's HTTP(S) calls to hosts no stub intercepts, and fail the scenario naming them. |
|
|
743
|
+
| `strictStubs` | `false` | Fail a scenario that registered a stub route the app never called, so a test can't pass without reaching the code it set up for. Exempt a route with `.optional()` (YAML `optional: true`). Unused routes are listed in the failure output either way. |
|
|
650
744
|
| `workers` | Vitest's default | Most Vitest workers (`maxWorkers`). Each has its own app and database. |
|
|
651
745
|
| `stubs` | `[]` | Names of stubbed services, or `{ name, openapi?, autoReply?, upstream?, recordings?, hosts? }`: check calls against the provider's spec, answer from it, [replay recordings](#recording-a-real-service) of the real service, or answer for [hard-coded hosts](#hard-coded-hosts-hosts). |
|
|
652
746
|
| `openapi` | none | The app's OpenAPI 3 spec, or `{ spec, minCoverage }`, or `{ fromApp: "/v3/api-docs" }` for a spec the running app serves (springdoc, FastAPI's `/openapi.json`, NestJS). Every response must match it; the run ends with a coverage report. |
|
|
@@ -700,9 +794,9 @@ scenarios:
|
|
|
700
794
|
|
|
701
795
|
| Step | Keys |
|
|
702
796
|
|---|---|
|
|
703
|
-
| `stub: <name>` | `on: METHOD /path` (`:params` allowed)
|
|
704
|
-
| `submit: <button>` | `form`, `fields
|
|
705
|
-
| `request: METHOD /path` | `headers`, `query`, one of `json` / `form` / `body`, `follow`, `expect: { status, headers, json, text }`, `capture`. `concurrency: n` sends it `n` times at once; `expect` then applies to each response, and `expect.statuses: { 201: 1, 409: 9 }` counts them. |
|
|
797
|
+
| `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. |
|
|
706
800
|
| `insert: <table>` | `rows`, `capture` (from `row` / `rows`) |
|
|
707
801
|
| `request` with `auth` | `auth: true` or the claims: sends a bearer token from the `auth` issuer |
|
|
708
802
|
| `request` with `webhook` | `{ provider, secret, event, stale, invalidSignature }`: signs the body like that provider's deliveries |
|
|
@@ -710,21 +804,79 @@ scenarios:
|
|
|
710
804
|
| `make: <table>` | `rows` (a mapping, or a list for several rows), `count`, `capture` (from `row` / `rows`) — like `db.make()` |
|
|
711
805
|
| `db: <table>` | `where`, `orderBy`, `expect: { rows, count }`, `capture` |
|
|
712
806
|
| `sql: <query>` | `params`, `expect: { rows, count }`, `capture` |
|
|
713
|
-
| `received: <stub>` | `call: METHOD /path
|
|
807
|
+
| `received: <stub>` | `call: METHOD /path` or `graphql: <operation>`, `when`, `times` (exact; default at least once) |
|
|
808
|
+
| `order: [...]` | Calls to stubs in the order they must have come, others allowed between: `"stripe POST /v1/charges"` or `{ stub, call, when }`. Like `toHaveReceivedInOrder`. |
|
|
714
809
|
| `log: <regex>` | `from` (a service; default the app), `within` (ms, default 5000). Waits for a matching line printed during the scenario. |
|
|
715
|
-
| `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. |
|
|
810
|
+
| `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. |
|
|
716
811
|
| `checkpoint: true` | Later `changes` steps only see what happens after this step. |
|
|
812
|
+
| `set: { name: value }` | Defines variables for later steps, e.g. `{ orderId: "{{$uuid}}", expires: "{{$now+1d}}" }`. |
|
|
717
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. |
|
|
814
|
+
| `use: <definition>` | `with: { param: value }`. Runs the steps of a `define:` entry. |
|
|
718
815
|
| `snapshot: true` | The scenario's [trace](#snapshot-the-whole-scenario-trace) so far must match its stored snapshot. `mask: [keys]` hides more values. |
|
|
719
816
|
|
|
720
|
-
`db`, `sql`, `received` and `changes` steps take `within: <ms>` to retry until they pass, for effects the app applies asynchronously.
|
|
817
|
+
`db`, `sql`, `received` and `changes` steps take `within: <ms>` to retry until they pass, for effects the app applies asynchronously. A `request` step with `within` sends the request again (every 200 ms, or `every: <ms>`) until its `expect` passes, for a job the app answers `202` and finishes later:
|
|
818
|
+
|
|
819
|
+
```yaml
|
|
820
|
+
- request: POST /exports
|
|
821
|
+
expect: { status: 202 }
|
|
822
|
+
capture: { job: json.id }
|
|
823
|
+
- request: GET /exports/{{job}}
|
|
824
|
+
within: 10000
|
|
825
|
+
expect: { json: { status: done } }
|
|
826
|
+
capture: { file: json.url }
|
|
827
|
+
```
|
|
721
828
|
|
|
829
|
+
- `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
|
+
- `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.
|
|
722
831
|
- `request:` also takes a captured URL of the app, e.g. `GET {{link}}` after capturing a link from a mail.
|
|
723
832
|
- `{{name}}` inserts a captured value or an `each` field. A string that is only `{{name}}` keeps the value's type, so `id: "{{pollId}}"` compares as a number.
|
|
724
|
-
-
|
|
725
|
-
-
|
|
833
|
+
- Built-ins: `{{$uuid}}` (a new one each time), `{{$seq}}` (1, 2, 3… per scenario, the same on every run), `{{$now}}` (ISO time), `{{$today}}` (`YYYY-MM-DD`, UTC), `{{$timestamp}}` (Unix seconds) and `{{$timestampMs}}`; the time ones take an offset: `{{$now+7d}}`, `{{$timestamp-30m}}` (`ms`, `s`, `m`, `h`, `d`). `{{env.NAME}}` reads an environment variable, for tokens a CI job provides. In a stub's reply they're evaluated per call, so `id: "ch_{{$seq}}"` gives each call its own id.
|
|
834
|
+
- Expected `json`, `rows` and `headers` are subsets: extra keys are fine. Values match loosely with:
|
|
835
|
+
|
|
836
|
+
| Matcher | Matches |
|
|
837
|
+
|---|---|
|
|
838
|
+
| `{ $type: number }` | `string`, `number`, `integer`, `boolean`, `array`, `object`, `null` |
|
|
839
|
+
| `{ $regex: "^ch_" }` | a string the pattern finds |
|
|
840
|
+
| `{ $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` |
|
|
842
|
+
| `{ $len: 3 }` | a string or list of that length; `{ $len: { $gte: 1 } }` |
|
|
843
|
+
| `{ $oneOf: [paid, pending] }` | any of the values (or matchers) |
|
|
844
|
+
| `{ $not: "" }` | anything the value or matcher doesn't match |
|
|
845
|
+
| `{ $format: uuid }` | `uuid`, `email`, `date`, `date-time`, `uri`, `integer` (a string of digits) |
|
|
846
|
+
| `{ $any: true }` | anything but `null` / missing |
|
|
847
|
+
|
|
848
|
+
Several `$` keys in one mapping must all hold.
|
|
849
|
+
- A file-level `setup:` list runs at the start of every scenario. `skip`, `only`, `timeout` and `tags` work per scenario.
|
|
850
|
+
- A file-level `define:` names step lists that `use:` steps run, like functions: `params` are given with `with:` and are `{{variables}}` inside, and what the steps capture is visible after the `use` (see below).
|
|
726
851
|
- Mistakes are reported with the file and line before anything runs (`polls.scenario.yaml:12: unknown key "stauts" in expect`). A failing step reports its file, line and step number. The JSON Schema in `schema/` gives editors completion and inline errors.
|
|
727
852
|
|
|
853
|
+
### Reusing steps: `define` and `use`
|
|
854
|
+
|
|
855
|
+
```yaml
|
|
856
|
+
define:
|
|
857
|
+
signed up:
|
|
858
|
+
params: [email]
|
|
859
|
+
steps:
|
|
860
|
+
- request: POST /signup
|
|
861
|
+
json: { email: "{{email}}", password: hunter22 }
|
|
862
|
+
expect: { status: 201 }
|
|
863
|
+
capture: { userId: json.id }
|
|
864
|
+
- request: POST /login
|
|
865
|
+
json: { email: "{{email}}", password: hunter22 }
|
|
866
|
+
capture: { token: json.token }
|
|
867
|
+
|
|
868
|
+
scenarios:
|
|
869
|
+
- name: a new user has an empty cart
|
|
870
|
+
steps:
|
|
871
|
+
- use: signed up
|
|
872
|
+
with: { email: ada@example.com }
|
|
873
|
+
- request: GET /users/{{userId}}/cart
|
|
874
|
+
headers: { authorization: "Bearer {{token}}" }
|
|
875
|
+
expect: { json: { items: [] } }
|
|
876
|
+
```
|
|
877
|
+
|
|
878
|
+
A definition is a list of steps, or `{ params, steps }`, and may `use` other definitions. Unknown names, missing or extra `with` keys and definitions that use themselves are reported with their line before anything runs. A failing step inside one names the whole path: `cart.scenario.yaml:5 (a new user has an empty cart, step 1: use signed up → step 2: POST /login)`.
|
|
879
|
+
|
|
728
880
|
### Without any JavaScript: `npx slicetest`
|
|
729
881
|
|
|
730
882
|
Put the plugin options in `slicetest.config.yaml` and run the CLI. It needs Node, but no `package.json` scripts, TypeScript or Vitest config. To add TypeScript scenarios later, `slicetest()` without options in a `vitest.config.ts` reads the same file (`slicetest("path/to/config.yaml")` for another one):
|
|
@@ -744,6 +896,7 @@ stubs: [slack]
|
|
|
744
896
|
npx slicetest # every *.scenario.yaml under the config's directory
|
|
745
897
|
npx slicetest polls -t voting # filter by file and scenario name
|
|
746
898
|
npx slicetest --watch
|
|
899
|
+
npx slicetest list --tag smoke # what would run: file, line, tags, steps (--json for tools)
|
|
747
900
|
```
|
|
748
901
|
|
|
749
902
|
### Scenarios from your OpenAPI spec: `npx slicetest gen`
|
package/dist/cli.js
CHANGED
|
@@ -15,6 +15,8 @@ const HELP = `Usage: slicetest [filters...] [options]
|
|
|
15
15
|
slicetest gen [--spec <file>] [--out <dir>] [--uncovered] [--force]
|
|
16
16
|
slicetest doctor [--config <file>]
|
|
17
17
|
slicetest record [--out <file>] [--port <n>]
|
|
18
|
+
slicetest import <file.har> [--stub <name>] [--upstream <url>]
|
|
19
|
+
slicetest list [filters...] [--tag <tag>] [-t <pattern>] [--json]
|
|
18
20
|
|
|
19
21
|
Runs *.scenario.yaml files against your app, as configured in slicetest.config.yaml.
|
|
20
22
|
\`slicetest init\` looks at the project and writes a starting config and scenario.
|
|
@@ -24,29 +26,42 @@ app's OpenAPI spec; with --uncovered, only for those the last run didn't produce
|
|
|
24
26
|
migrations, commands and spec files, and says what to fix.
|
|
25
27
|
\`slicetest record\` starts everything and a proxy in front of the app: use the app
|
|
26
28
|
through it (a browser, curl), press Enter, and get the session as a YAML scenario.
|
|
29
|
+
\`slicetest list\` shows the YAML scenarios (file, line, tags, steps) and which ones
|
|
30
|
+
--tag / -t / filters select, without starting anything; --json for tools.
|
|
31
|
+
\`slicetest import\` turns a HAR file (the browser's network panel: "Save all as
|
|
32
|
+
HAR"; Charles, mitmproxy, Proxyman) into recordings for the stubs whose
|
|
33
|
+
\`upstream\` it has requests for, so they replay the real service's answers.
|
|
27
34
|
|
|
28
35
|
Options:
|
|
29
36
|
-c, --config <file> Config file (default: ${CONFIG_NAMES.join(" / ")} in the current directory)
|
|
30
37
|
-w, --watch Re-run on changes
|
|
31
38
|
-t, --name <pattern> Only run scenarios whose name matches
|
|
39
|
+
--tag <tag> Only run scenarios with this tag (repeat for any of several;
|
|
40
|
+
!tag leaves those out), like SLICETEST_TAGS=smoke,!slow
|
|
32
41
|
--spec <file> gen: OpenAPI file (default: \`openapi\` from the config)
|
|
33
42
|
--out <dir> gen: where to write scenarios (default: scenarios)
|
|
34
43
|
record: the scenario file (default: scenarios/recorded-<time>.scenario.yaml)
|
|
35
44
|
--port <n> record: the proxy's port (default: any free port)
|
|
45
|
+
--stub <name> import: only this stub (with --upstream, one not in the config)
|
|
46
|
+
--upstream <url> import: the real service's base URL for --stub
|
|
36
47
|
--uncovered gen: only responses the last run didn't cover
|
|
37
48
|
--force init, gen: overwrite existing files
|
|
49
|
+
--diagrams <dir> Write a Mermaid sequence diagram of every scenario to <dir>,
|
|
50
|
+
one Markdown page per scenario file
|
|
38
51
|
-h, --help Show this help
|
|
39
52
|
|
|
40
53
|
Config (paths are relative to the config file):
|
|
41
54
|
app: { command, env, cwd, ready: { path } | { log }, readyTimeout }
|
|
42
|
-
db: { migrate: { atlas: { dir } } | { sql } | { command, inputs }, engine, seed, url, image, schemas, keep, reuse, queries }
|
|
55
|
+
db: { migrate: { atlas: { dir } } | { sql } | { command, inputs }, engine, seed, url, image, schemas, keep, ignoreChanges, reuse, queries }
|
|
43
56
|
stubs: [name | { name, openapi, autoReply, upstream, recordings }]
|
|
44
57
|
services: { name: { command, env, cwd, ready } }
|
|
45
58
|
containers: { name: { image, port, env, command, ready: { log }, reset } }
|
|
46
59
|
mail: true SMTP server at {{mail.host}} / {{mail.port}}
|
|
47
60
|
auth: true | { audience, claims } OpenID issuer at {{auth.issuer}} / {{auth.jwks}}
|
|
48
61
|
openapi: file | { spec, minCoverage }
|
|
49
|
-
http: { headers, query }
|
|
62
|
+
http: { headers, query, timeout }
|
|
63
|
+
offline: true refuse calls to hosts no stub answers
|
|
64
|
+
strictStubs: true fail scenarios with stub routes the app never called
|
|
50
65
|
include: [globs] default ["**/*.scenario.{yaml,yml}"]
|
|
51
66
|
`;
|
|
52
67
|
export async function main(argv = process.argv.slice(2)) {
|
|
@@ -57,12 +72,17 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
57
72
|
config: { type: "string", short: "c" },
|
|
58
73
|
watch: { type: "boolean", short: "w" },
|
|
59
74
|
name: { type: "string", short: "t" },
|
|
75
|
+
tag: { type: "string", multiple: true },
|
|
60
76
|
help: { type: "boolean", short: "h" },
|
|
61
77
|
force: { type: "boolean" },
|
|
62
78
|
spec: { type: "string" },
|
|
63
79
|
out: { type: "string" },
|
|
64
80
|
uncovered: { type: "boolean" },
|
|
65
81
|
port: { type: "string" },
|
|
82
|
+
diagrams: { type: "string" },
|
|
83
|
+
stub: { type: "string" },
|
|
84
|
+
upstream: { type: "string" },
|
|
85
|
+
json: { type: "boolean" },
|
|
66
86
|
},
|
|
67
87
|
});
|
|
68
88
|
if (values.help) {
|
|
@@ -106,6 +126,45 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
106
126
|
process.stdout.write(`${lines.join("\n")}\n`);
|
|
107
127
|
return;
|
|
108
128
|
}
|
|
129
|
+
if (positionals[0] === "import") {
|
|
130
|
+
const har = positionals[1];
|
|
131
|
+
if (!har)
|
|
132
|
+
throw new Error("slicetest import: which HAR file? e.g. npx slicetest import session.har");
|
|
133
|
+
const config = configPath && existsSync(configPath) ? (parse(await readFile(configPath, "utf8")) ?? {}) : undefined;
|
|
134
|
+
const root = configPath && config ? path.dirname(configPath) : process.cwd();
|
|
135
|
+
const declared = (config?.stubs ?? []).flatMap((s) => (typeof s === "object" && s.upstream ? [s] : []));
|
|
136
|
+
let targets = declared.map((s) => ({ name: s.name, upstream: s.upstream, file: path.resolve(root, s.recordings ?? `recordings/${s.name}.yaml`) }));
|
|
137
|
+
if (values.stub) {
|
|
138
|
+
const known = targets.find((t) => t.name === values.stub);
|
|
139
|
+
const upstream = values.upstream ?? known?.upstream;
|
|
140
|
+
if (!upstream)
|
|
141
|
+
throw new Error(`slicetest import: stub "${values.stub}" has no upstream in the config; pass --upstream https://api.example.com`);
|
|
142
|
+
targets = [{ name: values.stub, upstream, file: known?.file ?? path.resolve(root, `recordings/${values.stub}.yaml`) }];
|
|
143
|
+
}
|
|
144
|
+
if (targets.length === 0)
|
|
145
|
+
throw new Error("slicetest import: no stub to import into. Give stubs an `upstream` in the config, or pass --stub <name> --upstream <url>.");
|
|
146
|
+
const { importHar } = await import("./har.js");
|
|
147
|
+
const { written, skipped, others } = await importHar(path.resolve(har), targets);
|
|
148
|
+
const lines = [
|
|
149
|
+
...written.map((w) => ` ${w.name}: ${w.count} recording(s) → ${path.relative(process.cwd(), w.file)}`),
|
|
150
|
+
...(written.length === 0 ? [`No requests in ${har} go to ${targets.map((t) => t.upstream).join(", ")}.`] : []),
|
|
151
|
+
...(skipped ? [` skipped ${skipped} preflight, aborted or binary request(s)`] : []),
|
|
152
|
+
...(others.length ? ["", "Requests to other hosts (not imported):", ...others.slice(0, 10).map(([h, n]) => ` ${h} (${n})`), "Import one with --stub <name> --upstream <url>."] : []),
|
|
153
|
+
];
|
|
154
|
+
process.stdout.write(`${lines.join("\n")}\n`);
|
|
155
|
+
if (written.length === 0)
|
|
156
|
+
process.exitCode = 1;
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (positionals[0] === "list") {
|
|
160
|
+
const root = configPath && existsSync(configPath) ? path.dirname(configPath) : process.cwd();
|
|
161
|
+
const { listScenarios, formatList } = await import("./list.js");
|
|
162
|
+
const listed = await listScenarios(root, { filters: positionals.slice(1), name: values.name, tags: values.tag?.join(",") });
|
|
163
|
+
process.stdout.write(values.json ? `${JSON.stringify(listed, null, 2)}\n` : formatList(listed));
|
|
164
|
+
if (listed.errors.length)
|
|
165
|
+
process.exitCode = 1;
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
109
168
|
if (!configPath || !existsSync(configPath)) {
|
|
110
169
|
process.stderr.write(`slicetest: no config found. Run "npx slicetest init" to create ${CONFIG_NAMES[0]} (see --help).\n`);
|
|
111
170
|
process.exitCode = 1;
|
|
@@ -117,6 +176,11 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
117
176
|
await record(configPath, options, { out: values.out, port: values.port ? Number(values.port) : 0 });
|
|
118
177
|
return;
|
|
119
178
|
}
|
|
179
|
+
// Workers inherit the environment; a relative directory is taken from where the command runs.
|
|
180
|
+
if (values.diagrams)
|
|
181
|
+
process.env.SLICETEST_DIAGRAMS = path.resolve(values.diagrams);
|
|
182
|
+
if (values.tag?.length)
|
|
183
|
+
process.env.SLICETEST_TAGS = values.tag.join(",");
|
|
120
184
|
const { startVitest, version } = await import("vitest/node");
|
|
121
185
|
if (Number.parseInt(version, 10) < 4) {
|
|
122
186
|
process.stderr.write(`slicetest: needs Vitest 4 or later, and this project has Vitest ${version}. Upgrade it (npm i -D vitest@latest), or run slicetest from a folder with its own package.json.\n`);
|
package/dist/config.d.ts
CHANGED
|
@@ -52,6 +52,11 @@ export interface SlicetestOptions {
|
|
|
52
52
|
* scenario, naming the host, so a forgotten stub can't reach a real service.
|
|
53
53
|
*/
|
|
54
54
|
offline?: boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Fail a scenario that registered a stub route the app never called: the test may not be
|
|
57
|
+
* exercising what it set up. Routes marked `.optional()` (YAML `optional: true`) are exempt.
|
|
58
|
+
*/
|
|
59
|
+
strictStubs?: boolean;
|
|
55
60
|
/**
|
|
56
61
|
* Most Vitest workers to run test files in (Vitest's `maxWorkers`). Each worker has
|
|
57
62
|
* its own app and database; with `app.scope: "worker"`, fewer workers means fewer app
|
|
@@ -170,6 +175,12 @@ export interface DbOptions {
|
|
|
170
175
|
schemas?: string[];
|
|
171
176
|
/** Extra tables kept across resets, in addition to known migration bookkeeping tables. */
|
|
172
177
|
keep?: string[];
|
|
178
|
+
/**
|
|
179
|
+
* Columns and tables `db.changes()`, YAML `changes` steps, `trace()` and the failure output leave out:
|
|
180
|
+
* `updated_at` (that column in every table), `orders.synced_at`, `sessions.*` (a whole table).
|
|
181
|
+
* For values the app sets on every write, so they don't fail a `toEqual` or a snapshot.
|
|
182
|
+
*/
|
|
183
|
+
ignoreChanges?: string[];
|
|
173
184
|
/**
|
|
174
185
|
* Keep the Postgres container running between runs and cache the migrated
|
|
175
186
|
* template by the contents of the migrations, so a run with unchanged
|
|
@@ -204,6 +215,12 @@ export type MigrateOptions = {
|
|
|
204
215
|
* without `inputs`, the command runs on every run.
|
|
205
216
|
*/
|
|
206
217
|
inputs?: string[];
|
|
218
|
+
/**
|
|
219
|
+
* Extra environment for the command, for tools that don't read `DATABASE_URL`
|
|
220
|
+
* (Laravel's `DB_HOST`, EF Core's connection string): `{ DB_HOST: "{{db.host}}" }`.
|
|
221
|
+
* The command itself may use the same `{{db.*}}` placeholders.
|
|
222
|
+
*/
|
|
223
|
+
env?: Record<string, string>;
|
|
207
224
|
};
|
|
208
225
|
/** Normalized shape passed from the plugin to globalSetup and workers. Must stay JSON-serializable. */
|
|
209
226
|
export interface ResolvedOptions {
|
|
@@ -216,6 +233,7 @@ export interface ResolvedOptions {
|
|
|
216
233
|
containers: Record<string, ContainerOptions>;
|
|
217
234
|
mail: boolean;
|
|
218
235
|
offline: boolean;
|
|
236
|
+
strictStubs: boolean;
|
|
219
237
|
workers?: number;
|
|
220
238
|
auth: AuthOptions | false;
|
|
221
239
|
db: Required<Pick<DbOptions, "engine" | "image" | "schemas" | "keep" | "reuse">> & Omit<DbOptions, "engine" | "image" | "schemas" | "keep" | "reuse"> & {
|