slicetest 0.7.0 → 0.9.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
@@ -38,7 +38,7 @@ 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`, 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.
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 (the main package at the root or in `cmd/<name>`) and Rust apps (both compiled once before the workers start); Atlas, Prisma, Alembic, Django, Rails, Laravel, Doctrine, EF Core, Ecto, Drizzle, Knex and plain SQL migrations (golang-migrate, goose, sqlx, Diesel and dbmate layouts in `migrations/`, `db/migrations/` or `sql/migrations/`); and an `openapi.yaml`. If there's a `compose.yaml` / `docker-compose.yml`, its database service sets `db.image` (and `db.engine: mysql` for MySQL or MariaDB), and Redis, Valkey, Mongo, Elasticsearch, MinIO, RabbitMQ and other services with a port become [`containers`](#containers-redis-search-s3-and-other-dependencies), with a reset command where one is known and the usual variable (`REDIS_URL`, `S3_ENDPOINT`, …) passed to the app. A mail catcher there (Mailpit, MailHog, MailDev, smtp4dev, …) or a mail library in the dependencies turns on [`mail`](#mail-catch-what-the-app-sends). SQLite is picked up from Prisma's provider, Rails' `database.yml`, Django's settings or a SQLite driver, with `DATABASE_URL` in the form the framework reads (`file:…`, `sqlite3:…`). And third-party API URLs in `.env.example` (`STRIPE_API_BASE=https://api.stripe.com`) become stubs [recorded from that service](#recording-a-real-service), with the variable pointed at the stub, while local addresses, databases and your own URLs are left alone. Token issuer settings there (`OIDC_ISSUER`, `AUTH0_DOMAIN`, `JWKS_URL`, `JWT_AUDIENCE`, …) turn on [`auth`](#auth-a-real-openid-issuer-tokens-with-any-claims) and point at slicetest's issuer instead of becoming stubs. It lists every guess as a comment in the config so you know what to check.
42
42
 
43
43
  ## What you get that's hard to find elsewhere
44
44
 
@@ -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,11 +287,14 @@ 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
 
291
- 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)`).
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 `*`. A query written into the path (`on("GET", "/search?q=tea")`, also in `calls()`, `toHaveReceived()` and YAML stubs) is a condition on those parameters, like `query: { q: "tea" }`; other parameters may come along. A list matches a repeated parameter's values in order: `query: { ids: ["1", "2"] }` for `?ids=1&ids=2`. Unanswered calls get a `501` and fail the scenario, with the closest route and why it didn't match (`stub.explain(call)`).
292
298
 
293
299
  ### OpenAPI contracts — for your app and for the services you stub
294
300
 
@@ -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
 
@@ -459,7 +465,7 @@ Record once, with real credentials in the app's environment:
459
465
  SLICETEST_RECORD=github npx vitest # or SLICETEST_RECORD=1 for every stub with an upstream
460
466
  ```
461
467
 
462
- 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.
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. Credentials sent in the query or the body (`api_key`, `key` in the query, `appid`, `access_token`, `client_secret`, `password`, signatures, …) are stored as `[redacted]` and match any value on replay, so the test environment's dummy key replays what the real key recorded. Other values are stored as sent, so review the file before committing it.
463
469
 
464
470
  #### From a HAR file: `npx slicetest import`
465
471
 
@@ -480,10 +486,15 @@ Registered automatically:
480
486
 
481
487
  ```ts
482
488
  expect(res).toHaveStatus(201); // failure shows the response body
489
+ expect(res).toHaveStatus("2xx"); // a class, or a list: [200, 204], ["2xx", 304]
490
+ expect(res).toRespondWithin(300); // a response-time budget, in ms
491
+ expect(res).toSetCookie("sid", { httpOnly: true, secure: true, sameSite: "Lax" }); // a cookie and its attributes
492
+ expect(res.events).toContainEqual({ event: "message_stop", data: {} }); // text/event-stream responses are parsed into events
483
493
  expect(responses).toHaveStatuses({ 201: 1, 409: 9 }); // an array, e.g. from http.concurrently()
484
494
  expect(stub("slack")).toHaveReceived("POST", "/hook", { json: { text: "hi" } });
485
495
  expect(stub("slack")).toHaveReceivedTimes(1, "POST", "/hook");
486
496
  expect(stub("mail")).not.toHaveReceived("POST", "/send");
497
+ expect(stub).toHaveReceivedInOrder([["stripe", "POST", "/v1/charges"], ["mail", "POST", "/send"]]); // across stubs, others may come between
487
498
  expect(stub("github")).toHaveReceivedGraphQL("CreateIssue", { title: "Bug" });
488
499
  expect(await http.graphql(QUERY)).toHaveGraphQLData({ poll: { title: "x" } }); // no errors, data as a subset
489
500
  await expect(db).toHaveRow("polls", { title: "x" }); // at least one row
@@ -491,7 +502,7 @@ await expect(db).toHaveRow("votes", { poll_id: 1 }, 3); // exactly thre
491
502
  expect(res).toMatchSchema("openapi.yaml#/components/schemas/Poll"); // a response's JSON, or any value; inline schemas too
492
503
  ```
