slicetest 0.7.0 → 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 CHANGED
@@ -175,6 +175,7 @@ res.status; res.headers; res.text; res.json; res.durationMs;
175
175
 
176
176
  await http.get("/polls", { query: { page: 2 }, headers: { accept: "text/html" } });
177
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
178
179
  await http.get("/old-path", { follow: true }); // redirects are NOT followed by default
179
180
  await http.submit(await http.get("/signup"), { button: "Sign up", fields: { email: "a@b.test" } }); // a form, as a browser sends it
180
181
  await http.graphql("query Poll($id: ID!) { poll(id: $id) { title } }", { id: 1 }); // POST /graphql ({ path } for another)
@@ -183,7 +184,7 @@ const admin = http.with({ headers: { authorization: `Bearer ${token}` } }); // s
183
184
  http.cookies.get("session"); // cookies persist within a scenario
184
185
  ```
185
186
 
186
- 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.
187
188
 
188
189
  #### Forms: `http.submit()`
189
190
 
@@ -200,6 +201,8 @@ await http.submit(await http.get("/settings"), {
200
201
  });
201
202
  ```
202
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
+
203
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.
204
207
 
205
208
  ### `db` — arrange and inspect the real database
@@ -243,7 +246,7 @@ expect(await db.changes()).toEqual({
243
246
  });
244
247
  ```
245
248
 
246
- `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.
247
250
 
248
251
  #### `db.queries()` — the SQL the app ran, from any language
249
252
 
