slicetest 0.3.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 +211 -7
- package/dist/auth.d.ts +45 -0
- package/dist/auth.js +128 -0
- package/dist/ci.d.ts +29 -0
- package/dist/ci.js +57 -0
- package/dist/cli.js +31 -3
- package/dist/config.d.ts +25 -2
- package/dist/config.js +25 -4
- package/dist/db.d.ts +26 -0
- package/dist/db.js +50 -0
- package/dist/doctor.d.ts +22 -0
- package/dist/doctor.js +190 -0
- package/dist/drivers/driver.d.ts +29 -0
- package/dist/drivers/index.js +2 -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 +24 -0
- package/dist/drivers/sqlite.js +255 -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/global-setup.js +24 -3
- package/dist/http.d.ts +13 -0
- package/dist/http.js +24 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +2 -0
- package/dist/init.js +123 -5
- package/dist/mail.d.ts +58 -0
- package/dist/mail.js +299 -0
- package/dist/matchers.d.ts +4 -0
- package/dist/matchers.js +33 -0
- package/dist/openapi.d.ts +9 -0
- package/dist/openapi.js +29 -1
- package/dist/provided.d.ts +1 -0
- package/dist/query-log.d.ts +49 -0
- package/dist/query-log.js +261 -0
- package/dist/record-cli.d.ts +6 -0
- package/dist/record-cli.js +93 -0
- package/dist/record-session.d.ts +1 -0
- package/dist/record-session.js +10 -0
- package/dist/record.d.ts +46 -0
- package/dist/record.js +201 -0
- package/dist/runtime.d.ts +10 -0
- package/dist/runtime.js +62 -3
- package/dist/stub.d.ts +32 -0
- package/dist/stub.js +87 -0
- package/dist/trace.d.ts +9 -1
- package/dist/trace.js +3 -1
- package/dist/vitest.js +5 -2
- package/dist/webhook.d.ts +40 -0
- package/dist/webhook.js +52 -0
- package/dist/yaml-runtime.d.ts +1 -0
- package/dist/yaml-runtime.js +92 -15
- package/dist/yaml.d.ts +59 -1
- package/dist/yaml.js +88 -5
- package/package.json +11 -4
- package/schema/scenario.schema.json +329 -1
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
Tests that sit between unit tests and end-to-end tests, for apps written in any language or framework.
|
|
6
6
|
|
|
7
|
-
slicetest starts your app as a real process, points it at a real Postgres (or MySQL) and at stub servers for the services it calls, and lets you check all three sides in one scenario:
|
|
7
|
+
slicetest starts your app as a real process, points it at a real Postgres (or MySQL, or SQLite) and at stub servers for the services it calls, and lets you check all three sides in one scenario:
|
|
8
8
|
|
|
9
9
|
```ts
|
|
10
10
|
import { expect } from "vitest";
|
|
@@ -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. 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
|
|
|
@@ -48,7 +48,15 @@ npx slicetest # starts Postgres, migrates, starts your app, runs scenario
|
|
|
48
48
|
- **Whole-scenario snapshots.** `expect(await trace()).toMatchSnapshot()` pins the responses, the outbound calls and the database changes in one reviewable file, with dates and UUIDs masked.
|
|
49
49
|
- **Record the real service once, replay forever.** Point a stub at the real API with `SLICETEST_RECORD=1`, commit the YAML it writes, and later runs are offline and deterministic.
|
|
50
50
|
- **OpenAPI coverage** of your own API, per operation and status, across all scenarios, and `slicetest gen --uncovered` to scaffold scenarios for what's missing.
|
|
51
|
-
- **
|
|
51
|
+
- **Record instead of write.** `npx slicetest record` puts a proxy in front of the app: click through a flow, press Enter, and get a replayable YAML scenario with the stubs' answers, the responses, captured ids and the database changes.
|
|
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
|
+
- **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
|
+
- **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.
|
|
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.
|
|
52
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.
|
|
53
61
|
|
|
54
62
|
## Install
|
|
@@ -57,7 +65,7 @@ npx slicetest # starts Postgres, migrates, starts your app, runs scenario
|
|
|
57
65
|
npm i -D slicetest vitest
|
|
58
66
|
```
|
|
59
67
|
|
|
60
|
-
You also need Docker or Podman. slicetest finds a running Podman machine on its own (on Windows too). Alternatively, pass `db.url` or set `SLICETEST_DATABASE_URL` to use an existing Postgres server, for example a CI service container.
|
|
68
|
+
You also need Docker or Podman (`npx slicetest doctor` checks). slicetest finds a running Podman machine on its own (on Windows too). Alternatively, pass `db.url` or set `SLICETEST_DATABASE_URL` to use an existing Postgres server, for example a CI service container.
|
|
61
69
|
|
|
62
70
|
### MySQL
|
|
63
71
|
|
|
@@ -69,6 +77,19 @@ npm i -D mysql2 @testcontainers/mysql # the second is only needed without db.u
|
|
|
69
77
|
|
|
70
78
|
Everything works the same: `mysql:8.4` in a container, a migrated template cloned per worker (tables, foreign keys, views and triggers; stored routines are not copied), a `TRUNCATE` reset that only touches tables that were written to, and `db.*` helpers whose rows look like Postgres's (`BOOLEAN` as `true`/`false`, `BIGINT` ids as numbers, `DATETIME` in UTC). Only the SQL you write yourself differs: `?` placeholders in `db.query` and YAML `sql` steps. `db.schemas` defaults to the database in the URL. `SLICETEST_DATABASE_URL` is only used by projects on the same engine as its scheme, so a CI job can provide one Postgres server while a MySQL project starts its own container.
|
|
71
79
|
|
|
80
|
+
### SQLite
|
|
81
|
+
|
|
82
|
+
Set `db: { engine: "sqlite" }`. There's nothing to install and no container: slicetest uses Node's built-in `node:sqlite` (Node.js 22.5 or later), creates the database files in a temporary directory, migrates a template once and gives each worker a copy (`VACUUM INTO`). Point the app at the file:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
slicetest({
|
|
86
|
+
app: { command: "python app.py", env: { PORT: "{{app.port}}", DATABASE_PATH: "{{db.path}}" } },
|
|
87
|
+
db: { engine: "sqlite", migrate: { sql: "schema.sql" } },
|
|
88
|
+
});
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`{{db.url}}` is `sqlite:///absolute/path.db` (the form SQLAlchemy, dj-database-url and many others read) and `{{db.path}}` the plain path. The files are in WAL mode, so the app keeps its connection while slicetest resets tables between scenarios (`DELETE` plus resetting `AUTOINCREMENT` counters, foreign keys off for that moment only). `db.changes()`, `trace()` and every other helper work the same; SQLite has no boolean type, so booleans you insert are stored and read back as `1` / `0`. Migrations with `sql`, `command` or Atlas (`sqlite://` URLs). `db.url`, `db.image` and `SLICETEST_DATABASE_URL` don't apply. With the default `reuse`, the migrated template stays in the system temp directory between runs.
|
|
92
|
+
|
|
72
93
|
Works on macOS, Linux and Windows. On Windows the app's process tree is stopped with `taskkill /T`, and `app.command` / `db.migrate.command` run through `cmd.exe`.
|
|
73
94
|
|
|
74
95
|
## Configure
|
|
@@ -170,6 +191,18 @@ await db.query("UPDATE users SET name = $1", ["b"]);
|
|
|
170
191
|
|
|
171
192
|
In `where`, `null` means `IS NULL` and an array means `IN (...)`.
|
|
172
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
|
+
|
|
173
206
|
#### `db.changes()` — assert on everything the app wrote
|
|
174
207
|
|
|
175
208
|
Instead of guessing which tables to query, ask for the diff. Rows are matched by primary key, so updates show which columns changed:
|
|
@@ -188,6 +221,26 @@ expect(await db.changes()).toEqual({
|
|
|
188
221
|
|
|
189
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.
|
|
190
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
|
+
|
|
191
244
|
### `stub(name)` — fake the services the app calls
|
|
192
245
|
|
|
193
246
|
```ts
|
|
@@ -259,6 +312,21 @@ To fail the run below a threshold, use `openapi: { spec: "openapi.yaml", minCove
|
|
|
259
312
|
|
|
260
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`.
|
|
261
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
|
+
|
|
262
330
|
### Recording a real service
|
|
263
331
|
|
|
264
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:
|
|
@@ -283,6 +351,7 @@ Registered automatically:
|
|
|
283
351
|
|
|
284
352
|
```ts
|
|
285
353
|
expect(res).toHaveStatus(201); // failure shows the response body
|
|
354
|
+
expect(responses).toHaveStatuses({ 201: 1, 409: 9 }); // an array, e.g. from http.concurrently()
|
|
286
355
|
expect(stub("slack")).toHaveReceived("POST", "/hook", { json: { text: "hi" } });
|
|
287
356
|
expect(stub("slack")).toHaveReceivedTimes(1, "POST", "/hook");
|
|
288
357
|
expect(stub("mail")).not.toHaveReceived("POST", "/send");
|
|
@@ -338,6 +407,88 @@ slicetest({
|
|
|
338
407
|
|
|
339
408
|
`{{container.<name>}}` is `host:port`; `.host` and `.port` are there too. The container is ready when its port accepts connections, or when it prints `ready: { log }`. In a scenario, `container("cache").exec(["redis-cli", "GET", "hits"])` runs a command inside it and returns its stdout.
|
|
340
409
|
|
|
410
|
+
### Races: many requests at once
|
|
411
|
+
|
|
412
|
+
Double bookings, lost updates and duplicate charges only show up when requests overlap. `http.concurrently(n, send)` prepares `n` requests and releases them together, against the real database, and `toHaveStatuses` checks how they were answered:
|
|
413
|
+
|
|
414
|
+
```ts
|
|
415
|
+
scenario("ten people booking the same seat: one gets it", async ({ http, db }) => {
|
|
416
|
+
const responses = await http.concurrently(10, () => http.post("/bookings", { seat: 7 }));
|
|
417
|
+
expect(responses).toHaveStatuses({ 201: 1, 409: 9 });
|
|
418
|
+
await expect(db).toHaveRow("bookings", { seat: 7 }, 1);
|
|
419
|
+
});
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
`send` gets the request's index, for variations. When the counts are off, the failure shows one response per status. In YAML, `concurrency: 10` on a `request` step does the same, with `expect: { statuses: { 201: 1, 409: 9 } }`.
|
|
423
|
+
|
|
424
|
+
### Mail: catch what the app sends
|
|
425
|
+
|
|
426
|
+
`mail: true` starts an SMTP server for the app to send to (plain SMTP, no TLS, any username and password accepted). Point the app's mail settings at it, and read what arrived in the scenario, already decoded (encoded subjects, quoted-printable and base64 parts, multipart text and HTML):
|
|
427
|
+
|
|
428
|
+
```ts
|
|
429
|
+
slicetest({
|
|
430
|
+
mail: true,
|
|
431
|
+
app: { command: "node server.js", env: { SMTP_HOST: "{{mail.host}}", SMTP_PORT: "{{mail.port}}" } },
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
scenario("sign-up sends a confirmation link that works", async ({ http, mail }) => {
|
|
435
|
+
await http.post("/signup", { json: { email: "alice@example.com" } });
|
|
436
|
+
const message = await mail.waitFor({ to: "alice@example.com", subject: "Confirm" });
|
|
437
|
+
expect((await http.get(message.links[0]!)).status).toBe(200);
|
|
438
|
+
});
|
|
439
|
+
```
|
|
440
|
+
|
|
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.
|
|
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
|
+
|
|
341
492
|
### Asynchronous side effects
|
|
342
493
|
|
|
343
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:
|
|
@@ -389,15 +540,18 @@ Scenarios in one file share an app and a database, so they always run one at a t
|
|
|
389
540
|
| `app.cwd` | vitest root | |
|
|
390
541
|
| `app.ready` | `{ path: "/" }` | Poll a path until it answers below 500, or `{ log: "listening" \| /regex/ }`. |
|
|
391
542
|
| `app.readyTimeout` | `30000` | |
|
|
392
|
-
| `db.engine` | `postgres`, or `mysql` for a `mysql://` URL | `postgres
|
|
543
|
+
| `db.engine` | `postgres`, or `mysql` for a `mysql://` URL | `postgres`, `mysql` (see [MySQL](#mysql)) or `sqlite` (see [SQLite](#sqlite)). |
|
|
393
544
|
| `db.migrate` | none | `{ atlas: { dir } }`, `{ sql: "file-or-dir" }` or `{ command, inputs? }` (gets `DATABASE_URL`). |
|
|
394
545
|
| `db.seed` | none | SQL file re-run after every reset. |
|
|
395
546
|
| `db.schemas` | `["public"]` | Schemas whose tables are reset. |
|
|
396
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). |
|
|
397
549
|
| `db.url` | `$SLICETEST_DATABASE_URL`, else a container | Use an existing Postgres server (e.g. a CI service container) instead of Testcontainers. |
|
|
398
550
|
| `db.image` | `postgres:17-alpine` / `mysql:8.4` | |
|
|
399
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. |
|
|
400
552
|
| `containers` | `{}` | Dependencies as containers: `{ name: { image, port, env?, command?, ready?: { log }, reset? } }`. See [Containers](#containers-redis-search-s3-and-other-dependencies). |
|
|
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). |
|
|
401
555
|
| `services` | `{}` | Other processes: `{ name: { command, env?, cwd?, ready?, readyTimeout? } }`. Without `ready` a service is not waited for. |
|
|
402
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. |
|
|
403
557
|
| `openapi` | none | The app's OpenAPI 3 spec, or `{ spec, minCoverage }`. Every response must match it; the run ends with a coverage report. |
|
|
@@ -452,18 +606,24 @@ scenarios:
|
|
|
452
606
|
| Step | Keys |
|
|
453
607
|
|---|---|
|
|
454
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}}`. |
|
|
455
|
-
| `request: METHOD /path` | `headers`, `query`, one of `json` / `form` / `body`, `follow`, `expect: { status, headers, json, text }`, `capture` |
|
|
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. |
|
|
456
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()` |
|
|
457
615
|
| `db: <table>` | `where`, `orderBy`, `expect: { rows, count }`, `capture` |
|
|
458
616
|
| `sql: <query>` | `params`, `expect: { rows, count }`, `capture` |
|
|
459
617
|
| `received: <stub>` | `call: METHOD /path`, `when`, `times` (exact; default at least once) |
|
|
460
618
|
| `log: <regex>` | `from` (a service; default the app), `within` (ms, default 5000). Waits for a matching line printed during the scenario. |
|
|
461
619
|
| `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. |
|
|
462
620
|
| `checkpoint: true` | Later `changes` steps only see what happens after this step. |
|
|
621
|
+
| `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. |
|
|
463
622
|
| `snapshot: true` | The scenario's [trace](#snapshot-the-whole-scenario-trace) so far must match its stored snapshot. `mask: [keys]` hides more values. |
|
|
464
623
|
|
|
465
624
|
`db`, `sql`, `received` and `changes` steps take `within: <ms>` to retry until they pass, for effects the app applies asynchronously.
|
|
466
625
|
|
|
626
|
+
- `request:` also takes a captured URL of the app, e.g. `GET {{link}}` after capturing a link from a mail.
|
|
467
627
|
- `{{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.
|
|
468
628
|
- Expected `json`, `rows` and `headers` are subsets: extra keys are fine. `{ $type: number }`, `{ $regex: "^ch_" }`, `{ $contains: "..." }` and `{ $any: true }` match loosely.
|
|
469
629
|
- A file-level `setup:` list runs at the start of every scenario. `skip`, `only` and `timeout` work per scenario.
|
|
@@ -499,8 +659,52 @@ npx slicetest gen --uncovered # only the documented responses the last run d
|
|
|
499
659
|
|
|
500
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`.
|
|
501
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
|
+
|
|
502
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.
|
|
503
665
|
|
|
666
|
+
### Record a scenario by using the app: `npx slicetest record`
|
|
667
|
+
|
|
668
|
+
The fastest way to a first scenario is to do the thing once. `record` starts the database, the stubs and the app exactly as a test run would, plus a proxy in front of the app:
|
|
669
|
+
|
|
670
|
+
```
|
|
671
|
+
$ npx slicetest record
|
|
672
|
+
Recording. Use the app through http://127.0.0.1:52301 (it forwards to http://127.0.0.1:52288).
|
|
673
|
+
Everything it does is captured: responses, calls to stubs, database changes.
|
|
674
|
+
Press Enter (or Ctrl+C) to finish and write the scenario.
|
|
675
|
+
|
|
676
|
+
Wrote scenarios/recorded-20261001-091500.scenario.yaml: 3 request(s), 1 stub route(s), changes in polls, votes.
|
|
677
|
+
Replay it with: npx slicetest scenarios/recorded-20261001-091500.scenario.yaml
|
|
678
|
+
```
|
|
679
|
+
|
|
680
|
+
Point a browser, curl, Postman or a mobile build at the proxy URL and go through the flow. The scenario it writes has:
|
|
681
|
+
|
|
682
|
+
- a `stub` step for every answer a stubbed service gave (from `autoReply`, or a real service with `upstream` and `SLICETEST_RECORD`), so the replay needs neither;
|
|
683
|
+
- a `request` step per request with the status and body to expect, where dates and UUIDs only have to be strings, and a `capture` for any value that a later request reuses (`POST /orders` → `GET /orders/{{id}}`);
|
|
684
|
+
- `received` steps for the calls the app made to stubs, and a closing `changes` step with the rows written per table.
|
|
685
|
+
|
|
686
|
+
Requests for scripts, styles and images are left out. The file starts with a comment of what to review: it is a starting point, and the assertions that matter to you are yours to tighten. `--out` names the file, `--port` fixes the proxy's port.
|
|
687
|
+
|
|
688
|
+
### On GitHub Actions
|
|
689
|
+
|
|
690
|
+
Nothing to configure. When `GITHUB_ACTIONS` is set, a failing YAML step is annotated on its own line of the `.scenario.yaml` file in the pull request (Vitest already does this for TypeScript tests), and the job summary gets a table of the failed steps and the OpenAPI coverage table, with ✅ / ❌ per documented response. A coverage below `minCoverage` is annotated on the spec file.
|
|
691
|
+
|
|
692
|
+
### Is everything in place? `npx slicetest doctor`
|
|
693
|
+
|
|
694
|
+
Checks what a run needs before it starts, instead of failing with a timeout halfway: the config, the container runtime (or the database server at `db.url` / `SLICETEST_DATABASE_URL`, with the password hidden), the migrations, seed and working directories, the `atlas` CLI, `mysql2` for MySQL, the programs the app and services start, the OpenAPI files and missing recordings. Each problem says what to do, and the exit code is 1 when something must be fixed, so it also works as the first step of a CI job.
|
|
695
|
+
|
|
696
|
+
```
|
|
697
|
+
✓ Node.js 24.13.0
|
|
698
|
+
✓ config slicetest.config.yaml
|
|
699
|
+
✗ no container runtime
|
|
700
|
+
Could not find a working container runtime strategy. Start Docker or a Podman machine (`podman machine start`), or set SLICETEST_DATABASE_URL / db.url to an existing database server
|
|
701
|
+
✓ migrations migrations
|
|
702
|
+
! stub github: no recordings yet (recordings/github.yaml)
|
|
703
|
+
run once with SLICETEST_RECORD=github to record https://api.github.com
|
|
704
|
+
|
|
705
|
+
1 problem(s) to fix before running.
|
|
706
|
+
```
|
|
707
|
+
|
|
504
708
|
## Examples
|
|
505
709
|
|
|
506
710
|
`examples/` has a Node app (`node:http` + `pg`) and a Python app (`http.server` + `psycopg`) with the same API. **The same scenario files (`polls.test.ts` and `polls.scenario.yaml`) run against both**:
|
|
@@ -516,4 +720,4 @@ npm run test:dist # the built package, and the CLI with examples/slicetest.con
|
|
|
516
720
|
|
|
517
721
|
## Status
|
|
518
722
|
|
|
519
|
-
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/ci.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Actions output. Vitest already annotates failing TypeScript tests;
|
|
3
|
+
* YAML scenarios fail inside slicetest's runtime, so their annotations point at
|
|
4
|
+
* the `.scenario.yaml` line here instead. The OpenAPI coverage table also goes
|
|
5
|
+
* to the job summary.
|
|
6
|
+
*/
|
|
7
|
+
export declare const onGitHub: (env?: NodeJS.ProcessEnv) => boolean;
|
|
8
|
+
export interface YamlFailure {
|
|
9
|
+
/** Absolute path of the scenario file. */
|
|
10
|
+
file: string;
|
|
11
|
+
line: number;
|
|
12
|
+
scenario: string;
|
|
13
|
+
step: string;
|
|
14
|
+
message: string;
|
|
15
|
+
}
|
|
16
|
+
/** `::error file=…,line=…,title=…::message`, escaped as the runner expects. */
|
|
17
|
+
export declare function annotation(level: "error" | "warning", message: string, props?: {
|
|
18
|
+
file?: string;
|
|
19
|
+
line?: number;
|
|
20
|
+
title?: string;
|
|
21
|
+
}): string;
|
|
22
|
+
/** Paths in annotations are relative to the repository checkout. */
|
|
23
|
+
export declare function repoPath(file: string, env?: NodeJS.ProcessEnv): string;
|
|
24
|
+
/** Called in a worker: one JSON line per failed YAML step, printed by the main process at the end. */
|
|
25
|
+
export declare function recordYamlFailure(dir: string, failure: YamlFailure): Promise<void>;
|
|
26
|
+
export declare function yamlFailures(dir: string): Promise<YamlFailure[]>;
|
|
27
|
+
export declare function failureAnnotations(failures: YamlFailure[], env?: NodeJS.ProcessEnv): string[];
|
|
28
|
+
export declare function failureSummary(failures: YamlFailure[], env?: NodeJS.ProcessEnv): string;
|
|
29
|
+
export declare function appendSummary(markdown: string, env?: NodeJS.ProcessEnv): Promise<void>;
|
package/dist/ci.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { appendFile, readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* GitHub Actions output. Vitest already annotates failing TypeScript tests;
|
|
5
|
+
* YAML scenarios fail inside slicetest's runtime, so their annotations point at
|
|
6
|
+
* the `.scenario.yaml` line here instead. The OpenAPI coverage table also goes
|
|
7
|
+
* to the job summary.
|
|
8
|
+
*/
|
|
9
|
+
export const onGitHub = (env = process.env) => env.GITHUB_ACTIONS === "true";
|
|
10
|
+
/** `::error file=…,line=…,title=…::message`, escaped as the runner expects. */
|
|
11
|
+
export function annotation(level, message, props = {}) {
|
|
12
|
+
const prop = (v) => v.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A").replace(/:/g, "%3A").replace(/,/g, "%2C");
|
|
13
|
+
const data = message.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
|
|
14
|
+
const list = Object.entries(props)
|
|
15
|
+
.filter(([, v]) => v !== undefined && v !== "")
|
|
16
|
+
.map(([k, v]) => `${k}=${prop(String(v))}`)
|
|
17
|
+
.join(",");
|
|
18
|
+
return `::${level}${list ? ` ${list}` : ""}::${data}`;
|
|
19
|
+
}
|
|
20
|
+
/** Paths in annotations are relative to the repository checkout. */
|
|
21
|
+
export function repoPath(file, env = process.env) {
|
|
22
|
+
return path.relative(env.GITHUB_WORKSPACE ?? process.cwd(), file).replace(/\\/g, "/");
|
|
23
|
+
}
|
|
24
|
+
/** Called in a worker: one JSON line per failed YAML step, printed by the main process at the end. */
|
|
25
|
+
export async function recordYamlFailure(dir, failure) {
|
|
26
|
+
await appendFile(path.join(dir, `${process.pid}.jsonl`), `${JSON.stringify(failure)}\n`);
|
|
27
|
+
}
|
|
28
|
+
export async function yamlFailures(dir) {
|
|
29
|
+
const out = [];
|
|
30
|
+
for (const f of await readdir(dir).catch(() => [])) {
|
|
31
|
+
for (const line of (await readFile(path.join(dir, f), "utf8")).split("\n"))
|
|
32
|
+
if (line)
|
|
33
|
+
out.push(JSON.parse(line));
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
export function failureAnnotations(failures, env = process.env) {
|
|
38
|
+
return failures.map((f) => annotation("error", f.message, { file: repoPath(f.file, env), line: f.line, title: `${f.scenario}: ${f.step}` }));
|
|
39
|
+
}
|
|
40
|
+
export function failureSummary(failures, env = process.env) {
|
|
41
|
+
if (failures.length === 0)
|
|
42
|
+
return "";
|
|
43
|
+
const cell = (s) => s.replace(/\|/g, "\\|").replace(/\r?\n/g, "<br>");
|
|
44
|
+
return [
|
|
45
|
+
`### slicetest: ${failures.length} failed YAML step(s)`,
|
|
46
|
+
"",
|
|
47
|
+
"| Where | Scenario | Step | Error |",
|
|
48
|
+
"|---|---|---|---|",
|
|
49
|
+
...failures.map((f) => `| \`${repoPath(f.file, env)}:${f.line}\` | ${cell(f.scenario)} | ${cell(f.step)} | ${cell(f.message.split("\n")[0].slice(0, 300))} |`),
|
|
50
|
+
"",
|
|
51
|
+
].join("\n");
|
|
52
|
+
}
|
|
53
|
+
export async function appendSummary(markdown, env = process.env) {
|
|
54
|
+
if (!markdown || !env.GITHUB_STEP_SUMMARY)
|
|
55
|
+
return;
|
|
56
|
+
await appendFile(env.GITHUB_STEP_SUMMARY, `${markdown}\n`).catch(() => { });
|
|
57
|
+
}
|