slicetest 0.4.0 → 0.5.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 +110 -2
- package/dist/auth.d.ts +45 -0
- package/dist/auth.js +128 -0
- package/dist/cli.js +8 -3
- package/dist/config.d.ts +14 -0
- package/dist/config.js +16 -0
- package/dist/db.d.ts +26 -0
- package/dist/db.js +50 -0
- package/dist/drivers/driver.d.ts +27 -0
- package/dist/drivers/mysql.d.ts +2 -1
- package/dist/drivers/mysql.js +38 -0
- package/dist/drivers/postgres.d.ts +2 -1
- package/dist/drivers/postgres.js +28 -0
- package/dist/drivers/sqlite.d.ts +2 -1
- package/dist/drivers/sqlite.js +52 -0
- package/dist/factory.d.ts +21 -0
- package/dist/factory.js +129 -0
- package/dist/gen.d.ts +1 -0
- package/dist/gen.js +9 -4
- package/dist/http.d.ts +7 -0
- package/dist/http.js +10 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +2 -0
- package/dist/init.js +44 -1
- package/dist/openapi.d.ts +8 -0
- package/dist/openapi.js +18 -0
- package/dist/query-log.d.ts +49 -0
- package/dist/query-log.js +261 -0
- package/dist/runtime.d.ts +6 -0
- package/dist/runtime.js +40 -2
- package/dist/stub.d.ts +32 -0
- package/dist/stub.js +87 -0
- package/dist/webhook.d.ts +40 -0
- package/dist/webhook.js +52 -0
- package/dist/yaml-runtime.js +41 -3
- package/dist/yaml.d.ts +36 -1
- package/dist/yaml.js +56 -2
- package/package.json +8 -2
- package/schema/scenario.schema.json +180 -0
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`), Django, FastAPI, Flask, Rails, Go and Rust apps; Atlas, Prisma, Alembic, Django, Rails, Drizzle, Knex and plain SQL migrations; and an `openapi.yaml`. If there's a `compose.yaml` / `docker-compose.yml`, its database service sets `db.image` (and `db.engine: mysql` for MySQL or MariaDB), and Redis, Valkey, Mongo, Elasticsearch, MinIO, RabbitMQ and other services with a port become [`containers`](#containers-redis-search-s3-and-other-dependencies), with a reset command where one is known and the usual variable (`REDIS_URL`, `S3_ENDPOINT`, …) passed to the app. A mail catcher there (Mailpit, MailHog, MailDev, smtp4dev, …) or a mail library in the dependencies turns on [`mail`](#mail-catch-what-the-app-sends). SQLite is picked up from Prisma's provider, Rails' `database.yml`, Django's settings or a SQLite driver, with `DATABASE_URL` in the form the framework reads (`file:…`, `sqlite3:…`). And third-party API URLs in `.env.example` (`STRIPE_API_BASE=https://api.stripe.com`) become stubs [recorded from that service](#recording-a-real-service), with the variable pointed at the stub, while local addresses, databases and your own URLs are left alone. It lists every guess as a comment in the config so you know what to check.
|
|
41
|
+
`init` recognises Node (`npm start`), Django, FastAPI, Flask, Rails, Go and Rust apps; Atlas, Prisma, Alembic, Django, Rails, Drizzle, Knex and plain SQL migrations; and an `openapi.yaml`. If there's a `compose.yaml` / `docker-compose.yml`, its database service sets `db.image` (and `db.engine: mysql` for MySQL or MariaDB), and Redis, Valkey, Mongo, Elasticsearch, MinIO, RabbitMQ and other services with a port become [`containers`](#containers-redis-search-s3-and-other-dependencies), with a reset command where one is known and the usual variable (`REDIS_URL`, `S3_ENDPOINT`, …) passed to the app. A mail catcher there (Mailpit, MailHog, MailDev, smtp4dev, …) or a mail library in the dependencies turns on [`mail`](#mail-catch-what-the-app-sends). SQLite is picked up from Prisma's provider, Rails' `database.yml`, Django's settings or a SQLite driver, with `DATABASE_URL` in the form the framework reads (`file:…`, `sqlite3:…`). And third-party API URLs in `.env.example` (`STRIPE_API_BASE=https://api.stripe.com`) become stubs [recorded from that service](#recording-a-real-service), with the variable pointed at the stub, while local addresses, databases and your own URLs are left alone. Token issuer settings there (`OIDC_ISSUER`, `AUTH0_DOMAIN`, `JWKS_URL`, `JWT_AUDIENCE`, …) turn on [`auth`](#auth-a-real-openid-issuer-tokens-with-any-claims) and point at slicetest's issuer instead of becoming stubs. It lists every guess as a comment in the config so you know what to check.
|
|
42
42
|
|
|
43
43
|
## What you get that's hard to find elsewhere
|
|
44
44
|
|
|
@@ -52,6 +52,10 @@ npx slicetest # starts Postgres, migrates, starts your app, runs scenario
|
|
|
52
52
|
- **Readable in CI.** On GitHub Actions, failing YAML steps are annotated in the pull request on the line that failed, and the job summary shows the OpenAPI coverage table.
|
|
53
53
|
- **Races on purpose.** `http.concurrently(10, ...)` and `toHaveStatuses({ 201: 1, 409: 9 })` turn "what if two people click at once" into a test against the real database.
|
|
54
54
|
- **Mail as a fourth boundary.** `mail: true` catches the app's SMTP traffic in-process, decoded, with the links pulled out, so a sign-up test can follow the confirmation link.
|
|
55
|
+
- **Real token verification, any user.** `auth: true` gives the app an OpenID issuer with a JWKS, so JWT checks stay on in tests, and scenarios mint tokens with any claims, including expired or foreign-signed ones.
|
|
56
|
+
- **Webhooks signed like the real sender.** Stripe, GitHub, Slack, Shopify and Standard Webhooks signatures, plus forged and replayed deliveries, so signature checks are tested instead of bypassed.
|
|
57
|
+
- **Reproducible chaos.** Stubs can fail the first calls, drop connections or add latency, from a seed the failure output prints, so a resilience test that fails once fails again on demand.
|
|
58
|
+
- **N+1 detection for any stack.** A wire-protocol proxy records the SQL the app runs, so query counts are asserted at the HTTP boundary, whatever the ORM or language.
|
|
55
59
|
- **Postgres, MySQL or SQLite**, with the same scenarios and the same helpers on all three, plus Redis, MinIO or any other `containers` reset between scenarios.
|
|
56
60
|
- **Fast resets.** `TRUNCATE` between scenarios (about 1.5 ms) with the app still running, and a cached migrated template, so the second run skips container start-up and migrations.
|
|
57
61
|
|
|
@@ -187,6 +191,18 @@ await db.query("UPDATE users SET name = $1", ["b"]);
|
|
|
187
191
|
|
|
188
192
|
In `where`, `null` means `IS NULL` and an array means `IN (...)`.
|
|
189
193
|
|
|
194
|
+
#### `db.make()` — rows from the schema, not from fixtures
|
|
195
|
+
|
|
196
|
+
Give only the columns the scenario is about. slicetest reads the table's definition and fills in the rest: a value of each required column's type, the first allowed value for enums and `CHECK (status IN (...))`, and a parent row for every required foreign key, made the same way.
|
|
197
|
+
|
|
198
|
+
```ts
|
|
199
|
+
const order = await db.make("orders", { status: "paid" }); // also creates the customer and the product it references
|
|
200
|
+
await db.makeMany("votes", 3, { poll_id: order.poll_id });
|
|
201
|
+
await db.makeMany("users", 2, (i) => ({ name: `user ${i}` }));
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Generated values are numbered per scenario (`title-1`, `orders-2@example.test`, UUIDs, dates from 2026-01-01), so they are unique and identical on every run, which keeps `trace()` snapshots stable. Works the same on Postgres, MySQL and SQLite. When a column needs a value slicetest can't guess (a custom type, a check it can't satisfy), the error names the column to pass.
|
|
205
|
+
|
|
190
206
|
#### `db.changes()` — assert on everything the app wrote
|
|
191
207
|
|
|
192
208
|
Instead of guessing which tables to query, ask for the diff. Rows are matched by primary key, so updates show which columns changed:
|
|
@@ -205,6 +221,26 @@ expect(await db.changes()).toEqual({
|
|
|
205
221
|
|
|
206
222
|
`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.
|
|
207
223
|
|
|
224
|
+
#### `db.queries()` — the SQL the app ran, from any language
|
|
225
|
+
|
|
226
|
+
With `db: { queries: true }`, the app's `{{db.url}}` points at a proxy that reads the Postgres or MySQL wire protocol and records every statement the app runs. Nothing changes in the app, and it works the same for an ORM in Node, Python, Ruby, Go or Java. So you can pin down N+1 queries and query counts at the HTTP boundary:
|
|
227
|
+
|
|
228
|
+
```ts
|
|
229
|
+
const queries = await db.queries(() => http.get("/posts")); // only what ran during the request
|
|
230
|
+
expect(queries.repeated()).toEqual([]); // no statement shape ran 3+ times
|
|
231
|
+
expect(queries.withoutTransactions()).toHaveLength(2); // ignore BEGIN / COMMIT that some drivers add
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
`repeated(min = 3)` and `shapes()` group statements by shape, with literals and parameters replaced by `?`. `db.queries()` without a function returns the whole scenario so far. The test's own `db` calls aren't included. When a scenario fails, the output lists the SQL the app ran, most frequent first, so an N+1 stands out:
|
|
235
|
+
|
|
236
|
+
```
|
|
237
|
+
SQL the app ran during this scenario (21 statements, most frequent first):
|
|
238
|
+
×20 SELECT * FROM authors WHERE id = ?
|
|
239
|
+
SELECT * FROM posts ORDER BY id
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
YAML: `expect: { queries: 3 }` on a `request` step fails if the request ran more than 3 statements (not counting transaction control). Connections that switch to TLS are forwarded but not read. SQLite apps open the file directly, so this isn't available for them.
|
|
243
|
+
|
|
208
244
|
### `stub(name)` — fake the services the app calls
|
|
209
245
|
|
|
210
246
|
```ts
|
|
@@ -276,6 +312,21 @@ To fail the run below a threshold, use `openapi: { spec: "openapi.yaml", minCove
|
|
|
276
312
|
|
|
277
313
|
The example apps in `examples/` run every scenario against `examples/openapi.yaml` with `minCoverage: 100`, and their Slack calls against `examples/slack.openapi.yaml`.
|
|
278
314
|
|
|
315
|
+
#### Chaos: faults the app must survive
|
|
316
|
+
|
|
317
|
+
`chaos()` makes a stub misbehave for the rest of the scenario, to test retries, timeouts and fallbacks against the app's real HTTP client:
|
|
318
|
+
|
|
319
|
+
```ts
|
|
320
|
+
stub("payments").on("POST", "/charges").once().reply(201, { id: "ch_1" });
|
|
321
|
+
stub("payments").chaos({ failFirst: 2, statuses: [503] }); // 503, 503, then the real answer
|
|
322
|
+
await http.post("/orders", { ... });
|
|
323
|
+
expect(stub("payments")).toHaveReceivedTimes(3, "POST", "/charges");
|
|
324
|
+
|
|
325
|
+
stub("search").chaos({ errorRate: 0.3, networkErrorRate: 0.1, latency: [50, 300] });
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
Faulted calls don't use up `once()` / `times()` routes, so a retry gets the answer you registered. 429 and 503 come with `Retry-After: 1`. Random faults are drawn from a seeded generator: a failing scenario prints `chaos on search: …; 4 of 12 calls faulted. Replay with SLICETEST_CHAOS_SEED=1840211`, and running with that variable gives the same faults. `stub.faults()` lists the calls that faulted. YAML: `- chaos: payments` with `failFirst`, `errorRate`, `statuses`, `networkErrorRate`, `latency` and `seed`.
|
|
329
|
+
|
|
279
330
|
### Recording a real service
|
|
280
331
|
|
|
281
332
|
A stub can also answer from recordings of the real service, the way VCR or Polly do, except that it works for an app in any language because the stub is a server. Give it the real base URL:
|
|
@@ -389,6 +440,55 @@ scenario("sign-up sends a confirmation link that works", async ({ http, mail })
|
|
|
389
440
|
|
|
390
441
|
`mail.messages(filter?)`, `mail.last(filter?)` and `mail.waitFor(filter?, { within })` take `{ to, from, subject, text, html }`: addresses match exactly, other strings as substrings, and RegExps test the value. Each message has `from`, `to` (the envelope, so Cc and Bcc too), `subject`, `text`, `html`, `headers`, `links` and `raw`. The mailbox is emptied before each scenario, what was sent shows up in the failure output and in `trace()`, and `{{mail.url}}` is `smtp://host:port` for libraries that take a URL. No container is involved, so it works the same for apps in any language and on Windows.
|
|
391
442
|
|
|
443
|
+
### Auth: a real OpenID issuer, tokens with any claims
|
|
444
|
+
|
|
445
|
+
Apps that verify JWTs are hard to test from the outside: you either disable verification in tests or copy a production token. With `auth: true`, slicetest runs an OpenID Connect issuer for the app, with a discovery document, a JWKS and RS256 keys made for the run, so the app verifies tokens exactly as it does in production. The scenario mints whatever user it needs.
|
|
446
|
+
|
|
447
|
+
```ts
|
|
448
|
+
slicetest({
|
|
449
|
+
auth: { audience: "api://orders", claims: { tenant: "acme" } }, // or just `auth: true`
|
|
450
|
+
app: { command: "...", env: { OIDC_ISSUER: "{{auth.issuer}}", JWKS_URL: "{{auth.jwks}}", OIDC_AUDIENCE: "{{auth.audience}}" } },
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
scenario("admins can delete orders", async ({ http, auth }) => {
|
|
454
|
+
const res = await http.delete("/orders/1", { headers: auth.header({ sub: "alice", roles: ["admin"] }) });
|
|
455
|
+
expect(res).toHaveStatus(204);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
scenario("the app rejects tokens it must not trust", async ({ http, auth }) => {
|
|
459
|
+
for (const opts of [{ expired: true }, { wrongKey: true }, { audience: "api://other" }, { issuer: "https://evil.example" }]) {
|
|
460
|
+
expect(await http.get("/orders", { headers: auth.header({}, opts) })).toHaveStatus(401);
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
`auth.token(claims, opts)` returns the JWT itself. Tokens get `iss`, `aud`, `sub: "user-1"`, `iat`, `nbf`, `exp` (1 hour, or `expiresIn`) and the configured `claims`, all overridable. `auth.rotate()` switches to a new signing key, to check that the app refetches the JWKS. Apps that fetch tokens themselves can use `POST {{auth.issuer}}/token` with the client-credentials grant: `sub` is the client id, and `scope` and `audience` are carried over. No dependencies: keys and signatures come from `node:crypto`.
|
|
466
|
+
|
|
467
|
+
In YAML, `auth` on a `request` step sends `Authorization: Bearer` with those claims (`auth: true` for the defaults):
|
|
468
|
+
|
|
469
|
+
```yaml
|
|
470
|
+
- request: GET /me
|
|
471
|
+
auth: { sub: alice, roles: [admin] }
|
|
472
|
+
expect: { status: 200 }
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
### Webhooks: deliveries signed like the provider's
|
|
476
|
+
|
|
477
|
+
`http.webhook()` posts a payload the way Stripe, GitHub, Slack, Shopify or any [Standard Webhooks](https://www.standardwebhooks.com/) sender (Svix, Resend, Clerk, …) delivers it, signed with the secret the app is configured with, so the app's real verification code runs. The signatures are checked against the providers' documented examples.
|
|
478
|
+
|
|
479
|
+
```ts
|
|
480
|
+
const stripe = { provider: "stripe", secret: "whsec_test" } as const; // the same secret as in app.env
|
|
481
|
+
|
|
482
|
+
await http.webhook("/webhooks/stripe", { type: "invoice.paid", data: { object: { id: "in_1" } } }, stripe);
|
|
483
|
+
await http.webhook("/webhooks/github", { action: "opened" }, { provider: "github", secret: "s", event: "pull_request" });
|
|
484
|
+
|
|
485
|
+
// Deliveries the app must refuse:
|
|
486
|
+
expect(await http.webhook("/webhooks/stripe", event, { ...stripe, invalidSignature: true })).toHaveStatus(400);
|
|
487
|
+
expect(await http.webhook("/webhooks/stripe", event, { ...stripe, stale: true })).toHaveStatus(400); // signed 10 minutes ago
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
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.
|
|
491
|
+
|
|
392
492
|
### Asynchronous side effects
|
|
393
493
|
|
|
394
494
|
If the app does work in the background (a job queue, a fire-and-forget webhook), wait for the effect with Vitest's own helpers. slicetest doesn't need its own:
|
|
@@ -445,11 +545,13 @@ Scenarios in one file share an app and a database, so they always run one at a t
|
|
|
445
545
|
| `db.seed` | none | SQL file re-run after every reset. |
|
|
446
546
|
| `db.schemas` | `["public"]` | Schemas whose tables are reset. |
|
|
447
547
|
| `db.keep` | `[]` | Extra tables (`name` or `schema.name`) never truncated. |
|
|
548
|
+
| `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). |
|
|
448
549
|
| `db.url` | `$SLICETEST_DATABASE_URL`, else a container | Use an existing Postgres server (e.g. a CI service container) instead of Testcontainers. |
|
|
449
550
|
| `db.image` | `postgres:17-alpine` / `mysql:8.4` | |
|
|
450
551
|
| `db.reuse` | on, unless `CI` is set or `db.url` is given | Keep the container between runs and cache the migrated template. The cache key is the migration files' contents; for `{ command }`, list what it reads in `inputs: ["prisma/migrations"]`, or it migrates every run. Databases left by killed runs are dropped after a day. Remove the container (`docker rm -f` / `podman rm -f`) to start clean. |
|
|
451
552
|
| `containers` | `{}` | Dependencies as containers: `{ name: { image, port, env?, command?, ready?: { log }, reset? } }`. See [Containers](#containers-redis-search-s3-and-other-dependencies). |
|
|
452
553
|
| `mail` | `false` | Start an SMTP server at `{{mail.host}}` / `{{mail.port}}` and collect the app's mail. See [Mail](#mail-catch-what-the-app-sends). |
|
|
554
|
+
| `auth` | `false` | `true` or `{ audience, claims }`: an OpenID Connect issuer at `{{auth.issuer}}` (JWKS at `{{auth.jwks}}`) whose tokens scenarios mint with `auth.token()`. See [Auth](#auth-a-real-openid-issuer-tokens-with-any-claims). |
|
|
453
555
|
| `services` | `{}` | Other processes: `{ name: { command, env?, cwd?, ready?, readyTimeout? } }`. Without `ready` a service is not waited for. |
|
|
454
556
|
| `stubs` | `[]` | Names of stubbed services, or `{ name, openapi?, autoReply?, upstream?, recordings? }`: check calls against the provider's spec, answer from it, or [replay recordings](#recording-a-real-service) of the real service. |
|
|
455
557
|
| `openapi` | none | The app's OpenAPI 3 spec, or `{ spec, minCoverage }`. Every response must match it; the run ends with a coverage report. |
|
|
@@ -506,6 +608,10 @@ scenarios:
|
|
|
506
608
|
| `stub: <name>` | `on: METHOD /path` (`:params` allowed), `when: { query, headers, json, body }`, one of `reply: { status, headers, body }` / `sequence: [...]` / `networkError: true`, plus `times`, `delay`. Replies may echo the call: `{{call.params.id}}`, `{{call.json.name}}`. |
|
|
507
609
|
| `request: METHOD /path` | `headers`, `query`, one of `json` / `form` / `body`, `follow`, `expect: { status, headers, json, text }`, `capture`. `concurrency: n` sends it `n` times at once; `expect` then applies to each response, and `expect.statuses: { 201: 1, 409: 9 }` counts them. |
|
|
508
610
|
| `insert: <table>` | `rows`, `capture` (from `row` / `rows`) |
|
|
611
|
+
| `request` with `auth` | `auth: true` or the claims: sends a bearer token from the `auth` issuer |
|
|
612
|
+
| `request` with `webhook` | `{ provider, secret, event, stale, invalidSignature }`: signs the body like that provider's deliveries |
|
|
613
|
+
| `chaos: <stub>` | `failFirst`, `errorRate`, `statuses`, `networkErrorRate`, `latency`, `seed` — like `stub(name).chaos()` |
|
|
614
|
+
| `make: <table>` | `rows` (a mapping, or a list for several rows), `count`, `capture` (from `row` / `rows`) — like `db.make()` |
|
|
509
615
|
| `db: <table>` | `where`, `orderBy`, `expect: { rows, count }`, `capture` |
|
|
510
616
|
| `sql: <query>` | `params`, `expect: { rows, count }`, `capture` |
|
|
511
617
|
| `received: <stub>` | `call: METHOD /path`, `when`, `times` (exact; default at least once) |
|
|
@@ -553,6 +659,8 @@ npx slicetest gen --uncovered # only the documented responses the last run d
|
|
|
553
659
|
|
|
554
660
|
writes `scenarios/<resource>.gen.scenario.yaml` with one scenario per documented response. Requests are built from the spec's examples and schemas. A path that needs an id gets a step that creates the resource first through the collection's `POST` and captures its id. A 404 on a made-up id and a 400/422 on an empty body are runnable as is; other responses are generated as `skip: true` scenarios marked TODO, so the skipped list in the test output is what's left to cover. Existing files are kept unless you pass `--force`.
|
|
555
661
|
|
|
662
|
+
Operations that the spec protects with a bearer token (`http: bearer`, `oauth2` or `openIdConnect` security) get `auth:` on their requests, with the scopes the spec requires in the token's `scope` claim, and their 401 responses become runnable scenarios that send no token. With [`auth`](#auth-a-real-openid-issuer-tokens-with-any-claims) in the config, the generated scenarios run against the app's real token checks.
|
|
663
|
+
|
|
556
664
|
`--uncovered` reads the coverage the last run left in `node_modules/.cache/slicetest/`, which closes the loop: run, look at the ✗ in the coverage table, `gen --uncovered`, fill in the TODOs.
|
|
557
665
|
|
|
558
666
|
### Record a scenario by using the app: `npx slicetest record`
|
|
@@ -612,4 +720,4 @@ npm run test:dist # the built package, and the CLI with examples/slicetest.con
|
|
|
612
720
|
|
|
613
721
|
## Status
|
|
614
722
|
|
|
615
|
-
Early. Postgres and
|
|
723
|
+
Early. Postgres, MySQL and SQLite. CI runs on Linux and Windows.
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export interface TokenOptions {
|
|
2
|
+
/** Seconds until the token expires. Default 3600. */
|
|
3
|
+
expiresIn?: number;
|
|
4
|
+
/** A token that expired a minute ago. */
|
|
5
|
+
expired?: boolean;
|
|
6
|
+
/** Signed by a key the issuer doesn't publish: the app must reject it. */
|
|
7
|
+
wrongKey?: boolean;
|
|
8
|
+
/** Override `aud` (default: the configured audience). */
|
|
9
|
+
audience?: string | string[];
|
|
10
|
+
/** Override `iss`, e.g. to test that the app checks it. */
|
|
11
|
+
issuer?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface AuthOptions {
|
|
14
|
+
/** `aud` of the tokens. Default `"slicetest"`. */
|
|
15
|
+
audience?: string;
|
|
16
|
+
/** Claims every token gets unless the scenario overrides them. */
|
|
17
|
+
claims?: Record<string, unknown>;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* An OpenID Connect issuer for the app under test: it publishes a discovery
|
|
21
|
+
* document and a JWKS, so the app verifies tokens exactly as it does in
|
|
22
|
+
* production, and the scenario mints tokens with whatever claims it needs.
|
|
23
|
+
* `POST /token` answers the client-credentials grant for apps that fetch
|
|
24
|
+
* tokens themselves.
|
|
25
|
+
*/
|
|
26
|
+
export declare class Issuer {
|
|
27
|
+
#private;
|
|
28
|
+
private readonly server;
|
|
29
|
+
readonly url: string;
|
|
30
|
+
private readonly opts;
|
|
31
|
+
private constructor();
|
|
32
|
+
static start(opts?: AuthOptions): Promise<Issuer>;
|
|
33
|
+
get audience(): string;
|
|
34
|
+
get jwksUrl(): string;
|
|
35
|
+
/** A signed RS256 JWT. `claims` override the defaults (`sub: "user-1"`, `iss`, `aud`, `iat`, `exp`). */
|
|
36
|
+
token(claims?: Record<string, unknown>, opts?: TokenOptions): string;
|
|
37
|
+
/** `{ authorization: "Bearer <token>" }`, for `http.get(path, { headers })`. */
|
|
38
|
+
header(claims?: Record<string, unknown>, opts?: TokenOptions): {
|
|
39
|
+
authorization: string;
|
|
40
|
+
};
|
|
41
|
+
/** Rotate the signing key: tokens minted from now on use a new `kid`, and the JWKS publishes only it. */
|
|
42
|
+
rotate(): void;
|
|
43
|
+
reset(): void;
|
|
44
|
+
close(): Promise<void>;
|
|
45
|
+
}
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { createSign, generateKeyPairSync, randomUUID } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
const b64url = (data) => Buffer.from(data).toString("base64url");
|
|
4
|
+
function newKey() {
|
|
5
|
+
const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
|
|
6
|
+
const kid = randomUUID();
|
|
7
|
+
return { kid, privateKey, jwk: { ...publicKey.export({ format: "jwk" }), kid, alg: "RS256", use: "sig" } };
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* An OpenID Connect issuer for the app under test: it publishes a discovery
|
|
11
|
+
* document and a JWKS, so the app verifies tokens exactly as it does in
|
|
12
|
+
* production, and the scenario mints tokens with whatever claims it needs.
|
|
13
|
+
* `POST /token` answers the client-credentials grant for apps that fetch
|
|
14
|
+
* tokens themselves.
|
|
15
|
+
*/
|
|
16
|
+
export class Issuer {
|
|
17
|
+
server;
|
|
18
|
+
url;
|
|
19
|
+
opts;
|
|
20
|
+
#key = newKey();
|
|
21
|
+
/** Never published: tokens signed with it must be rejected. */
|
|
22
|
+
#rogue;
|
|
23
|
+
#issued = 0;
|
|
24
|
+
constructor(server, url, opts) {
|
|
25
|
+
this.server = server;
|
|
26
|
+
this.url = url;
|
|
27
|
+
this.opts = opts;
|
|
28
|
+
}
|
|
29
|
+
static async start(opts = {}) {
|
|
30
|
+
let issuer;
|
|
31
|
+
const server = createServer((req, res) => {
|
|
32
|
+
issuer.#handle(req).then(([status, body]) => {
|
|
33
|
+
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
|
|
34
|
+
res.end(JSON.stringify(body));
|
|
35
|
+
}, (e) => {
|
|
36
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
37
|
+
res.end(JSON.stringify({ error: "server_error", error_description: String(e) }));
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
41
|
+
const { port } = server.address();
|
|
42
|
+
issuer = new Issuer(server, `http://127.0.0.1:${port}`, { audience: opts.audience ?? "slicetest", claims: opts.claims ?? {} });
|
|
43
|
+
return issuer;
|
|
44
|
+
}
|
|
45
|
+
get audience() {
|
|
46
|
+
return this.opts.audience;
|
|
47
|
+
}
|
|
48
|
+
get jwksUrl() {
|
|
49
|
+
return `${this.url}/.well-known/jwks.json`;
|
|
50
|
+
}
|
|
51
|
+
/** A signed RS256 JWT. `claims` override the defaults (`sub: "user-1"`, `iss`, `aud`, `iat`, `exp`). */
|
|
52
|
+
token(claims = {}, opts = {}) {
|
|
53
|
+
const now = Math.floor(Date.now() / 1000);
|
|
54
|
+
const exp = opts.expired ? now - 60 : now + (opts.expiresIn ?? 3600);
|
|
55
|
+
const key = opts.wrongKey ? (this.#rogue ??= newKey()) : this.#key;
|
|
56
|
+
const payload = {
|
|
57
|
+
iss: opts.issuer ?? this.url,
|
|
58
|
+
aud: opts.audience ?? this.opts.audience,
|
|
59
|
+
sub: "user-1",
|
|
60
|
+
iat: opts.expired ? exp - 3600 : now,
|
|
61
|
+
nbf: opts.expired ? exp - 3600 : now,
|
|
62
|
+
exp,
|
|
63
|
+
jti: `slicetest-${++this.#issued}`,
|
|
64
|
+
...this.opts.claims,
|
|
65
|
+
...claims,
|
|
66
|
+
};
|
|
67
|
+
const head = b64url(JSON.stringify({ alg: "RS256", typ: "JWT", kid: key.kid }));
|
|
68
|
+
const body = b64url(JSON.stringify(payload));
|
|
69
|
+
const signature = createSign("RSA-SHA256").update(`${head}.${body}`).sign(key.privateKey);
|
|
70
|
+
return `${head}.${body}.${b64url(signature)}`;
|
|
71
|
+
}
|
|
72
|
+
/** `{ authorization: "Bearer <token>" }`, for `http.get(path, { headers })`. */
|
|
73
|
+
header(claims, opts) {
|
|
74
|
+
return { authorization: `Bearer ${this.token(claims, opts)}` };
|
|
75
|
+
}
|
|
76
|
+
/** Rotate the signing key: tokens minted from now on use a new `kid`, and the JWKS publishes only it. */
|
|
77
|
+
rotate() {
|
|
78
|
+
this.#key = newKey();
|
|
79
|
+
}
|
|
80
|
+
reset() {
|
|
81
|
+
this.#issued = 0;
|
|
82
|
+
}
|
|
83
|
+
async #handle(req) {
|
|
84
|
+
const path = new URL(req.url ?? "/", this.url).pathname;
|
|
85
|
+
if (req.method === "GET" && path === "/.well-known/openid-configuration") {
|
|
86
|
+
return [
|
|
87
|
+
200,
|
|
88
|
+
{
|
|
89
|
+
issuer: this.url,
|
|
90
|
+
jwks_uri: this.jwksUrl,
|
|
91
|
+
token_endpoint: `${this.url}/token`,
|
|
92
|
+
response_types_supported: ["token"],
|
|
93
|
+
subject_types_supported: ["public"],
|
|
94
|
+
id_token_signing_alg_values_supported: ["RS256"],
|
|
95
|
+
grant_types_supported: ["client_credentials"],
|
|
96
|
+
},
|
|
97
|
+
];
|
|
98
|
+
}
|
|
99
|
+
if (req.method === "GET" && path === "/.well-known/jwks.json")
|
|
100
|
+
return [200, { keys: [this.#key.jwk] }];
|
|
101
|
+
if (req.method === "POST" && path === "/token") {
|
|
102
|
+
const form = new URLSearchParams(await text(req));
|
|
103
|
+
if (form.get("grant_type") !== "client_credentials")
|
|
104
|
+
return [400, { error: "unsupported_grant_type" }];
|
|
105
|
+
const basic = /^Basic\s+(.+)$/i.exec(req.headers.authorization ?? "")?.[1];
|
|
106
|
+
const client = form.get("client_id") ?? (basic ? decodeURIComponent(Buffer.from(basic, "base64").toString().split(":")[0]) : undefined);
|
|
107
|
+
if (!client)
|
|
108
|
+
return [401, { error: "invalid_client" }];
|
|
109
|
+
const claims = { sub: client, client_id: client };
|
|
110
|
+
const scope = form.get("scope");
|
|
111
|
+
if (scope)
|
|
112
|
+
claims.scope = scope;
|
|
113
|
+
const audience = form.get("audience") ?? undefined;
|
|
114
|
+
return [200, { access_token: this.token(claims, { audience }), token_type: "Bearer", expires_in: 3600, ...(scope ? { scope } : {}) }];
|
|
115
|
+
}
|
|
116
|
+
return [404, { error: "not_found", error_description: `slicetest's issuer serves /.well-known/openid-configuration, /.well-known/jwks.json and POST /token, not ${req.method} ${path}` }];
|
|
117
|
+
}
|
|
118
|
+
async close() {
|
|
119
|
+
this.server.closeAllConnections();
|
|
120
|
+
await new Promise((resolve) => this.server.close(resolve));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
async function text(req) {
|
|
124
|
+
const chunks = [];
|
|
125
|
+
for await (const chunk of req)
|
|
126
|
+
chunks.push(chunk);
|
|
127
|
+
return Buffer.concat(chunks).toString();
|
|
128
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -39,11 +39,12 @@ Options:
|
|
|
39
39
|
|
|
40
40
|
Config (paths are relative to the config file):
|
|
41
41
|
app: { command, env, cwd, ready: { path } | { log }, readyTimeout }
|
|
42
|
-
db: { migrate: { atlas: { dir } } | { sql } | { command, inputs }, engine, seed, url, image, schemas, keep, reuse }
|
|
42
|
+
db: { migrate: { atlas: { dir } } | { sql } | { command, inputs }, engine, seed, url, image, schemas, keep, reuse, queries }
|
|
43
43
|
stubs: [name | { name, openapi, autoReply, upstream, recordings }]
|
|
44
44
|
services: { name: { command, env, cwd, ready } }
|
|
45
45
|
containers: { name: { image, port, env, command, ready: { log }, reset } }
|
|
46
46
|
mail: true SMTP server at {{mail.host}} / {{mail.port}}
|
|
47
|
+
auth: true | { audience, claims } OpenID issuer at {{auth.issuer}} / {{auth.jwks}}
|
|
47
48
|
openapi: file | { spec, minCoverage }
|
|
48
49
|
http: { headers, query }
|
|
49
50
|
include: [globs] default ["**/*.scenario.{yaml,yml}"]
|
|
@@ -86,17 +87,21 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
86
87
|
return;
|
|
87
88
|
}
|
|
88
89
|
if (positionals[0] === "gen") {
|
|
89
|
-
const
|
|
90
|
+
const config = configPath && existsSync(configPath) ? (parse(await readFile(configPath, "utf8")) ?? {}) : undefined;
|
|
91
|
+
const configOpenapi = config?.openapi;
|
|
90
92
|
const spec = values.spec ?? (typeof configOpenapi === "object" ? configOpenapi.spec : configOpenapi);
|
|
91
93
|
if (!spec)
|
|
92
94
|
throw new Error("slicetest gen: no OpenAPI spec. Pass --spec openapi.yaml, or set `openapi` in the config.");
|
|
93
95
|
const root = values.spec || !configPath ? process.cwd() : path.dirname(configPath);
|
|
94
96
|
const { gen } = await import("./gen.js");
|
|
95
|
-
const { written, skipped, count } = await gen(root, { spec, out: values.out, uncovered: values.uncovered, force: values.force });
|
|
97
|
+
const { written, skipped, count, needsAuth } = await gen(root, { spec, out: values.out, uncovered: values.uncovered, force: values.force });
|
|
96
98
|
const lines = [
|
|
97
99
|
count === 0 ? "Every documented response is already covered; nothing to generate." : `${count} scenario(s) for the responses in ${spec}.`,
|
|
98
100
|
...written.map((f) => ` wrote ${f}`),
|
|
99
101
|
...skipped.map((f) => ` skipped ${f} (exists; --force to overwrite)`),
|
|
102
|
+
...(needsAuth && count > 0 && !config?.auth
|
|
103
|
+
? ["", "The spec requires bearer tokens: requests carry `auth:`. Add `auth: true` to the config and point the app's JWT settings at {{auth.issuer}} / {{auth.jwks}}."]
|
|
104
|
+
: []),
|
|
100
105
|
];
|
|
101
106
|
process.stdout.write(`${lines.join("\n")}\n`);
|
|
102
107
|
return;
|
package/dist/config.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AuthOptions } from "./auth.js";
|
|
1
2
|
import type { RequestOptions } from "./http.js";
|
|
2
3
|
export interface SlicetestOptions {
|
|
3
4
|
app: AppOptions;
|
|
@@ -43,6 +44,12 @@ export interface SlicetestOptions {
|
|
|
43
44
|
* scenarios read what arrived with `mail.messages()` / `mail.waitFor()`.
|
|
44
45
|
*/
|
|
45
46
|
mail?: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* An OpenID Connect issuer for apps that verify JWTs. The app gets
|
|
49
|
+
* `{{auth.issuer}}`, `{{auth.jwks}}` and `{{auth.audience}}`; scenarios mint
|
|
50
|
+
* tokens with `auth.token({ sub, roles })`. `true`, or `{ audience, claims }`.
|
|
51
|
+
*/
|
|
52
|
+
auth?: boolean | AuthOptions;
|
|
46
53
|
}
|
|
47
54
|
export interface ContainerOptions {
|
|
48
55
|
image: string;
|
|
@@ -135,6 +142,12 @@ export interface DbOptions {
|
|
|
135
142
|
* Default: on, except when `CI` is set or `url` is given.
|
|
136
143
|
*/
|
|
137
144
|
reuse?: boolean;
|
|
145
|
+
/**
|
|
146
|
+
* Record the SQL the app runs: `{{db.url}}` points the app at a proxy that
|
|
147
|
+
* reads the wire protocol (Postgres and MySQL), so `db.queries()` lists every
|
|
148
|
+
* statement, whatever the app's language or driver. Off by default.
|
|
149
|
+
*/
|
|
150
|
+
queries?: boolean;
|
|
138
151
|
}
|
|
139
152
|
export type MigrateOptions = {
|
|
140
153
|
atlas: {
|
|
@@ -160,6 +173,7 @@ export interface ResolvedOptions {
|
|
|
160
173
|
services: Record<string, ResolvedProcess>;
|
|
161
174
|
containers: Record<string, ContainerOptions>;
|
|
162
175
|
mail: boolean;
|
|
176
|
+
auth: AuthOptions | false;
|
|
163
177
|
db: Required<Pick<DbOptions, "engine" | "image" | "schemas" | "keep" | "reuse">> & Omit<DbOptions, "engine" | "image" | "schemas" | "keep" | "reuse">;
|
|
164
178
|
stubs: string[];
|
|
165
179
|
/** Spec files, resolved against the root: the app's, and per stub name. */
|
package/dist/config.js
CHANGED
|
@@ -6,6 +6,7 @@ export function resolveOptions(opts, root) {
|
|
|
6
6
|
services: Object.fromEntries(Object.entries(opts.services ?? {}).map(([name, s]) => [name, { ...s, ready: s.ready && resolveReady(s.ready) }])),
|
|
7
7
|
containers: opts.containers ?? {},
|
|
8
8
|
mail: opts.mail ?? false,
|
|
9
|
+
auth: opts.auth === true ? {} : (opts.auth ?? false),
|
|
9
10
|
db: resolveDb(opts.db ?? {}),
|
|
10
11
|
stubs: (opts.stubs ?? []).map(stubName),
|
|
11
12
|
openapi: {
|
|
@@ -87,9 +88,24 @@ function validate(opts) {
|
|
|
87
88
|
}
|
|
88
89
|
if (opts.mail !== undefined && typeof opts.mail !== "boolean")
|
|
89
90
|
fail(`mail must be true or false, got ${JSON.stringify(opts.mail)}`);
|
|
91
|
+
if (opts.auth !== undefined && typeof opts.auth !== "boolean") {
|
|
92
|
+
if (!opts.auth || typeof opts.auth !== "object" || Array.isArray(opts.auth))
|
|
93
|
+
fail(`auth must be true or { audience, claims }, got ${JSON.stringify(opts.auth)}`);
|
|
94
|
+
for (const key of Object.keys(opts.auth))
|
|
95
|
+
if (key !== "audience" && key !== "claims")
|
|
96
|
+
fail(`unknown key auth.${key} (expected audience, claims)`);
|
|
97
|
+
if (opts.auth.audience !== undefined && typeof opts.auth.audience !== "string")
|
|
98
|
+
fail("auth.audience must be a string");
|
|
99
|
+
if (opts.auth.claims !== undefined && (!opts.auth.claims || typeof opts.auth.claims !== "object" || Array.isArray(opts.auth.claims)))
|
|
100
|
+
fail("auth.claims must be a mapping of claim names to values");
|
|
101
|
+
}
|
|
90
102
|
const engine = opts.db?.engine;
|
|
91
103
|
if (engine !== undefined && engine !== "postgres" && engine !== "mysql" && engine !== "sqlite")
|
|
92
104
|
fail(`db.engine must be "postgres", "mysql" or "sqlite", got ${JSON.stringify(engine)}`);
|
|
105
|
+
if (opts.db?.queries !== undefined && typeof opts.db.queries !== "boolean")
|
|
106
|
+
fail(`db.queries must be true or false, got ${JSON.stringify(opts.db.queries)}`);
|
|
107
|
+
if (engine === "sqlite" && opts.db?.queries)
|
|
108
|
+
fail("db.queries needs a database server (postgres or mysql): an SQLite app opens the file directly, so there is no connection to read");
|
|
93
109
|
if (engine === "sqlite" && opts.db?.url)
|
|
94
110
|
fail("db.url doesn't apply to sqlite: slicetest creates the database files itself and passes them to the app as {{db.url}} / {{db.path}}");
|
|
95
111
|
const migrate = opts.db?.migrate;
|
package/dist/db.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Driver, Row } from "./drivers/driver.js";
|
|
2
|
+
import type { QueryList, QueryLog } from "./query-log.js";
|
|
2
3
|
export type { Row } from "./drivers/driver.js";
|
|
3
4
|
/**
|
|
4
5
|
* Column filters. `null` means IS NULL and an array means IN (...);
|
|
@@ -48,6 +49,31 @@ export declare class Db {
|
|
|
48
49
|
count(table: string, where?: Where): Promise<number>;
|
|
49
50
|
/** Insert rows and return them as stored (with defaults and generated ids). */
|
|
50
51
|
insert<T extends Row = Row>(table: string, rows: Row | Row[]): Promise<T[]>;
|
|
52
|
+
/**
|
|
53
|
+
* Insert a row that satisfies the schema, giving only the columns the test cares about.
|
|
54
|
+
* Required columns get a value of their type (enums and `CHECK (... IN (...))` their first
|
|
55
|
+
* allowed value) and required foreign keys a parent row made the same way.
|
|
56
|
+
*
|
|
57
|
+
* ```ts
|
|
58
|
+
* const order = await db.make("orders", { status: "paid" }); // also creates the customer it needs
|
|
59
|
+
* ```
|
|
60
|
+
*/
|
|
61
|
+
make<T extends Row = Row>(table: string, overrides?: Row): Promise<T>;
|
|
62
|
+
/** `count` rows made like `make()`; `overrides` may depend on the index. */
|
|
63
|
+
makeMany<T extends Row = Row>(table: string, count: number, overrides?: Row | ((i: number) => Row)): Promise<T[]>;
|
|
64
|
+
/** @internal Set by the runtime when `db.queries` is on. */
|
|
65
|
+
attachQueryLog(log: QueryLog): void;
|
|
66
|
+
/**
|
|
67
|
+
* SQL the app ran during this scenario, or only while `fn` ran. Needs `db: { queries: true }`.
|
|
68
|
+
* The test's own `db` calls aren't included.
|
|
69
|
+
*
|
|
70
|
+
* ```ts
|
|
71
|
+
* const queries = await db.queries(() => http.get("/posts"));
|
|
72
|
+
* expect(queries.repeated()).toEqual([]); // no statement shape ran 3+ times: no N+1
|
|
73
|
+
* expect(queries.length).toBeLessThanOrEqual(3);
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
queries(fn?: () => unknown): Promise<QueryList>;
|
|
51
77
|
/** Empty every data table without dropping the app's connections, then re-apply the seed. */
|
|
52
78
|
reset(): Promise<void>;
|
|
53
79
|
/**
|
package/dist/db.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { Factory } from "./factory.js";
|
|
2
3
|
/** Tables that record applied migrations. Truncating them would make tools re-run migrations. */
|
|
3
4
|
const MIGRATION_TABLES = [
|
|
4
5
|
"atlas_schema_revisions",
|
|
@@ -29,10 +30,13 @@ export class Db {
|
|
|
29
30
|
/** Contents right after the reset (and seed); undefined means every table was empty. */
|
|
30
31
|
#start;
|
|
31
32
|
#checkpoint;
|
|
33
|
+
#factory;
|
|
34
|
+
#queryLog;
|
|
32
35
|
constructor(driver, url, opts) {
|
|
33
36
|
this.url = url;
|
|
34
37
|
this.opts = opts;
|
|
35
38
|
this.#driver = driver;
|
|
39
|
+
this.#factory = new Factory((table) => driver.describe(table), (table, row) => driver.insert(table, row));
|
|
36
40
|
}
|
|
37
41
|
static async connect(driver, url, opts) {
|
|
38
42
|
const db = new Db(driver, url, opts);
|
|
@@ -80,10 +84,56 @@ export class Db {
|
|
|
80
84
|
out.push(...(await this.#driver.insert(table, row)));
|
|
81
85
|
return out;
|
|
82
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Insert a row that satisfies the schema, giving only the columns the test cares about.
|
|
89
|
+
* Required columns get a value of their type (enums and `CHECK (... IN (...))` their first
|
|
90
|
+
* allowed value) and required foreign keys a parent row made the same way.
|
|
91
|
+
*
|
|
92
|
+
* ```ts
|
|
93
|
+
* const order = await db.make("orders", { status: "paid" }); // also creates the customer it needs
|
|
94
|
+
* ```
|
|
95
|
+
*/
|
|
96
|
+
async make(table, overrides = {}) {
|
|
97
|
+
return (await this.#factory.make(table, overrides));
|
|
98
|
+
}
|
|
99
|
+
/** `count` rows made like `make()`; `overrides` may depend on the index. */
|
|
100
|
+
async makeMany(table, count, overrides = {}) {
|
|
101
|
+
const out = [];
|
|
102
|
+
for (let i = 0; i < count; i++)
|
|
103
|
+
out.push(await this.make(table, typeof overrides === "function" ? overrides(i) : overrides));
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
/** @internal Set by the runtime when `db.queries` is on. */
|
|
107
|
+
attachQueryLog(log) {
|
|
108
|
+
this.#queryLog = log;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* SQL the app ran during this scenario, or only while `fn` ran. Needs `db: { queries: true }`.
|
|
112
|
+
* The test's own `db` calls aren't included.
|
|
113
|
+
*
|
|
114
|
+
* ```ts
|
|
115
|
+
* const queries = await db.queries(() => http.get("/posts"));
|
|
116
|
+
* expect(queries.repeated()).toEqual([]); // no statement shape ran 3+ times: no N+1
|
|
117
|
+
* expect(queries.length).toBeLessThanOrEqual(3);
|
|
118
|
+
* ```
|
|
119
|
+
*/
|
|
120
|
+
async queries(fn) {
|
|
121
|
+
const log = this.#queryLog;
|
|
122
|
+
if (!log)
|
|
123
|
+
throw new Error("slicetest: db.queries() needs `db: { queries: true }` in the config (Postgres or MySQL); the app then connects through a proxy that records its SQL.");
|
|
124
|
+
if (!fn)
|
|
125
|
+
return log.queries();
|
|
126
|
+
const mark = log.mark();
|
|
127
|
+
await fn();
|
|
128
|
+
// Let statements already on the wire arrive.
|
|
129
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
130
|
+
return log.queries(mark);
|
|
131
|
+
}
|
|
83
132
|
/** Empty every data table without dropping the app's connections, then re-apply the seed. */
|
|
84
133
|
async reset() {
|
|
85
134
|
this.#tables ??= await this.#listTables();
|
|
86
135
|
await this.#driver.truncate(this.#tables);
|
|
136
|
+
this.#factory.reset();
|
|
87
137
|
this.#start = undefined;
|
|
88
138
|
this.#checkpoint = undefined;
|
|
89
139
|
if (this.#seed) {
|
package/dist/drivers/driver.d.ts
CHANGED
|
@@ -13,6 +13,31 @@ export interface Table {
|
|
|
13
13
|
/** Primary-key columns, in order; empty when there is none. */
|
|
14
14
|
key: string[];
|
|
15
15
|
}
|
|
16
|
+
export interface Column {
|
|
17
|
+
name: string;
|
|
18
|
+
/** The declared type as the engine reports it, e.g. `character varying(20)`, `int`, `TEXT`. */
|
|
19
|
+
type: string;
|
|
20
|
+
nullable: boolean;
|
|
21
|
+
/** Has a default, is generated, an identity or auto-increment column: inserts may leave it out. */
|
|
22
|
+
hasDefault: boolean;
|
|
23
|
+
maxLength?: number;
|
|
24
|
+
/** Labels of an enum type. */
|
|
25
|
+
values?: string[];
|
|
26
|
+
}
|
|
27
|
+
export interface ForeignKey {
|
|
28
|
+
columns: string[];
|
|
29
|
+
/** Referenced table, named like `Table.name`. */
|
|
30
|
+
table: string;
|
|
31
|
+
/** Referenced columns; empty means the referenced table's primary key (SQLite). */
|
|
32
|
+
references: string[];
|
|
33
|
+
}
|
|
34
|
+
/** What `db.make()` needs to build a valid row. */
|
|
35
|
+
export interface TableShape {
|
|
36
|
+
columns: Column[];
|
|
37
|
+
foreignKeys: ForeignKey[];
|
|
38
|
+
/** Bodies of CHECK constraints, as the engine prints them. */
|
|
39
|
+
checks: string[];
|
|
40
|
+
}
|
|
16
41
|
export interface Driver {
|
|
17
42
|
query<T extends Row = Row>(sql: string, params?: unknown[]): Promise<T[]>;
|
|
18
43
|
/** Several parameterless SELECTs in one round trip. */
|
|
@@ -33,6 +58,8 @@ export interface Driver {
|
|
|
33
58
|
truncate(tables: Table[]): Promise<void>;
|
|
34
59
|
/** Insert one row and return it as stored (defaults and generated ids filled in). */
|
|
35
60
|
insert(table: string, row: Row): Promise<Row[]>;
|
|
61
|
+
/** Columns, foreign keys and checks of `table`. Throws when there is no such table. */
|
|
62
|
+
describe(table: string): Promise<TableShape>;
|
|
36
63
|
close(): Promise<void>;
|
|
37
64
|
}
|
|
38
65
|
/** Server-level operations: the databases slicetest creates for templates and workers. */
|
package/dist/drivers/mysql.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Driver, Engine, Row, Table } from "./driver.js";
|
|
1
|
+
import type { Driver, Engine, Row, Table, TableShape } from "./driver.js";
|
|
2
2
|
export declare class MysqlDriver implements Driver {
|
|
3
3
|
#private;
|
|
4
4
|
private readonly conn;
|
|
@@ -24,6 +24,7 @@ export declare class MysqlDriver implements Driver {
|
|
|
24
24
|
truncate(tables: Table[]): Promise<void>;
|
|
25
25
|
/** MySQL has no RETURNING: insert, then read the row back by its primary key. */
|
|
26
26
|
insert(table: string, row: Row): Promise<Row[]>;
|
|
27
|
+
describe(table: string): Promise<TableShape>;
|
|
27
28
|
close(): Promise<void>;
|
|
28
29
|
}
|
|
29
30
|
export declare const mysqlEngine: Engine;
|