@@ -284,10 +287,13 @@ stub("pay").on("GET", "/status").replySequence([{ status: 503 }, { status: 200 }
284
287
  stub("pay").on("POST", "/charge").delay(5_000).reply(200); // exercise the app's timeouts
285
288
  stub("pay").on("POST", "/charge").networkError(); // drop the connection
286
289
 
290
+ stub("stripe").on("POST", "/v1/payment_intents", { form: { amount: 2000, metadata: { order: "7" } } }).reply(200, { id: "pi_1" }); // form-encoded bodies
287
291
  stub("slack").on("POST", "/hook").optional().reply(200); // may go uncalled, even with strictStubs
288
292
  stub("slack").calls("POST", "/hook"); // recorded calls: method, path, params, query, headers, body, json
289
293
  ```
290
294
 
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
+
291
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)`).
292
298
 
293
299
  ### OpenAPI contracts — for your app and for the services you stub
@@ -342,7 +348,7 @@ slicetest: OpenAPI coverage (openapi.yaml): 8/9 documented responses (89%)
342
348
  POST /polls/{id}/votes 204 ✓ 400 ✓ 404 ✓
343
349
  ```
344
350
 
345
- 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.
346
352
 
347
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`.
348
354
 
@@ -484,6 +490,7 @@ expect(responses).toHaveStatuses({ 201: 1, 409: 9 }); // an array, e.
484
490
  expect(stub("slack")).toHaveReceived("POST", "/hook", { json: { text: "hi" } });
485
491
  expect(stub("slack")).toHaveReceivedTimes(1, "POST", "/hook");
486
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
487
494
  expect(stub("github")).toHaveReceivedGraphQL("CreateIssue", { title: "Bug" });
488
495
  expect(await http.graphql(QUERY)).toHaveGraphQLData({ poll: { title: "x" } }); // no errors, data as a subset
489
496
  await expect(db).toHaveRow("polls", { title: "x" }); // at least one row
@@ -491,7 +498,7 @@ await expect(db).toHaveRow("votes", { poll_id: 1 }, 3); // exactly thre
491
498
  expect(res).toMatchSchema("openapi.yaml#/components/schemas/Poll"); // a response's JSON, or any value; inline schemas too
492
499
  ```
493
500
 
494
- Failure messages list the calls the stub actually received, or the first rows of the table. `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.
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.
495
502
 
496
503
  ### Services: workers and other processes
497
504
 
@@ -608,7 +615,24 @@ In YAML, `auth` on a `request` step sends `Authorization: Bearer` with those cla
608
615
 
609
616
  ### Webhooks: deliveries signed like the provider's
610
617
 
611
- `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.
612
636
 
613
637
  ```ts
614
638
  const stripe = { provider: "stripe", secret: "whsec_test" } as const; // the same secret as in app.env
@@ -621,7 +645,7 @@ expect(await http.webhook("/webhooks/stripe", event, { ...stripe, invalidSignatu
621
645
  expect(await http.webhook("/webhooks/stripe", event, { ...stripe, stale: true })).toHaveStatus(400); // signed 10 minutes ago
622
646
  ```
623
647
 
624
- 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.
625
649
 
626
650
  ### Asynchronous side effects
627
651
 
@@ -676,6 +700,7 @@ sequenceDiagram
676
700
 
677
701
  ```ts
678
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 });
679
704
  scenario.only / scenario.skip / scenario.todo
680
705
  scenario.each([{ choice: "a", status: 204 }, { choice: "x", status: 400 }])(
681
706
  "voting $choice returns $status",
@@ -685,6 +710,8 @@ scenario.each([{ choice: "a", status: 204 }, { choice: "x", status: 400 }])(
685
710
 
686
711
  Scenarios in one file share an app and a database, so they always run one at a time; `.concurrent` is rejected.
687
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
+
688
715
  ### Configuration reference
689
716
 
690
717
  | Option | Default | |
@@ -702,6 +729,7 @@ Scenarios in one file share an app and a database, so they always run one at a t
702
729
  | `db.seed` | none | SQL file re-run after every reset. |
703
730
  | `db.schemas` | `["public"]` | Schemas whose tables are reset. |
704
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. |
705
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)). |
706
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). |
707
735
  | `db.url` | `$SLICETEST_DATABASE_URL`, else a container | Use an existing Postgres server (e.g. a CI service container) instead of Testcontainers. |
@@ -766,9 +794,9 @@ scenarios:
766
794
 
767
795
  | Step | Keys |
768
796
  |---|---|
769
- | `stub: <name>` | `on: METHOD /path` (`:params` allowed) or `graphql: <operation>`, `when: { query, headers, json, body, variables }`, one of `reply: { status, headers, body }` (`{ data, errors }` for GraphQL) / `sequence: [...]` / `networkError: true`, plus `times`, `delay`. Replies may echo the call: `{{call.params.id}}`, `{{call.json.name}}`, `{{call.variables.id}}`. |
770
- | `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. |
771
- | `request: METHOD /path` | `headers`, `query`, one of `json` / `form` / `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`. `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. |
772
800
  | `insert: <table>` | `rows`, `capture` (from `row` / `rows`) |
773
801
  | `request` with `auth` | `auth: true` or the claims: sends a bearer token from the `auth` issuer |
774
802
  | `request` with `webhook` | `{ provider, secret, event, stale, invalidSignature }`: signs the body like that provider's deliveries |
@@ -777,19 +805,48 @@ scenarios:
777
805
  | `db: <table>` | `where`, `orderBy`, `expect: { rows, count }`, `capture` |
778
806
  | `sql: <query>` | `params`, `expect: { rows, count }`, `capture` |
779
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`. |
780
809
  | `log: <regex>` | `from` (a service; default the app), `within` (ms, default 5000). Waits for a matching line printed during the scenario. |
781
- | `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. |
782
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}}" }`. |
783
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. |
784
814
  | `use: <definition>` | `with: { param: value }`. Runs the steps of a `define:` entry. |
785
815
  | `snapshot: true` | The scenario's [trace](#snapshot-the-whole-scenario-trace) so far must match its stored snapshot. `mask: [keys]` hides more values. |
786
816
 
787
- `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
+ ```
788
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.
789
831
  - `request:` also takes a captured URL of the app, e.g. `GET {{link}}` after capturing a link from a mail.
790
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.
791
- - Expected `json`, `rows` and `headers` are subsets: extra keys are fine. `{ $type: number }`, `{ $regex: "^ch_" }`, `{ $contains: "..." }` and `{ $any: true }` match loosely.
792
- - 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.
793
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).
794
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.
795
852
 
@@ -839,6 +896,7 @@ stubs: [slack]
839
896
  npx slicetest # every *.scenario.yaml under the config's directory
840
897
  npx slicetest polls -t voting # filter by file and scenario name
841
898
  npx slicetest --watch
