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 +83 -17
- package/dist/cli-run.d.ts +13 -0
- package/dist/cli-run.js +15 -0
- package/dist/cli.js +32 -2
- package/dist/config.d.ts +6 -0
- package/dist/config.js +48 -0
- package/dist/db.d.ts +12 -1
- package/dist/db.js +33 -6
- package/dist/form.d.ts +8 -2
- package/dist/form.js +85 -11
- package/dist/global-setup.d.ts +5 -0
- package/dist/global-setup.js +32 -11
- package/dist/har.js +3 -3
- package/dist/http.d.ts +40 -4
- package/dist/http.js +126 -13
- package/dist/index.d.ts +4 -3
- package/dist/init.js +53 -8
- package/dist/list.d.ts +26 -0
- package/dist/list.js +71 -0
- package/dist/matchers.d.ts +21 -3
- package/dist/matchers.js +91 -2
- package/dist/openapi.d.ts +0 -5
- package/dist/openapi.js +13 -6
- package/dist/record.d.ts +1 -1
- package/dist/record.js +3 -1
- package/dist/recording.d.ts +7 -0
- package/dist/recording.js +38 -4
- package/dist/runtime.js +13 -0
- package/dist/scenario.d.ts +14 -4
- package/dist/scenario.js +36 -20
- package/dist/sql-migrations.d.ts +11 -0
- package/dist/sql-migrations.js +38 -0
- package/dist/stub.d.ts +32 -3
- package/dist/stub.js +148 -8
- 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 +287 -36
- package/dist/yaml.d.ts +51 -4
- package/dist/yaml.js +105 -14
- package/package.json +1 -1
- package/schema/scenario.schema.json +431 -6
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
|
-
|
|
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 }`.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
-
|
|
792
|
-
-
|
|
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
|
+
};
|
package/dist/cli-run.js
ADDED
|
@@ -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
|
-
|
|
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";
|
|
@@ -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 {};
|