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.
Files changed (51) hide show
  1. package/README.md +177 -24
  2. package/dist/cli.js +66 -2
  3. package/dist/config.d.ts +18 -0
  4. package/dist/config.js +13 -0
  5. package/dist/connection.d.ts +5 -0
  6. package/dist/connection.js +31 -0
  7. package/dist/db.d.ts +12 -1
  8. package/dist/db.js +33 -6
  9. package/dist/diagram.d.ts +13 -0
  10. package/dist/diagram.js +89 -0
  11. package/dist/form.d.ts +7 -2
  12. package/dist/form.js +72 -7
  13. package/dist/global-setup.d.ts +5 -0
  14. package/dist/global-setup.js +74 -9
  15. package/dist/graphql.d.ts +34 -0
  16. package/dist/graphql.js +56 -0
  17. package/dist/har.d.ts +49 -0
  18. package/dist/har.js +107 -0
  19. package/dist/http.d.ts +21 -3
  20. package/dist/http.js +47 -10
  21. package/dist/index.d.ts +5 -3
  22. package/dist/init.js +184 -15
  23. package/dist/list.d.ts +26 -0
  24. package/dist/list.js +71 -0
  25. package/dist/matchers.d.ts +16 -0
  26. package/dist/matchers.js +81 -1
  27. package/dist/openapi.d.ts +21 -0
  28. package/dist/openapi.js +32 -1
  29. package/dist/provided.d.ts +1 -0
  30. package/dist/record.d.ts +1 -1
  31. package/dist/record.js +3 -1
  32. package/dist/recording.d.ts +1 -1
  33. package/dist/recording.js +2 -2
  34. package/dist/runtime.d.ts +12 -5
  35. package/dist/runtime.js +78 -22
  36. package/dist/scenario.d.ts +14 -4
  37. package/dist/scenario.js +36 -17
  38. package/dist/schema.d.ts +5 -0
  39. package/dist/schema.js +73 -0
  40. package/dist/stub.d.ts +63 -0
  41. package/dist/stub.js +292 -5
  42. package/dist/timeline.d.ts +9 -0
  43. package/dist/timeline.js +6 -0
  44. package/dist/webhook.d.ts +16 -7
  45. package/dist/webhook.js +47 -7
  46. package/dist/yaml-runtime.d.ts +3 -2
  47. package/dist/yaml-runtime.js +308 -51
  48. package/dist/yaml.d.ts +82 -6
  49. package/dist/yaml.js +187 -21
  50. package/package.json +2 -1
  51. 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`), Django, FastAPI, Flask, Rails, Go and Rust apps; Atlas, Prisma, Alembic, Django, Rails, Drizzle, Knex and plain SQL migrations; and an `openapi.yaml`. If there's a `compose.yaml` / `docker-compose.yml`, its database service sets `db.image` (and `db.engine: mysql` for MySQL or MariaDB), and Redis, Valkey, Mongo, Elasticsearch, MinIO, RabbitMQ and other services with a port become [`containers`](#containers-redis-search-s3-and-other-dependencies), with a reset command where one is known and the usual variable (`REDIS_URL`, `S3_ENDPOINT`, …) passed to the app. A mail catcher there (Mailpit, MailHog, MailDev, smtp4dev, …) or a mail library in the dependencies turns on [`mail`](#mail-catch-what-the-app-sends). SQLite is picked up from Prisma's provider, Rails' `database.yml`, Django's settings or a SQLite driver, with `DATABASE_URL` in the form the framework reads (`file:…`, `sqlite3:…`). And third-party API URLs in `.env.example` (`STRIPE_API_BASE=https://api.stripe.com`) become stubs [recorded from that service](#recording-a-real-service), with the variable pointed at the stub, while local addresses, databases and your own URLs are left alone. 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.
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
- registered on mail: POST /other
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
- Later routes win. `path` may also be a RegExp, and `method` may be `*`. Unanswered calls get a `501` and fail the scenario.
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 }`. Filtered runs (`-t`, a single file) count too, so you may want `minCoverage: process.env.CI ? 100 : undefined`.
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 Stripe, GitHub, Slack, Shopify or any [Standard Webhooks](https://www.standardwebhooks.com/) sender (Svix, Resend, Clerk, …) delivers it, signed with the secret the app is configured with, so the app's real verification code runs. The signatures are checked against the providers' documented examples.
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? }` (gets `DATABASE_URL`). |
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), `when: { query, headers, json, body }`, one of `reply: { status, headers, body }` / `sequence: [...]` / `networkError: true`, plus `times`, `delay`. Replies may echo the call: `{{call.params.id}}`, `{{call.json.name}}`. |
704
- | `submit: <button>` | `form`, `fields`, `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. |
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`, `when`, `times` (exact; default at least once) |
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
- - Expected `json`, `rows` and `headers` are subsets: extra keys are fine. `{ $type: number }`, `{ $regex: "^ch_" }`, `{ $contains: "..." }` and `{ $any: true }` match loosely.
725
- - A file-level `setup:` list runs at the start of every scenario. `skip`, `only` and `timeout` work per scenario.
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"> & {