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 +71 -13
- package/dist/cli.js +20 -2
- package/dist/config.d.ts +6 -0
- package/dist/config.js +3 -0
- package/dist/db.d.ts +12 -1
- package/dist/db.js +33 -6
- package/dist/form.d.ts +7 -2
- package/dist/form.js +72 -7
- package/dist/global-setup.d.ts +5 -0
- package/dist/global-setup.js +29 -5
- package/dist/http.d.ts +11 -3
- package/dist/http.js +32 -9
- package/dist/index.d.ts +4 -3
- package/dist/list.d.ts +26 -0
- package/dist/list.js +71 -0
- package/dist/matchers.d.ts +7 -0
- package/dist/matchers.js +34 -0
- package/dist/record.d.ts +1 -1
- package/dist/record.js +3 -1
- package/dist/runtime.js +1 -0
- package/dist/scenario.d.ts +14 -4
- package/dist/scenario.js +36 -20
- package/dist/stub.d.ts +22 -0
- package/dist/stub.js +105 -2
- package/dist/webhook.d.ts +16 -7
- package/dist/webhook.js +47 -7
- package/dist/yaml-runtime.d.ts +3 -2
- package/dist/yaml-runtime.js +244 -34
- package/dist/yaml.d.ts +43 -2
- package/dist/yaml.js +91 -12
- package/package.json +1 -1
- package/schema/scenario.schema.json +361 -2
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 }`.
|
|
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
|
|
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
|
|
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
|
-
-
|
|
792
|
-
-
|
|
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
|
-
|
|
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
|
|
208
|
-
|
|
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
|
-
/**
|
|
17
|
-
|
|
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
|
-
|
|
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"
|
|
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(/[?#].*$/, "")}?${
|
|
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:
|
|
261
|
+
return { method, action, body: plain() };
|
|
222
262
|
const body = new FormData();
|
|
223
|
-
for (const [k, v] of entries)
|
|
224
|
-
|
|
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
|
+
}
|
package/dist/global-setup.d.ts
CHANGED
|
@@ -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`).
|