899
+ npx slicetest list --tag smoke # what would run: file, line, tags, steps (--json for tools)
842
900
  ```
843
901
 
844
902
  ### Scenarios from your OpenAPI spec: `npx slicetest gen`
package/dist/cli.js CHANGED
@@ -16,6 +16,7 @@ const HELP = `Usage: slicetest [filters...] [options]
16
16
  slicetest doctor [--config <file>]
17
17
  slicetest record [--out <file>] [--port <n>]
18
18
  slicetest import <file.har> [--stub <name>] [--upstream <url>]
19
+ slicetest list [filters...] [--tag <tag>] [-t <pattern>] [--json]
19
20
 
20
21
  Runs *.scenario.yaml files against your app, as configured in slicetest.config.yaml.
21
22
  \`slicetest init\` looks at the project and writes a starting config and scenario.
@@ -25,6 +26,8 @@ app's OpenAPI spec; with --uncovered, only for those the last run didn't produce
25
26
  migrations, commands and spec files, and says what to fix.
26
27
  \`slicetest record\` starts everything and a proxy in front of the app: use the app
27
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.
28
31
  \`slicetest import\` turns a HAR file (the browser's network panel: "Save all as
29
32
  HAR"; Charles, mitmproxy, Proxyman) into recordings for the stubs whose
30
33
  \`upstream\` it has requests for, so they replay the real service's answers.
@@ -33,6 +36,8 @@ Options:
33
36
  -c, --config <file> Config file (default: ${CONFIG_NAMES.join(" / ")} in the current directory)
34
37
  -w, --watch Re-run on changes
35
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
36
41
  --spec <file> gen: OpenAPI file (default: \`openapi\` from the config)
37
42
  --out <dir> gen: where to write scenarios (default: scenarios)
38
43
  record: the scenario file (default: scenarios/recorded-<time>.scenario.yaml)
@@ -47,14 +52,14 @@ Options:
47
52
 
48
53
  Config (paths are relative to the config file):
49
54
  app: { command, env, cwd, ready: { path } | { log }, readyTimeout }
50
- 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 }
51
56
  stubs: [name | { name, openapi, autoReply, upstream, recordings }]
52
57
  services: { name: { command, env, cwd, ready } }
53
58
  containers: { name: { image, port, env, command, ready: { log }, reset } }
54
59
  mail: true SMTP server at {{mail.host}} / {{mail.port}}
55
60
  auth: true | { audience, claims } OpenID issuer at {{auth.issuer}} / {{auth.jwks}}
56
61
  openapi: file | { spec, minCoverage }
57
- http: { headers, query }
62
+ http: { headers, query, timeout }
58
63
  offline: true refuse calls to hosts no stub answers
59
64
  strictStubs: true fail scenarios with stub routes the app never called
60
65
  include: [globs] default ["**/*.scenario.{yaml,yml}"]
@@ -67,6 +72,7 @@ export async function main(argv = process.argv.slice(2)) {
67
72
  config: { type: "string", short: "c" },
68
73
  watch: { type: "boolean", short: "w" },
69
74
  name: { type: "string", short: "t" },
75
+ tag: { type: "string", multiple: true },
70
76
  help: { type: "boolean", short: "h" },
71
77
  force: { type: "boolean" },
72
78
  spec: { type: "string" },
@@ -76,6 +82,7 @@ export async function main(argv = process.argv.slice(2)) {
76
82
  diagrams: { type: "string" },
77
83
  stub: { type: "string" },
78
84
  upstream: { type: "string" },
85
+ json: { type: "boolean" },
79
86
  },
80
87
  });
81
88
  if (values.help) {
@@ -149,6 +156,15 @@ export async function main(argv = process.argv.slice(2)) {
149
156
  process.exitCode = 1;
150
157
  return;
151
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
+ }
152
168
  if (!configPath || !existsSync(configPath)) {
153
169
  process.stderr.write(`slicetest: no config found. Run "npx slicetest init" to create ${CONFIG_NAMES[0]} (see --help).\n`);
154
170
  process.exitCode = 1;
@@ -163,6 +179,8 @@ export async function main(argv = process.argv.slice(2)) {
163
179
  // Workers inherit the environment; a relative directory is taken from where the command runs.
164
180
  if (values.diagrams)
165
181
  process.env.SLICETEST_DIAGRAMS = path.resolve(values.diagrams);
182
+ if (values.tag?.length)
183
+ process.env.SLICETEST_TAGS = values.tag.join(",");
166
184
  const { startVitest, version } = await import("vitest/node");
167
185
  if (Number.parseInt(version, 10) < 4) {
168
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
@@ -175,6 +175,12 @@ export interface DbOptions {
175
175
  schemas?: string[];
176
176
  /** Extra tables kept across resets, in addition to known migration bookkeeping tables. */
177
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[];
178
184
  /**
179
185
  * Keep the Postgres container running between runs and cache the migrated
180
186
  * template by the contents of the migrations, so a run with unchanged
package/dist/config.js CHANGED
@@ -124,6 +124,9 @@ function validate(opts) {
124
124
  const engine = dbOpts?.engine;
125
125
  if (engine !== undefined && engine !== "postgres" && engine !== "mysql" && engine !== "sqlite")
126
126
  fail(`db.engine must be "postgres", "mysql" or "sqlite", got ${JSON.stringify(engine)}`);
127
+ if (dbOpts?.ignoreChanges !== undefined && !(Array.isArray(dbOpts.ignoreChanges) && dbOpts.ignoreChanges.every((c) => typeof c === "string" && c && !c.startsWith(".")))) {
128
+ fail(`db.ignoreChanges must be a list of columns or tables, e.g. [updated_at, orders.synced_at, sessions.*], got ${JSON.stringify(dbOpts.ignoreChanges)}`);
129
+ }
127
130
  if (dbOpts?.queries !== undefined && typeof dbOpts.queries !== "boolean")
128
131
  fail(`db.queries must be true or false, got ${JSON.stringify(dbOpts.queries)}`);
129
132
  if (dbOpts?.neon !== undefined && typeof dbOpts.neon !== "boolean")
package/dist/db.d.ts CHANGED
@@ -26,6 +26,10 @@ export interface TableChanges<T extends Row = Row> {
26
26
  }[];
27
27
  deleted: T[];
28
28
  }
29
+ export interface ChangesOptions {
30
+ /** Columns or tables to leave out: `updated_at` (in every table), `orders.synced_at`, `sessions.*`. Added to `db.ignoreChanges`. */
31
+ ignore?: string[];
32
+ }
29
33
  /** Changed tables only, keyed by table name (`schema.table` outside `public`). */
30
34
  export type Changes = Record<string, TableChanges>;
31
35
  /** Test-side handle to the database the app under test is using. */
@@ -37,6 +41,7 @@ export declare class Db {
37
41
  static connect(driver: Driver, url: string, opts: {
38
42
  schemas: string[];
39
43
  keep: string[];
44
+ ignoreChanges?: string[];
40
45
  seedFile?: string;
41
46
  }): Promise<Db>;
42
47
  query<T extends Row = Row>(sql: string, params?: unknown[]): Promise<T[]>;
@@ -86,13 +91,19 @@ export declare class Db {
86
91
  * expect(await db.changes()).toEqual({ polls: { inserted: [expect.objectContaining({ title: "x" })], updated: [], deleted: [] } });
87
92
  * ```