493
504
 
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.
505
+ 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
506
 
496
507
  ### Services: workers and other processes
497
508
 
@@ -608,7 +619,24 @@ In YAML, `auth` on a `request` step sends `Authorization: Bearer` with those cla
608
619
 
609
620
  ### Webhooks: deliveries signed like the provider's
610
621
 
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.
622
+ `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:
623
+
624
+ | `provider` | Sends |
625
+ |---|---|
626
+ | `stripe` | `Stripe-Signature: t=…,v1=…` |
627
+ | `github` | `X-Hub-Signature-256`, `X-GitHub-Event` (`event`) |
628
+ | `slack` | `X-Slack-Signature: v0=…`, `X-Slack-Request-Timestamp` |
629
+ | `shopify` | `X-Shopify-Hmac-Sha256`, `X-Shopify-Topic` (`event`) |
630
+ | `standard` | [Standard Webhooks](https://www.standardwebhooks.com/) (Svix, Resend, Clerk, …): `webhook-id`, `webhook-timestamp`, `webhook-signature` |
631
+ | `line` | LINE Messaging API: `X-Line-Signature` (base64, with the channel secret) |
632
+ | `paddle` | Paddle Billing: `Paddle-Signature: ts=…;h1=…` |
633
+ | `linear` | `Linear-Signature`, `Linear-Event` (`event`) |
634
+ | `gitlab` | `X-Gitlab-Token` (the secret token), `X-Gitlab-Event` (`event`, default `Push Hook`) |
635
+ | `zoom` | `x-zm-signature: v0=…`, `x-zm-request-timestamp` |
636
+ | `twilio` | `X-Twilio-Signature` over the URL and the sorted form parameters; objects are sent as a form. The URL is the app's address and the path, or `url` when the app validates against a public URL it's configured with |
637
+ | `twitch` | EventSub: `Twitch-Eventsub-Message-Signature`, `-Id`, `-Timestamp`, `-Type` (`event`, default `notification`) |
638
+
639
+ The signatures are checked against the providers' documented examples where they publish one.
612
640
 
613
641
  ```ts
614
642
  const stripe = { provider: "stripe", secret: "whsec_test" } as const; // the same secret as in app.env