88
93
  */
89
- changes(): Promise<Changes>;
94
+ changes(opts?: ChangesOptions): Promise<Changes>;
90
95
  /** Make `changes()` report only what happens from now on. */
91
96
  checkpoint(): Promise<void>;
92
97
  /** Changes since the scenario started, regardless of checkpoints. Used for failure output. */
93
98
  changesSinceStart(): Promise<Changes>;
94
99
  close(): Promise<void>;
95
100
  }
101
+ /**
102
+ * What `ignore` leaves out of one table: `"*"` for the whole table, else the columns.
103
+ * `updated_at` is that column in every table, `orders.synced_at` in one, `sessions.*` the whole table
104
+ * (the last segment is the column, so `billing.invoices.*` works for other schemas).
105
+ */
106
+ export declare function ignored(ignore: readonly string[], table: string): "*" | Set<string>;
96
107
  /** Short summary of `changes()` for failure output. */
97
108
  export declare function formatChanges(changes: Changes, maxRows?: number): string;
98
109
  /** The `db` of an app without a database (`db: false`): resetting is a no-op, anything else explains. */
package/dist/db.js CHANGED
@@ -151,9 +151,9 @@ export class Db {
151
151
  * expect(await db.changes()).toEqual({ polls: { inserted: [expect.objectContaining({ title: "x" })], updated: [], deleted: [] } });
152
152
  * ```
153
153
  */
154
- async changes() {
154
+ async changes(opts = {}) {
155
155
  const base = this.#checkpoint === undefined || this.#checkpoint === "start" ? this.#start : this.#checkpoint;
156
- return diff(this.#tables ?? [], base, await this.#snapshot());
156
+ return diff(this.#tables ?? [], base, await this.#snapshot(), [...(this.opts.ignoreChanges ?? []), ...(opts.ignore ?? [])]);
157
157
  }
158
158
  /** Make `changes()` report only what happens from now on. */
159
159
  async checkpoint() {
@@ -161,7 +161,7 @@ export class Db {
161
161
  }
162
162
  /** Changes since the scenario started, regardless of checkpoints. Used for failure output. */
163
163
  async changesSinceStart() {
164
- return diff(this.#tables ?? [], this.#start, await this.#snapshot());
164
+ return diff(this.#tables ?? [], this.#start, await this.#snapshot(), this.opts.ignoreChanges ?? []);
165
165
  }
166
166
  /** Every tracked table's rows, in one round trip. */
167
167
  async #snapshot() {
@@ -201,11 +201,38 @@ export class Db {
201
201
  await this.#driver.close();
202
202
  }
203
203
  }
204
- function diff(tables, before, after) {
204
+ /**
205
+ * What `ignore` leaves out of one table: `"*"` for the whole table, else the columns.
206
+ * `updated_at` is that column in every table, `orders.synced_at` in one, `sessions.*` the whole table
207
+ * (the last segment is the column, so `billing.invoices.*` works for other schemas).
208
+ */
209
+ export function ignored(ignore, table) {
210
+ const columns = new Set();
211
+ for (const entry of ignore) {
212
+ const dot = entry.lastIndexOf(".");
213
+ if (dot === -1) {
214
+ columns.add(entry);
215
+ continue;
216
+ }
217
+ const t = entry.slice(0, dot);
218
+ if (t !== table && !(t === table.split(".").pop() && !t.includes(".")))
219
+ continue;
220
+ const column = entry.slice(dot + 1);
221
+ if (column === "*")
222
+ return "*";
223
+ columns.add(column);
224
+ }
225
+ return columns;
226
+ }
227
+ function diff(tables, before, after, ignore = []) {
205
228
  const out = {};
206
229
  for (const table of tables) {
207
- const a = before?.get(table.name) ?? [];
208
- const b = after.get(table.name) ?? [];
230
+ const skip = ignored(ignore, table.name);
231
+ if (skip === "*")
232
+ continue;
233
+ const strip = (rows) => (skip.size ? rows.map((r) => Object.fromEntries(Object.entries(r).filter(([c]) => !skip.has(c)))) : rows);
234
+ const a = strip(before?.get(table.name) ?? []);
235
+ const b = strip(after.get(table.name) ?? []);
209
236
  const changes = table.key.length > 0 ? diffByKey(a, b, table.key) : diffAsBags(a, b);
210
237
  if (changes.inserted.length || changes.updated.length || changes.deleted.length)
211
238
  out[table.name] = changes;
package/dist/form.d.ts CHANGED
@@ -13,8 +13,11 @@ export interface SubmitOptions {
13
13
  button?: string;
14
14
  /** The form, by `id`, `name` or position (0-based), when the page has several and no `button` picks one. */
15
15
  form?: string | number;
16
- /** Values typed into the form, by field name. A name the form doesn't have is an error (a typo, usually). */
17
- fields?: Record<string, string | number | boolean | (string | number)[]>;
16
+ /**
17
+ * Values typed into the form, by field name. A name the form doesn't have is an error (a typo, usually).
18
+ * A file input takes a `Blob` / `File` (its name is the file name sent), and the form must be multipart.
19
+ */
20
+ fields?: Record<string, string | number | boolean | (string | number)[] | Blob>;
18
21
  }
19
22
  export interface FormRequest {
20
23
  method: "GET" | "POST";
@@ -42,4 +45,6 @@ export declare function decodeEntities(s: string): string;
42
45
  export declare function parseForms(html: string): ParsedForm[];
43
46
  /** Works out the request a browser would send for this page's form. */
44
47
  export declare function formRequest(html: string, opts?: SubmitOptions): FormRequest;
48
+ /** Fields as `application/x-www-form-urlencoded`: lists repeat the key, nested objects use bracket keys (`metadata[order]`). */
49
+ export declare function encodeForm(fields: Record<string, unknown>): URLSearchParams;
45
50
  export {};
package/dist/form.js CHANGED
@@ -37,7 +37,11 @@ export function parseForms(html) {
37
37
  let open;
38
38
  let select;
39
39
  let option;
40
+ // Controls inside a disabled <fieldset> are disabled too (and not submitted).
41
+ const fieldsets = [];
40
42
  const add = (control) => {
43
+ if (fieldsets.includes(true))
44
+ control.attrs.disabled ??= "";
41
45
  const formId = control.attrs.form;
42
46
  if (formId !== undefined)
43
47
  orphans.push({ formId, control });
@@ -62,6 +66,13 @@ export function parseForms(html) {
62
66
  TAG.lastIndex = end < 0 ? html.length : end;
63
67
  continue;
64
68
  }
69
+ if (tag === "fieldset") {
70
+ if (closing)
71
+ fieldsets.pop();
72
+ else
73
+ fieldsets.push("disabled" in parseAttrs(m[3] ?? ""));
74
+ continue;
75
+ }
65
76
  if (tag === "form") {
66
77
  if (closing)
67
78
  current = undefined;
@@ -162,12 +173,22 @@ export function formRequest(html, opts = {}) {
162
173
  throw new Error(`slicetest: submit: the button ${JSON.stringify(label(button))} is disabled`);
163
174
  const names = new Set(form.controls.filter((c) => c.attrs.name !== undefined && !isSubmit(c) && c.tag !== "button").map((c) => c.attrs.name));
164
175
  const typed = new Map();
176
+ const files = new Map();
177
+ const fileInputs = new Set(form.controls.filter((c) => c.tag === "input" && c.attrs.type?.toLowerCase() === "file").map((c) => c.attrs.name));
165
178
  for (const [name, value] of Object.entries(opts.fields ?? {})) {
166
179
  if (!names.has(name)) {
167
180
  const shown = [...names].filter((n) => !n.startsWith("$ACTION_"));
168
181
  throw new Error(`slicetest: submit: the form has no field "${name}". Fields: ${shown.join(", ") || "(none)"}`);
169
182
  }
170
- typed.set(name, typeof value === "boolean" ? value : (Array.isArray(value) ? value : [value]).map(String));
183
+ if (fileInputs.has(name) !== value instanceof Blob) {
184
+ throw new Error(fileInputs.has(name)
185
+ ? `slicetest: submit: "${name}" is a file input; give it a file (a Blob or File, or { file: path } in YAML)`
186
+ : `slicetest: submit: "${name}" isn't a file input, so it can't take a file`);
187
+ }
188
+ if (value instanceof Blob)
189
+ files.set(name, value);
190
+ else
191
+ typed.set(name, typeof value === "boolean" ? value : (Array.isArray(value) ? value : [value]).map(String));
171
192
  }
172
193
  // The successful controls, in document order (HTML's "constructing the entry list").
173
194
  // A typed value replaces what the page had: text for a field, the checked state for
@@ -176,6 +197,12 @@ export function formRequest(html, opts = {}) {
176
197
  const done = new Set();
177
198
  for (const c of form.controls) {
178
199
  const name = c.attrs.name;
200
+ if (c === button && c.attrs.type?.toLowerCase() === "image") {
201
+ // An image button sends the click position, as name.x / name.y (x / y without a name).
202
+ const prefix = name ? `${name}.` : "";
203
+ entries.push([`${prefix}x`, "0"], [`${prefix}y`, "0"]);
204
+ continue;
205
+ }
179
206
  if (name === undefined || "disabled" in c.attrs)
180
207
  continue;
181
208
  if (c.tag === "button" || isSubmit(c)) {
@@ -184,8 +211,16 @@ export function formRequest(html, opts = {}) {
184
211
  continue;
185
212
  }
186
213
  const type = c.tag === "input" ? (c.attrs.type ?? "text").toLowerCase() : c.tag;
187
- if (type === "reset" || type === "button" || type === "file")
214
+ if (type === "reset" || type === "button")
215
+ continue;
216
+ if (type === "file") {
217
+ // With no file chosen, browsers still send the field: an empty file without a name.
218
+ const file = files.get(name);
219
+ if (!done.has(name))
220
+ entries.push([name, file ?? new File([], "", { type: "application/octet-stream" })]);
221
+ done.add(name);
188
222
  continue;
223
+ }
189
224
  const want = typed.get(name);
190
225
  if (type === "checkbox" || type === "radio") {
191
226
  const value = c.attrs.value ?? "on";
@@ -214,13 +249,43 @@ export function formRequest(html, opts = {}) {
214
249
  const pick = (attr) => (button && button.attrs[`form${attr}`] !== undefined ? button.attrs[`form${attr}`] : form.attrs[attr]);
215
250
  const method = (pick("method") ?? "get").toUpperCase() === "POST" ? "POST" : "GET";
216
251
  const action = pick("action") ?? "";
252
+ const multipart = method === "POST" && (pick("enctype") ?? "").toLowerCase() === "multipart/form-data";
253
+ if (files.size && !multipart) {
254
+ throw new Error(`slicetest: submit: the form sends ${method === "GET" ? "a GET" : "urlencoded"} data, which can't carry files; a browser would send only the file name. Upload forms need method="post" enctype="multipart/form-data"`);
255
+ }
256
+ // Outside multipart, a file field is sent as its file name (empty when none was chosen), as browsers do.
257
+ const plain = () => new URLSearchParams(entries.map(([k, v]) => [k, typeof v === "string" ? v : (v.name ?? "")]));
217
258
  if (method === "GET")