@@ -621,7 +649,7 @@ expect(await http.webhook("/webhooks/stripe", event, { ...stripe, invalidSignatu
621
649
  expect(await http.webhook("/webhooks/stripe", event, { ...stripe, stale: true })).toHaveStatus(400); // signed 10 minutes ago
622
650
  ```
623
651
 
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.
652
+ 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
653
 
626
654
  ### Asynchronous side effects
627
655
 
@@ -676,6 +704,7 @@ sequenceDiagram
676
704
 
677
705
  ```ts
678
706
  scenario("name", async ({ http, db, stub, app, service, container, trace, diagram }) => { ... }, timeoutMs?);
707
+ scenario("refunds a charge", async (ctx) => { ... }, { tags: ["payments", "slow"], timeout: 10_000 });
679
708
  scenario.only / scenario.skip / scenario.todo
680
709
  scenario.each([{ choice: "a", status: 204 }, { choice: "x", status: 400 }])(
681
710
  "voting $choice returns $status",
@@ -685,6 +714,8 @@ scenario.each([{ choice: "a", status: 204 }, { choice: "x", status: 400 }])(
685
714
 
686
715
  Scenarios in one file share an app and a database, so they always run one at a time; `.concurrent` is rejected.
687
716
 
717
+ Tags select scenarios across files: `npx slicetest --tag smoke` (repeat `--tag` for any of several, `--tag '!slow'` to leave some out), or `SLICETEST_TAGS=smoke,!slow npx vitest` with your own Vitest config. Scenarios a filter leaves out are reported as skipped. In YAML, `tags: [smoke, payments]` on a scenario.
718
+
688
719
  ### Configuration reference
689
720
 
690
721
  | Option | Default | |
@@ -698,10 +729,11 @@ Scenarios in one file share an app and a database, so they always run one at a t
698
729
  | `app.readyTimeout` | `30000` | |
699
730
  | `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`. |
700
731
  | `db.engine` | `postgres`, or `mysql` for a `mysql://` URL | `postgres`, `mysql` (see [MySQL](#mysql)) or `sqlite` (see [SQLite](#sqlite)). |
701
- | `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}}\"" }`. |
732
+ | `db.migrate` | none | `{ atlas: { dir } }`, `{ sql: "file-or-dir" }` or `{ command, inputs?, env? }`. A `sql` directory is applied in name order with version numbers compared as numbers (`V2__` before `V10__`), leaving out rollbacks: `*.down.sql` (golang-migrate, sqlx, Diesel), Flyway undo files (`U2__…`) and the down section of goose (`-- +goose Down`) and dbmate (`-- migrate:down`) files. The command gets `DATABASE_URL`, and both it and `env` may use the `{{db.*}}` placeholders, for tools that read other variables: `{ command: "php artisan migrate --force", env: { DB_HOST: "{{db.host}}", DB_DATABASE: "{{db.name}}" } }`, `{ command: "dotnet ef database update --connection \"{{db.adoNet}}\"" }`. |
702
733
  | `db.seed` | none | SQL file re-run after every reset. |
703
734
  | `db.schemas` | `["public"]` | Schemas whose tables are reset. |
704
735
  | `db.keep` | `[]` | Extra tables (`name` or `schema.name`) never truncated. |
736
+ | `db.ignoreChanges` | `[]` | Columns (`updated_at`, `orders.synced_at`) and tables (`sessions.*`) left out of `db.changes()`, `trace()` and the failure output. |
705
737
  | `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
738
  | `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
739
  | `db.url` | `$SLICETEST_DATABASE_URL`, else a container | Use an existing Postgres server (e.g. a CI service container) instead of Testcontainers. |
@@ -766,9 +798,9 @@ scenarios:
766
798
 
767
799
  | Step | Keys |
768
800
  |---|---|
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. |
801
+ | `stub: <name>` | `on: METHOD /path` (`:params` allowed) or `graphql: <operation>`, `when: { query, headers, json, form, body, variables }`, one of `reply: { status, headers, body }` (`{ file }` for a fixture file, `{ data, errors }` for GraphQL) / `sequence: [...]` / `networkError: true`, plus `times`, `delay`. Replies may echo the call: `{{call.params.id}}`, `{{call.json.name}}`, `{{call.form.amount}}`, `{{call.variables.id}}`. |
802
+ | `submit: <button>` | `form`, `fields` (`{ file: path }` for a file input), `headers`, `follow`, `expect: { status, headers, json, text }` (`status` may be a class, `2xx`, or a list, `[200, 204]`; `duration: 300` fails a response slower than 300 ms, `duration: { $lt: 300 }` takes matchers; `cookies: { sid: { httpOnly: true, sameSite: Lax }, tracking: null }` checks the cookies it sets and their attributes, `null` for one it must not set; `events: [{ event: message_start }, { data: "[DONE]" }]` checks a `text/event-stream` response's events in order, others allowed between), `capture` (also from `events.0.data.id`). Submits a form of the page the last request returned, like `http.submit()`; `submit: true` presses the form's only button. |
803
+ | `request: METHOD /path` | `headers`, `query`, one of `json` / `form` / `multipart` / `body` / `graphql: { query, variables, operationName }`, `expect.schema` (a JSON Schema, or `../openapi.yaml#/components/schemas/Poll` relative to the file), `follow`, `expect: { status, headers, json, text }`, `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
804
  | `insert: <table>` | `rows`, `capture` (from `row` / `rows`) |
773
805
  | `request` with `auth` | `auth: true` or the claims: sends a bearer token from the `auth` issuer |
774
806
  | `request` with `webhook` | `{ provider, secret, event, stale, invalidSignature }`: signs the body like that provider's deliveries |
@@ -777,19 +809,49 @@ scenarios:
777
809
  | `db: <table>` | `where`, `orderBy`, `expect: { rows, count }`, `capture` |
778
810
  | `sql: <query>` | `params`, `expect: { rows, count }`, `capture` |
779
811
  | `received: <stub>` | `call: METHOD /path` or `graphql: <operation>`, `when`, `times` (exact; default at least once) |
812
+ | `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
813
  | `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. |
814
+ | `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
815
  | `checkpoint: true` | Later `changes` steps only see what happens after this step. |
816
+ | `set: { name: value }` | Defines variables for later steps, e.g. `{ orderId: "{{$uuid}}", expires: "{{$now+1d}}" }`. |
783
817
  | `mail: { to, from, subject, text, html }` | `times` (exact; default at least one), `within` (ms, default 5000), `capture` from the last match (`subject`, `text`, `links.0`). Waits for mail the app sends. `{}` matches any message. |
784
818
  | `use: <definition>` | `with: { param: value }`. Runs the steps of a `define:` entry. |
785
819
  | `snapshot: true` | The scenario's [trace](#snapshot-the-whole-scenario-trace) so far must match its stored snapshot. `mask: [keys]` hides more values. |
786
820
 
787
- `db`, `sql`, `received` and `changes` steps take `within: <ms>` to retry until they pass, for effects the app applies asynchronously.
821
+ `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:
822
+
823
+ ```yaml
824
+ - request: POST /exports
825
+ expect: { status: 202 }
826
+ capture: { job: json.id }
827
+ - request: GET /exports/{{job}}
828
+ within: 10000
829
+ expect: { json: { status: done } }
830
+ capture: { file: json.url }
831
+ ```
788
832
 
833
+ - `reply: { file: replies/charge.json }` answers with a file relative to the scenario file: `.json` and `.yaml` are sent as JSON and may still use `{{call.*}}`, other files as they are (images, PDFs, CSV), with a content type from the extension unless `headers` set one. Large provider payloads stay out of the scenario.
834
+ - `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
835
  - `request:` also takes a captured URL of the app, e.g. `GET {{link}}` after capturing a link from a mail.
790
836
  - `{{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.
837
+ - 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.
838
+ - Expected `json`, `rows` and `headers` are subsets: extra keys are fine. Values match loosely with:
839
+
840
+ | Matcher | Matches |
841
+ |---|---|
842
+ | `{ $type: number }` | `string`, `number`, `integer`, `boolean`, `array`, `object`, `null` |
843
+ | `{ $regex: "^ch_" }` | a string the pattern finds |
844
+ | `{ $contains: "ok" }` | a string with that substring, or a list with a matching item (`{ $contains: { sku: a } }`) |
845
+ | `{ $gte: 1, $lt: 10 }` | numbers (also decimals that `numeric` / `DECIMAL` columns return as strings, `"12.50"`), or strings such as ISO dates (`{ $gte: "2026-01-01" }`); also `$gt`, `$lte` |
846
+ | `{ $closeTo: 9.99 }` | a number within ±0.005 (`[9.99, 0.1]` for another tolerance), for floats and computed totals; decimal strings too |
847
+ | `{ $len: 3 }` | a string or list of that length; `{ $len: { $gte: 1 } }` |
848
+ | `{ $oneOf: [paid, pending] }` | any of the values (or matchers) |
849
+ | `{ $not: "" }` | anything the value or matcher doesn't match |
850
+ | `{ $format: uuid }` | `uuid`, `email`, `date`, `date-time`, `uri`, `integer` (a string of digits) |
851
+ | `{ $any: true }` | anything but `null` / missing |
852
+
853
+ Several `$` keys in one mapping must all hold.
854
+ - A file-level `setup:` list runs at the start of every scenario. `skip`, `only`, `timeout` and `tags` work per scenario.
793
855
  - 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
856
  - 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
857
 
@@ -839,6 +901,10 @@ stubs: [slack]
839
901
  npx slicetest # every *.scenario.yaml under the config's directory
840
902
  npx slicetest polls -t voting # filter by file and scenario name
841
903
  npx slicetest --watch
904
+ npx slicetest list --tag smoke # what would run: file, line, tags, steps (--json for tools)
905
+ npx slicetest -u # rewrite `snapshot: true` snapshots that no longer match
906
+ npx slicetest --reporter junit --output-file reports/slicetest.xml # GitLab, Jenkins, CircleCI test reports
907
+ npx slicetest --shard 2/4 # the second of four parts, for parallel CI jobs
842
908
  ```
843
909
 
844
910
  ### Scenarios from your OpenAPI spec: `npx slicetest gen`
@@ -0,0 +1,13 @@
1
+ export interface RunFlags {
2
+ update?: boolean;
3
+ reporter?: string[];
4
+ "output-file"?: string;
5
+ shard?: string;
6
+ }
7
+ /** Vitest options for the run flags of `npx slicetest`; paths are taken from where the command runs, like `--diagrams`. */
8
+ export declare function runOptions(flags: RunFlags, cwd?: string): {
9
+ update?: boolean | undefined;
10
+ reporters?: string[] | undefined;
11
+ outputFile?: string | undefined;
12
+ shard?: string | undefined;
13
+ };
@@ -0,0 +1,15 @@
1
+ import path from "node:path";
2
+ /** Vitest options for the run flags of `npx slicetest`; paths are taken from where the command runs, like `--diagrams`. */
3
+ export function runOptions(flags, cwd = process.cwd()) {
4
+ if (flags.shard !== undefined) {
5
+ const m = /^(\d+)\/(\d+)$/.exec(flags.shard);
6
+ if (!m || Number(m[1]) < 1 || Number(m[1]) > Number(m[2]))
7
+ throw new Error(`slicetest: --shard takes <index>/<count> with 1 ≤ index ≤ count, e.g. --shard 1/3, got "${flags.shard}"`);
8
+ }
9
+ return {
10
+ ...(flags.update ? { update: true } : {}),
11
+ ...(flags.reporter?.length ? { reporters: flags.reporter } : {}),
12
+ ...(flags["output-file"] ? { outputFile: path.resolve(cwd, flags["output-file"]) } : {}),
13
+ ...(flags.shard ? { shard: flags.shard } : {}),
14
+ };
15
+ }
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)
@@ -41,20 +46,25 @@ Options:
41
46
  --upstream <url> import: the real service's base URL for --stub
42
47
  --uncovered gen: only responses the last run didn't cover
43
48
  --force init, gen: overwrite existing files
49
+ -u, --update Rewrite snapshots (\`snapshot: true\` steps) that no longer match
50
+ --reporter <name> Vitest reporter: default, verbose, dot, junit, json, tap,
51
+ github-actions (repeat for several)
52
+ --output-file <file> Where junit / json / tap reporters write
53
+ --shard <i/n> Run the i-th of n parts of the suite (split CI jobs)
44
54
  --diagrams <dir> Write a Mermaid sequence diagram of every scenario to <dir>,
45
55
  one Markdown page per scenario file
46
56
  -h, --help Show this help
47
57
 
48
58
  Config (paths are relative to the config file):
49
59
  app: { command, env, cwd, ready: { path } | { log }, readyTimeout }
50
- db: { migrate: { atlas: { dir } } | { sql } | { command, inputs }, engine, seed, url, image, schemas, keep, reuse, queries }
60
+ db: { migrate: { atlas: { dir } } | { sql } | { command, inputs }, engine, seed, url, image, schemas, keep, ignoreChanges, reuse, queries }
51
61
  stubs: [name | { name, openapi, autoReply, upstream, recordings }]
52
62
  services: { name: { command, env, cwd, ready } }
53
63
  containers: { name: { image, port, env, command, ready: { log }, reset } }
54
64
  mail: true SMTP server at {{mail.host}} / {{mail.port}}
55
65
  auth: true | { audience, claims } OpenID issuer at {{auth.issuer}} / {{auth.jwks}}
56
66
  openapi: file | { spec, minCoverage }
57
- http: { headers, query }
67
+ http: { headers, query, timeout }
58
68
  offline: true refuse calls to hosts no stub answers
59
69
  strictStubs: true fail scenarios with stub routes the app never called
60
70
  include: [globs] default ["**/*.scenario.{yaml,yml}"]
@@ -67,6 +77,7 @@ export async function main(argv = process.argv.slice(2)) {
67
77
  config: { type: "string", short: "c" },
68
78
  watch: { type: "boolean", short: "w" },
69
79
  name: { type: "string", short: "t" },
80
+ tag: { type: "string", multiple: true },
70
81
  help: { type: "boolean", short: "h" },
71
82
  force: { type: "boolean" },
72
83
  spec: { type: "string" },
@@ -76,6 +87,11 @@ export async function main(argv = process.argv.slice(2)) {
76
87
  diagrams: { type: "string" },
77
88
  stub: { type: "string" },
78
89
  upstream: { type: "string" },
90
+ json: { type: "boolean" },
91
+ update: { type: "boolean", short: "u" },
92
+ reporter: { type: "string", multiple: true },
93
+ "output-file": { type: "string" },
94
+ shard: { type: "string" },
79
95
  },
80
96
  });
81
97
  if (values.help) {
@@ -149,6 +165,15 @@ export async function main(argv = process.argv.slice(2)) {
149
165
  process.exitCode = 1;
150
166
  return;
151
167
  }
168
+ if (positionals[0] === "list") {
169
+ const root = configPath && existsSync(configPath) ? path.dirname(configPath) : process.cwd();
170
+ const { listScenarios, formatList } = await import("./list.js");
171
+ const listed = await listScenarios(root, { filters: positionals.slice(1), name: values.name, tags: values.tag?.join(",") });
172
+ process.stdout.write(values.json ? `${JSON.stringify(listed, null, 2)}\n` : formatList(listed));
173
+ if (listed.errors.length)
174
+ process.exitCode = 1;
175
+ return;
176
+ }
152
177
  if (!configPath || !existsSync(configPath)) {
153
178
  process.stderr.write(`slicetest: no config found. Run "npx slicetest init" to create ${CONFIG_NAMES[0]} (see --help).\n`);
154
179
  process.exitCode = 1;
@@ -163,6 +188,8 @@ export async function main(argv = process.argv.slice(2)) {
163
188
  // Workers inherit the environment; a relative directory is taken from where the command runs.
164
189
  if (values.diagrams)
165
190
  process.env.SLICETEST_DIAGRAMS = path.resolve(values.diagrams);
191
+ if (values.tag?.length)
192
+ process.env.SLICETEST_TAGS = values.tag.join(",");
166
193
  const { startVitest, version } = await import("vitest/node");
167
194
  if (Number.parseInt(version, 10) < 4) {
168
195
  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`);
@@ -172,6 +199,8 @@ export async function main(argv = process.argv.slice(2)) {
172
199
  const { slicetest, YAML_SCENARIOS } = await import("./vitest.js");
173
200
  // Vitest 4 takes the mode ("test") first; 5 dropped it.
174
201
  const start = (Number.parseInt(version, 10) === 4 ? startVitest.bind(null, "test") : startVitest);
202
+ const { runOptions } = await import("./cli-run.js");
203
+ const run = runOptions(values);
175
204
  const vitest = await start(positionals, {
176
205
  config: false,
177
206
  root: path.dirname(configPath),
@@ -179,6 +208,7 @@ export async function main(argv = process.argv.slice(2)) {
179
208
  watch: !!values.watch,
180
209
  run: !values.watch,
181
210
  testNamePattern: values.name,
211
+ ...run,
182
212
  }, { plugins: [slicetest(options)] });
183
213
  if (!values.watch)
184
214
  await vitest?.close();
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
@@ -59,10 +59,55 @@ function resolveDb(db) {
59
59
  reuse: db.reuse ?? (!url && !process.env.CI),
60
60
  };
61
61
  }
62
+ const TOP_LEVEL_KEYS = ["app", "db", "stubs", "openapi", "http", "services", "containers", "mail", "offline", "strictStubs", "workers", "auth", "include"];
63
+ const APP_KEYS = ["command", "build", "cwd", "env", "ready", "readyTimeout", "scope", "baseEnv"];
64
+ const STUB_KEYS = ["name", "openapi", "autoReply", "upstream", "recordings", "hosts"];
65
+ const CONTAINER_KEYS = ["image", "port", "env", "command", "ready", "reset"];
66
+ const DB_KEYS = ["engine", "image", "url", "migrate", "seed", "schemas", "keep", "ignoreChanges", "reuse", "queries", "neon"];
67
+ function editDistance(a, b) {
68
+ const row = Array.from({ length: b.length + 1 }, (_, i) => i);
69
+ for (let i = 1; i <= a.length; i++) {
70
+ let prev = row[0];
71
+ row[0] = i;
72
+ for (let j = 1; j <= b.length; j++) {
73
+ const tmp = row[j];
74
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
75
+ prev = tmp;
76
+ }
77
+ }
78
+ return row[b.length];
79
+ }
62
80
  function validate(opts) {
63
81
  const fail = (msg) => {
64
82
  throw new Error(`slicetest: invalid config: ${msg}`);
65
83
  };
84
+ // A misspelt key (`stub:`, `strictstubs:`) would otherwise be ignored without a word; YAML configs have no type checker.
85
+ const checkKeys = (value, allowed, where) => {
86
+ if (!value || typeof value !== "object" || Array.isArray(value))
87
+ return;
88
+ for (const key of Object.keys(value)) {
89
+ if (allowed.includes(key) || key === "$schema")
90
+ continue;
91
+ // Close in spelling, or sharing the first four letters (`servers` / `services`, `migrations` / `migrate`).
92
+ const near = allowed
93
+ .map((k) => ({ k, d: editDistance(k.toLowerCase(), key.toLowerCase()) }))
94
+ .filter(({ k, d }) => d <= Math.max(2, Math.floor(key.length / 3)) || (key.length >= 4 && k.toLowerCase().startsWith(key.slice(0, 4).toLowerCase())))
95
+ .sort((a, b) => a.d - b.d)[0];
96
+ const hint = near ? `; did you mean "${near.k}"?` : ` (expected ${allowed.join(", ")})`;
97
+ fail(`unknown key ${where}${key}${hint}`);
98
+ }
99
+ };
100
+ checkKeys(opts, TOP_LEVEL_KEYS, "");
101
+ checkKeys(opts?.app, APP_KEYS, "app.");
102
+ if (opts?.db)
103
+ checkKeys(opts.db, DB_KEYS, "db.");
104
+ for (const stub of opts?.stubs ?? [])
105
+ if (typeof stub === "object" && stub)
106
+ checkKeys(stub, STUB_KEYS, `stubs.${stub.name ?? "?"}.`);
107
+ for (const [name, service] of Object.entries(opts?.services ?? {}))
108
+ checkKeys(service, APP_KEYS, `services.${name}.`);
109
+ for (const [name, container] of Object.entries(opts?.containers ?? {}))
110
+ checkKeys(container, CONTAINER_KEYS, `containers.${name}.`);
66
111
  if (!opts?.app || typeof opts.app.command !== "string" || !opts.app.command.trim()) {
67
112
  fail("app.command is required, e.g. { app: { command: \"node server.js\" } }");
68
113
  }
@@ -124,6 +169,9 @@ function validate(opts) {
124
169
  const engine = dbOpts?.engine;
125
170
  if (engine !== undefined && engine !== "postgres" && engine !== "mysql" && engine !== "sqlite")
126
171
  fail(`db.engine must be "postgres", "mysql" or "sqlite", got ${JSON.stringify(engine)}`);
172
+ if (dbOpts?.ignoreChanges !== undefined && !(Array.isArray(dbOpts.ignoreChanges) && dbOpts.ignoreChanges.every((c) => typeof c === "string" && c && !c.startsWith(".")))) {
173
+ fail(`db.ignoreChanges must be a list of columns or tables, e.g. [updated_at, orders.synced_at, sessions.*], got ${JSON.stringify(dbOpts.ignoreChanges)}`);
174
+ }
127
175
  if (dbOpts?.queries !== undefined && typeof dbOpts.queries !== "boolean")
128
176
  fail(`db.queries must be true or false, got ${JSON.stringify(dbOpts.queries)}`);
129
177
  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";
@@ -31,6 +34,7 @@ interface Control {
31
34
  options: {
32
35
  value: string;
33
36
  selected: boolean;
37
+ disabled: boolean;
34
38
  }[];
35
39
  }
36
40
  interface ParsedForm {
@@ -42,4 +46,6 @@ export declare function decodeEntities(s: string): string;
42
46
  export declare function parseForms(html: string): ParsedForm[];
43
47
  /** Works out the request a browser would send for this page's form. */
44
48
  export declare function formRequest(html: string, opts?: SubmitOptions): FormRequest;
49
+ /** Fields as `application/x-www-form-urlencoded`: lists repeat the key, nested objects use bracket keys (`metadata[order]`). */
50
+ export declare function encodeForm(fields: Record<string, unknown>): URLSearchParams;
45
51
  export {};