218
- return { method, action: `${action.replace(/[?#].*$/, "")}?${new URLSearchParams(entries)}` };
219
- const multipart = (pick("enctype") ?? "").toLowerCase() === "multipart/form-data";
259
+ return { method, action: `${action.replace(/[?#].*$/, "")}?${plain()}` };
220
260
  if (!multipart)
221
- return { method, action, body: new URLSearchParams(entries) };
261
+ return { method, action, body: plain() };
222
262
  const body = new FormData();
223
- for (const [k, v] of entries)
224
- body.append(k, v);
263
+ for (const [k, v] of entries) {
264
+ if (typeof v === "string")
265
+ body.append(k, v);
266
+ else
267
+ body.append(k, v, v.name ?? "blob");
268
+ }
225
269
  return { method, action, body };
226
270
  }
271
+ /** Fields as `application/x-www-form-urlencoded`: lists repeat the key, nested objects use bracket keys (`metadata[order]`). */
272
+ export function encodeForm(fields) {
273
+ const out = new URLSearchParams();
274
+ const add = (key, v) => {
275
+ if (v === null || v === undefined)
276
+ return;
277
+ if (Array.isArray(v)) {
278
+ const nested = v.some((x) => x !== null && typeof x === "object");
279
+ v.forEach((x, i) => add(nested ? `${key}[${i}]` : key, x));
280
+ }
281
+ else if (typeof v === "object" && !(v instanceof Date)) {
282
+ for (const [k, x] of Object.entries(v))
283
+ add(`${key}[${k}]`, x);
284
+ }
285
+ else
286
+ out.append(key, v instanceof Date ? v.toISOString() : String(v));
287
+ };
288
+ for (const [k, v] of Object.entries(fields))
289
+ add(k, v);
290
+ return out;
291
+ }
@@ -3,6 +3,11 @@ import type { ResolvedOptions } from "./config.js";
3
3
  import "./provided.js";
4
4
  /** Runs once per vitest run: start the database server, migrate a template database, hand its location to the workers. */
5
5
  export default function setup(project: TestProject): Promise<() => Promise<void>>;
6
+ /**
7
+ * Why this run covers only part of the suite (a file or name filter, tags, a shard), or undefined for a full run.
8
+ * Coverage then says little about the suite, so `minCoverage` isn't enforced and the cache for `gen --uncovered` is kept.
9
+ */
10
+ export declare function partialRun(project: TestProject): Promise<string | undefined>;
6
11
  /**
7
12
  * A hash of everything that determines the migrated schema, or undefined when
8
13
  * that can't be known (a migration command without `inputs`).